Compare commits
5 Commits
21b158b3b6
...
issue-271-
| Author | SHA1 | Date | |
|---|---|---|---|
| 73db8eee43 | |||
| 9dcd875dd4 | |||
| c755a5e944 | |||
| d5595c0151 | |||
| e4c3708b84 |
43
README.md
43
README.md
@@ -169,6 +169,34 @@ reserve while sitting on the same side of the estimate, so swapping the two in
|
||||
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
||||
previously correct by reading only.
|
||||
|
||||
It also covers the **dApp approval round trips** — the one place where the
|
||||
content script, the inpage provider, the background worker and the approval
|
||||
popup all have to work together. A local test page is served by the route
|
||||
handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
|
||||
`MAIN`-world content script like any other page, and drives
|
||||
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
|
||||
`eth_sendTransaction` through the real prompts. Every signature is recovered in
|
||||
the runner and compared against the active address, the transaction assertions
|
||||
run against the raw signed transaction 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, 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).
|
||||
|
||||
Three limits of that coverage, none of them papered over. The RPC is stubbed
|
||||
throughout, so this is **not** a real dApp against a real network with real
|
||||
funds; that remains a human pass before 1.0.0. The site-connection prompt is
|
||||
raised through `chrome.action.openPopup()`, and headless Chromium's
|
||||
browser-action popup is not a page Playwright can see or click, so that one
|
||||
prompt is driven at the URL the extension itself puts on the action — the same
|
||||
page and the same approval id, but whether a real toolbar click shows it is not
|
||||
observable here. And the EIP-1193 error code does not survive the last hop: the
|
||||
rejection that crosses the boundary carries code 4001 and is asserted to, but
|
||||
`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
|
||||
catches an error with no `code` property.
|
||||
|
||||
Any test that drives a failure path on purpose declares the `console.error` it
|
||||
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
|
||||
consumes exactly one matching record, and a declaration nothing matched fails
|
||||
@@ -610,15 +638,6 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
|
||||
so the popup can never reopen onto one of them with no password prompt in front
|
||||
of it.
|
||||
|
||||
A reopened popup renders the wallet list and the one screen it restores onto,
|
||||
and nothing else, so every screen on the stack behind that one is still the
|
||||
blank template from `index.html`. "Back" therefore renders its target rather
|
||||
than only unhiding it, through the same dispatch and data guards as the restore
|
||||
(`src/popup/viewRouter.js`), and falls back to Home when the state the target
|
||||
would render is gone. Forward navigation renders as it goes and does not go
|
||||
through that dispatch: rendering a screen a second time would re-fetch and
|
||||
clobber whatever it has in flight.
|
||||
|
||||
Every screen that holds secret material in the page registers a cleanup with
|
||||
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
|
||||
exit from that screen rather than only on its "Back" button, so nothing secret
|
||||
@@ -1144,7 +1163,11 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
||||
opening the window, so the screen shows a complete transaction and the signed
|
||||
artifact can be compared with it field for field. A request that cannot be
|
||||
populated — unreachable node, reverting gas estimate — opens no window and is
|
||||
failed back to the site.
|
||||
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 EIP-1193 code `-32002` rather than being
|
||||
populated at the same nonce. It opens no window and takes no nonce, and the
|
||||
site can send it again once the pending one is answered.
|
||||
- **Elements**:
|
||||
- "Transaction Request" heading
|
||||
- Phishing warning banner (shown when the hostname is on the phishing
|
||||
|
||||
83
TODO.md
83
TODO.md
@@ -45,19 +45,76 @@ undefined identifiers, which is how
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-12: "Back" now renders the screen it lands on instead of only unhiding
|
||||
it. A reopened popup renders the wallet list and the one screen it restores
|
||||
onto, so every screen further down the stack was still the blank template from
|
||||
`index.html`, and Back walked straight onto it — an empty address, no
|
||||
balances, no QR code. The Back path now goes through the same per-view
|
||||
dispatch and data guards as the restore (`src/popup/viewRouter.js`, shared
|
||||
with `restoreView()`), falling back to Home when the state the target would
|
||||
render is gone, and declining any view the popup does not render from
|
||||
persisted state, which can only be on the stack from the current page load and
|
||||
was rendered on the way in. Forward navigation is untouched, so nothing
|
||||
renders twice. Covered by unit tests on the real `goBack()` and by two
|
||||
end-to-end cases against the real popup, both demonstrated failing on the
|
||||
unfixed build ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
|
||||
- 2026-08-14: One transaction approval at a time. Populating in the background
|
||||
before the window opens is what makes the displayed object the verified
|
||||
object, and it also fixes the nonce: two `eth_sendTransaction` calls populated
|
||||
concurrently took the same nonce from a node that had seen neither broadcast,
|
||||
and the second could then never be sent, because the only way to give it a
|
||||
fresh nonce is to populate it again after the user has read the old one off
|
||||
the screen. A second request is now refused with EIP-1193 `-32002` while one
|
||||
is unanswered — before anything is populated, so no second nonce is allocated
|
||||
and no second window opens — and the slot is freed when the page has its
|
||||
answer. Signature approvals are not gated, consuming no nonce. A collision
|
||||
that does happen is also reported accurately now: a broadcast the node refused
|
||||
for the nonce, and an approval carrying a nonce this worker has already
|
||||
broadcast (caught before the node is asked at all), both say the transaction
|
||||
did not reach the network and to send it again, instead of warning that it may
|
||||
have sent. `already known` deliberately keeps the ambiguous wording, because a
|
||||
node that says it has the transaction has it
|
||||
([#271](https://git.eeqj.de/sneak/AutistMask/issues/271)).
|
||||
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
|
||||
rebuilt every failure as `new Error(error.message)`, so the code the
|
||||
background produced and the content script relayed intact was dropped in the
|
||||
last hop and a dApp checking `err.code === 4001` saw `undefined` — a wallet
|
||||
the user deliberately declined was indistinguishable from one that broke. The
|
||||
provider now rejects with a `ProviderRpcError` carrying `code` and, where the
|
||||
boundary sent one, `data`, passed through verbatim rather than matched against
|
||||
a list, so 4001, 4100 and 4902 all arrive and a future code needs no edit
|
||||
here. An error the background sent with no code stays a plain `Error` with no
|
||||
`code` property, and `message` is unchanged in every case. All four request
|
||||
entry points (`request`, `enable`, `send`, `sendAsync`) are covered by
|
||||
`tests/inpageErrors.test.js`, and the e2e probe that printed the missing code
|
||||
now requires it on the page's Error as well as on the wire, for all four
|
||||
rejected flows ([#274](https://git.eeqj.de/sneak/AutistMask/issues/274)).
|
||||
- 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses
|
||||
that bear it, not to one of them. A ticker is not unique: seven of the 512
|
||||
bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC` —
|
||||
share a symbol with another bundled entry at a different real contract, and
|
||||
the table, built from the list first-wins, kept only the earlier one. The
|
||||
other seven were judged spoofs of their own symbol at their own address and
|
||||
hidden from the balance list, the history and the send selector, so a holder
|
||||
could not spend them. Both contracts of each pair come from the same CoinGecko
|
||||
fetch of 2026-02-27, so neither was stale and neither was dropped.
|
||||
`isSpoofedSymbol()` asks set membership instead of equality, which does not
|
||||
loosen the rule — a contract outside the set is still a spoof — and a test now
|
||||
walks `TOKENS` asserting no bundled token is filtered at its own address,
|
||||
which is the walk the suite lacked
|
||||
([#276](https://git.eeqj.de/sneak/AutistMask/issues/276)).
|
||||
- 2026-08-12: The dApp approval round trips are driven end to end in the
|
||||
browser. A test page served by the harness speaks EIP-1193 to the real inpage
|
||||
provider through the real content script, background worker and approval popup
|
||||
for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
|
||||
`eth_sendTransaction`. Every signature is recovered and compared against the
|
||||
active address, the transaction is checked against the bytes handed to the
|
||||
stubbed RPC, each rejection must reach the page as a rejection, and the
|
||||
password must appear in no message the approval window sends — the assertion
|
||||
that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent
|
||||
floor. This does not discharge a real dApp with real funds against mainnet
|
||||
([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)).
|
||||
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
|
||||
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
|
||||
character that paints nothing removed (the format and default-ignorable
|
||||
characters, plus U+007F), then trimmed — so `" ETH "`, a no-break space, a
|
||||
zero-width space, a Hangul filler, a variation selector, a DELETE and a
|
||||
fullwidth `ETH` are all caught on the balance list, the history and the
|
||||
send selector at once. Confusables that are distinct letters (Cyrillic `Е`),
|
||||
bidi reordering and the visible C0/C1 controls — which measure 48.00px, a box,
|
||||
in the pinned e2e Chromium where an invisible prefix measures 32.00px — stay
|
||||
knowingly open and are asserted as open in the suite. No bundled symbol
|
||||
contains whitespace or a non-ASCII character, so nothing legitimate is newly
|
||||
filtered; the balance list's token-type gate also became case-insensitive,
|
||||
which no longer drops a real holding if an explorer writes `erc-20`
|
||||
([#260](https://git.eeqj.de/sneak/AutistMask/issues/260)).
|
||||
- 2026-08-12: A containerized Firefox end-to-end harness
|
||||
(`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2
|
||||
build installed as a temporary add-on. Zero npm dependencies — a WebDriver
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_INFLIGHT,
|
||||
TX_STAGE_NONCE,
|
||||
} = require("../shared/approvalVerify");
|
||||
const { prepareApprovalTx } = require("../shared/approvalTx");
|
||||
const {
|
||||
@@ -57,6 +58,81 @@ const connectedSites = {};
|
||||
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||
const pendingApprovals = {};
|
||||
|
||||
// One transaction approval at a time, wallet-wide.
|
||||
//
|
||||
// The transaction a site asks for is populated before its approval window
|
||||
// opens, so that the object the user is shown is the object the signed
|
||||
// artifact is verified against. Populating fixes the nonce. Two requests
|
||||
// populated concurrently therefore take the SAME nonce — the node reports the
|
||||
// same pending count to both, neither having been broadcast — and whichever is
|
||||
// broadcast second is refused by the network for a nonce it can never be
|
||||
// re-signed at, because re-signing it would mean signing something other than
|
||||
// what was displayed.
|
||||
//
|
||||
// So the second request is refused while the first is unanswered. It is
|
||||
// refused before anything is populated, so no second nonce is allocated at
|
||||
// all, and while the page is still waiting with nothing on screen. The
|
||||
// alternatives were considered and rejected in
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/271: populating again at Confirm
|
||||
// puts a nonce on screen that is not the nonce that gets signed, and
|
||||
// allocating around in-flight approvals makes the wallet's own bookkeeping the
|
||||
// authority on a nonce the network has not accepted, which an abandoned
|
||||
// approval then leaves a hole in.
|
||||
//
|
||||
// Sign approvals are not gated: a signature consumes no nonce.
|
||||
let txApprovalSlotHeld = false;
|
||||
|
||||
// EIP-1474 "resource unavailable": the standard code for a request that is
|
||||
// refused because another one is already pending.
|
||||
const TX_APPROVAL_PENDING_CODE = -32002;
|
||||
|
||||
const TX_APPROVAL_PENDING_MESSAGE =
|
||||
"Another transaction is already waiting to be approved in AutistMask," +
|
||||
" so this one was not sent. Please answer that request, then send this" +
|
||||
" one again.";
|
||||
|
||||
// Take the slot, or refuse. Called before the first await of the
|
||||
// eth_sendTransaction handler, so two requests arriving in the same tick
|
||||
// cannot both pass it.
|
||||
function reserveTxApprovalSlot() {
|
||||
if (txApprovalSlotHeld) return false;
|
||||
txApprovalSlotHeld = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function releaseTxApprovalSlot() {
|
||||
txApprovalSlotHeld = false;
|
||||
}
|
||||
|
||||
// Nonces this worker has already handed to the node, per address. This is the
|
||||
// wallet's own knowledge that a nonce is spent, and it is checked before a
|
||||
// broadcast rather than after: a node's pending count can lag a transaction it
|
||||
// has itself just accepted, and a request populated inside that window would
|
||||
// otherwise be signed and sent at a nonce this wallet has already used.
|
||||
//
|
||||
// The record dies with the worker, which is correct rather than merely
|
||||
// convenient: after a restart the node's count is the only answer available,
|
||||
// and a transaction of this wallet's that the node has forgotten is one the
|
||||
// user does want to be able to send again.
|
||||
const broadcastNonces = {};
|
||||
|
||||
function broadcastNoncesFor(address) {
|
||||
const key = String(address || "").toLowerCase();
|
||||
if (!broadcastNonces[key]) broadcastNonces[key] = new Set();
|
||||
return broadcastNonces[key];
|
||||
}
|
||||
|
||||
// An approved transaction's nonce as a decimal string, or null if it cannot be
|
||||
// read as a number. Verification refuses an unreadable nonce before this is
|
||||
// ever reached; null here only keeps the record from holding junk.
|
||||
function approvedNonce(approvedTx) {
|
||||
try {
|
||||
return BigInt(approvedTx.nonce).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getState() {
|
||||
const result = await storageApi.get("autistmask");
|
||||
return (
|
||||
@@ -585,68 +661,24 @@ async function handleRpc(method, params, origin) {
|
||||
}
|
||||
|
||||
if (method === "eth_sendTransaction") {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
!allowed.includes(hostname) &&
|
||||
!connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
}
|
||||
|
||||
const txParams = params?.[0] || {};
|
||||
if (namesAnotherAddress(txParams.from, activeAddress)) {
|
||||
// Synchronous, before any await: two requests delivered in the same
|
||||
// tick must not both get past this.
|
||||
if (!reserveTxApprovalSlot()) {
|
||||
return {
|
||||
error: {
|
||||
code: 4100,
|
||||
message:
|
||||
"This site asked to send from an address that is not the active one.",
|
||||
code: TX_APPROVAL_PENDING_CODE,
|
||||
message: TX_APPROVAL_PENDING_MESSAGE,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Populate here, before any window opens, so that the transaction the
|
||||
// user is shown is a complete one and is the same object the signed
|
||||
// artifact is checked against. A failure raises no approval at all and
|
||||
// is reported to the requesting page; see approvalTx.js.
|
||||
let approvedTx;
|
||||
try {
|
||||
approvedTx = await prepareApprovalTx(
|
||||
getProvider(await getRpcUrl()),
|
||||
activeAddress,
|
||||
txParams,
|
||||
);
|
||||
} catch (e) {
|
||||
return { error: { message: e.message } };
|
||||
return await handleSendTransaction(params, origin);
|
||||
} finally {
|
||||
// Held until the page has its answer — the approval was broadcast,
|
||||
// rejected, or retired by a closed window — because until then its
|
||||
// nonce is allocated and unspent.
|
||||
releaseTxApprovalSlot();
|
||||
}
|
||||
|
||||
// Population is a network round trip, and the user can switch address
|
||||
// during it. Raising the approval anyway would put an account on the
|
||||
// screen that the wallet is no longer on, and it could never be signed
|
||||
// — the signing handler refuses exactly that. Refuse it here instead,
|
||||
// while the page is still waiting and nothing has been displayed.
|
||||
if (!sameAddress(await getActiveAddress(), activeAddress)) {
|
||||
return {
|
||||
error: {
|
||||
message:
|
||||
"The active address changed while this transaction was being prepared, so it was not sent.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const decision = await requestTxApproval(
|
||||
origin,
|
||||
hostname,
|
||||
approvedTx,
|
||||
activeAddress,
|
||||
);
|
||||
if (decision.error) return { error: decision.error };
|
||||
return { result: decision.txHash };
|
||||
}
|
||||
|
||||
// Proxy safe read-only methods to the RPC node
|
||||
@@ -662,6 +694,73 @@ async function handleRpc(method, params, origin) {
|
||||
return { error: { message: "Unsupported method: " + method } };
|
||||
}
|
||||
|
||||
// The body of eth_sendTransaction, from the connection check through to the
|
||||
// user's decision. Its caller holds the single transaction-approval slot for
|
||||
// as long as this runs.
|
||||
async function handleSendTransaction(params, origin) {
|
||||
const s = await getState();
|
||||
const activeAddress = await getActiveAddress();
|
||||
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
!allowed.includes(hostname) &&
|
||||
!connectedSites[origin + ":" + activeAddress]
|
||||
) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
}
|
||||
|
||||
const txParams = params?.[0] || {};
|
||||
if (namesAnotherAddress(txParams.from, activeAddress)) {
|
||||
return {
|
||||
error: {
|
||||
code: 4100,
|
||||
message:
|
||||
"This site asked to send from an address that is not the active one.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Populate here, before any window opens, so that the transaction the
|
||||
// user is shown is a complete one and is the same object the signed
|
||||
// artifact is checked against. A failure raises no approval at all and
|
||||
// is reported to the requesting page; see approvalTx.js.
|
||||
let approvedTx;
|
||||
try {
|
||||
approvedTx = await prepareApprovalTx(
|
||||
getProvider(await getRpcUrl()),
|
||||
activeAddress,
|
||||
txParams,
|
||||
);
|
||||
} catch (e) {
|
||||
return { error: { message: e.message } };
|
||||
}
|
||||
|
||||
// Population is a network round trip, and the user can switch address
|
||||
// during it. Raising the approval anyway would put an account on the
|
||||
// screen that the wallet is no longer on, and it could never be signed
|
||||
// — the signing handler refuses exactly that. Refuse it here instead,
|
||||
// while the page is still waiting and nothing has been displayed.
|
||||
if (!sameAddress(await getActiveAddress(), activeAddress)) {
|
||||
return {
|
||||
error: {
|
||||
message:
|
||||
"The active address changed while this transaction was being prepared, so it was not sent.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const decision = await requestTxApproval(
|
||||
origin,
|
||||
hostname,
|
||||
approvedTx,
|
||||
activeAddress,
|
||||
);
|
||||
if (decision.error) return { error: decision.error };
|
||||
return { result: decision.txHash };
|
||||
}
|
||||
|
||||
// Broadcast chainChanged to all tabs when the network is switched.
|
||||
function broadcastChainChanged(chainId) {
|
||||
tabsApi.query({}, (tabs) => {
|
||||
@@ -959,7 +1058,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_SIGN,
|
||||
stage: outcome.stage,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -1019,7 +1118,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_VERIFY,
|
||||
stage: outcome.stage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// A nonce this worker has already broadcast for this address. The
|
||||
// node is not asked: it has answered once already, and the wallet
|
||||
// holding the receipt of that answer is what makes this failure
|
||||
// one the user can be told did not reach the network.
|
||||
const nonce = approvedNonce(approval.approvedTx);
|
||||
const spent = broadcastNoncesFor(approval.approvedFrom);
|
||||
if (nonce !== null && spent.has(nonce)) {
|
||||
const outcome = describeTxFailure(TX_STAGE_NONCE, null);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{ error: { message: outcome.error } },
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: outcome.stage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1027,6 +1147,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||
if (nonce !== null) spent.add(nonce);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{ txHash: tx.hash },
|
||||
@@ -1039,6 +1160,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
// tell a transaction that never left from one already in the
|
||||
// mempool. The page has been given its outcome for this
|
||||
// request; a second attempt would report a second one.
|
||||
//
|
||||
// Unless the node blamed the nonce, which is the one answer
|
||||
// that says plainly it did not take the transaction:
|
||||
// describeTxFailure() reclassifies that, and the stage it
|
||||
// returns is the one reported.
|
||||
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
|
||||
settleApproval(
|
||||
msg.id,
|
||||
@@ -1048,7 +1174,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
sendResponse({
|
||||
error: outcome.error,
|
||||
retryable: outcome.retryable,
|
||||
stage: TX_STAGE_BROADCAST,
|
||||
stage: outcome.stage,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -11,6 +11,39 @@
|
||||
let nextId = 1;
|
||||
const pending = {};
|
||||
|
||||
// EIP-1193 ProviderRpcError: `code`, `message`, optional `data`. A class
|
||||
// rather than properties bolted onto an Error because this object crosses
|
||||
// no boundary after construction — it is built in the page's own realm and
|
||||
// handed straight to the caller's catch — so the prototype survives and
|
||||
// `error.name` is a stable thing for a dApp to see.
|
||||
class ProviderRpcError extends Error {
|
||||
constructor(code, message, data) {
|
||||
super(message);
|
||||
this.name = "ProviderRpcError";
|
||||
this.code = code;
|
||||
if (data !== undefined) this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild a boundary error as the error the page catches, carrying the
|
||||
// code (and data) the extension reported. Without this a dApp cannot tell
|
||||
// a user's refusal (4001) from a wallet that broke, and retries or shows
|
||||
// an error instead of accepting the refusal.
|
||||
//
|
||||
// Whatever code arrived is passed through verbatim rather than being
|
||||
// matched against a list: the extension emits 4001, 4100 and 4902 today,
|
||||
// and a code this file has never heard of is still the truth about what
|
||||
// happened. An error reported with no code at all stays a plain Error —
|
||||
// a ProviderRpcError whose `code` is undefined would advertise a
|
||||
// conformance it does not have. `message` is untouched in every case.
|
||||
function toPageError(error) {
|
||||
const message = (error && error.message) || "Request failed";
|
||||
if (error && error.code !== undefined && error.code !== null) {
|
||||
return new ProviderRpcError(error.code, message, error.data);
|
||||
}
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
// Listen for responses from the content script
|
||||
window.addEventListener("message", function onUuid(event) {
|
||||
if (event.source !== window) return;
|
||||
@@ -20,7 +53,7 @@
|
||||
if (!p) return;
|
||||
delete pending[id];
|
||||
if (error) {
|
||||
p.reject(new Error(error.message || "Request failed"));
|
||||
p.reject(toPageError(error));
|
||||
} else {
|
||||
p.resolve(result);
|
||||
}
|
||||
|
||||
@@ -9,17 +9,16 @@ const {
|
||||
$,
|
||||
showView,
|
||||
updateDebugBanner,
|
||||
setBackRenderer,
|
||||
setRenderMain,
|
||||
pushCurrentView,
|
||||
goBack,
|
||||
clearViewStack,
|
||||
} = require("./views/helpers");
|
||||
const { applyTheme } = require("./theme");
|
||||
// Renders a view the popup lands on without having navigated to it forward:
|
||||
// on restore here, and on Back. Only the views that can be fully re-rendered
|
||||
// from persisted state (RESTORABLE_VIEWS, src/popup/restorableViews.js) go
|
||||
// through it; anything else falls back to the nearest restorable parent.
|
||||
const { renderView, makeBackRenderer } = require("./viewRouter");
|
||||
// Views that can be fully re-rendered from persisted state. All others fall
|
||||
// back to the nearest restorable parent; see the module for why the
|
||||
// secret-bearing views are absent.
|
||||
const { RESTORABLE_VIEWS } = require("./restorableViews");
|
||||
|
||||
const home = require("./views/home");
|
||||
const welcome = require("./views/welcome");
|
||||
@@ -109,22 +108,91 @@ const ctx = {
|
||||
},
|
||||
};
|
||||
|
||||
// The view modules the router renders through, keyed as it expects them.
|
||||
const viewModules = {
|
||||
main: { show: () => fallbackView() },
|
||||
addressDetail,
|
||||
addressToken,
|
||||
receive,
|
||||
settings,
|
||||
settingsAddToken,
|
||||
confirmTx,
|
||||
transactionDetail,
|
||||
txStatus,
|
||||
};
|
||||
function needsAddress(view) {
|
||||
return (
|
||||
view === "address" ||
|
||||
view === "address-token" ||
|
||||
view === "receive" ||
|
||||
view === "transaction"
|
||||
);
|
||||
}
|
||||
|
||||
function hasValidAddress() {
|
||||
return (
|
||||
state.selectedWallet !== null &&
|
||||
state.selectedAddress !== null &&
|
||||
state.wallets[state.selectedWallet] &&
|
||||
state.wallets[state.selectedWallet].addresses[state.selectedAddress]
|
||||
);
|
||||
}
|
||||
|
||||
function restoreView() {
|
||||
if (!renderView(state.currentView, state, viewModules)) {
|
||||
fallbackView();
|
||||
const view = state.currentView;
|
||||
if (!view || !RESTORABLE_VIEWS.has(view)) {
|
||||
return fallbackView();
|
||||
}
|
||||
|
||||
if (needsAddress(view) && !hasValidAddress()) {
|
||||
return fallbackView();
|
||||
}
|
||||
|
||||
if (view === "address-token" && !state.selectedToken) {
|
||||
return fallbackView();
|
||||
}
|
||||
|
||||
switch (view) {
|
||||
case "address":
|
||||
addressDetail.show();
|
||||
break;
|
||||
case "address-token":
|
||||
addressToken.show();
|
||||
break;
|
||||
case "receive":
|
||||
receive.show();
|
||||
break;
|
||||
case "settings":
|
||||
settings.show();
|
||||
break;
|
||||
case "settings-addtoken":
|
||||
settingsAddToken.show();
|
||||
break;
|
||||
case "confirm-tx":
|
||||
if (state.viewData && state.viewData.pendingTx) {
|
||||
confirmTx.restore();
|
||||
} else {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "transaction":
|
||||
if (state.viewData && state.viewData.tx) {
|
||||
transactionDetail.render();
|
||||
} else {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "wait-tx":
|
||||
// Resumes the receipt poll from the persisted broadcast time.
|
||||
if (!txStatus.restoreWait()) {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "success-tx":
|
||||
if (state.viewData && state.viewData.hash) {
|
||||
txStatus.renderSuccess();
|
||||
} else {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
case "error-tx":
|
||||
if (state.viewData && state.viewData.message) {
|
||||
txStatus.renderError();
|
||||
} else {
|
||||
fallbackView();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
fallbackView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +247,7 @@ async function init() {
|
||||
settings.show();
|
||||
});
|
||||
|
||||
setBackRenderer(makeBackRenderer(state, viewModules));
|
||||
setRenderMain(renderWalletList);
|
||||
|
||||
welcome.init(ctx);
|
||||
addWallet.init(ctx);
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// Rendering a view the popup lands on without having navigated to it
|
||||
// forward: on restore, and on Back. In both cases the view may never have
|
||||
// been rendered in this page load — a reopened popup renders only the
|
||||
// wallet list and the view it restores onto, so every other view is still
|
||||
// the blank static template from index.html — so unhiding it is not enough.
|
||||
//
|
||||
// Forward navigation renders as it goes and must NOT come through here:
|
||||
// rendering a second time would re-fetch and clobber whatever the view has
|
||||
// in flight.
|
||||
//
|
||||
// The view modules are injected and nothing here touches the DOM, so the
|
||||
// dispatch and its data guards can be tested directly; src/popup/index.js
|
||||
// cannot be required outside a browser.
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("./restorableViews");
|
||||
|
||||
// Views that render an address the user picked and cannot be rendered
|
||||
// without one.
|
||||
const ADDRESS_VIEWS = new Set([
|
||||
"address",
|
||||
"address-token",
|
||||
"receive",
|
||||
"transaction",
|
||||
]);
|
||||
|
||||
function needsAddress(view) {
|
||||
return ADDRESS_VIEWS.has(view);
|
||||
}
|
||||
|
||||
function hasValidAddress(state) {
|
||||
return Boolean(
|
||||
state.selectedWallet !== null &&
|
||||
state.selectedAddress !== null &&
|
||||
state.wallets[state.selectedWallet] &&
|
||||
state.wallets[state.selectedWallet].addresses[state.selectedAddress],
|
||||
);
|
||||
}
|
||||
|
||||
// Render `view` from persisted state. Each view module shows itself, so a
|
||||
// true return means the view is both rendered and on screen.
|
||||
//
|
||||
// Returns false when the view is not one the popup renders from state, or
|
||||
// when the state it would render is gone — a token no longer selected, a
|
||||
// transaction no longer persisted. The caller falls back rather than
|
||||
// putting an empty template on screen.
|
||||
function renderView(view, state, views) {
|
||||
if (!view || !RESTORABLE_VIEWS.has(view)) return false;
|
||||
if (needsAddress(view) && !hasValidAddress(state)) return false;
|
||||
if (view === "address-token" && !state.selectedToken) return false;
|
||||
|
||||
const data = state.viewData || {};
|
||||
switch (view) {
|
||||
case "main":
|
||||
views.main.show();
|
||||
return true;
|
||||
case "address":
|
||||
views.addressDetail.show();
|
||||
return true;
|
||||
case "address-token":
|
||||
views.addressToken.show();
|
||||
return true;
|
||||
case "receive":
|
||||
views.receive.show();
|
||||
return true;
|
||||
case "settings":
|
||||
views.settings.show();
|
||||
return true;
|
||||
case "settings-addtoken":
|
||||
views.settingsAddToken.show();
|
||||
return true;
|
||||
case "confirm-tx":
|
||||
if (!data.pendingTx) return false;
|
||||
views.confirmTx.restore();
|
||||
return true;
|
||||
case "transaction":
|
||||
if (!data.tx) return false;
|
||||
views.transactionDetail.render();
|
||||
return true;
|
||||
case "wait-tx":
|
||||
// Resumes the receipt poll from the persisted broadcast time,
|
||||
// and answers false when there is nothing resumable left.
|
||||
return Boolean(views.txStatus.restoreWait());
|
||||
case "success-tx":
|
||||
if (!data.hash) return false;
|
||||
views.txStatus.renderSuccess();
|
||||
return true;
|
||||
case "error-tx":
|
||||
if (!data.message) return false;
|
||||
views.txStatus.renderError();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The Back-path renderer, registered with setBackRenderer() in
|
||||
// views/helpers.js. Returns false for a view the popup does not render from
|
||||
// persisted state, leaving goBack() to unhide it: the restored stack is
|
||||
// filtered against RESTORABLE_VIEWS, so such a view can only be on the
|
||||
// stack from this page load, where forward navigation already rendered it.
|
||||
function makeBackRenderer(state, views) {
|
||||
return function renderBack(view) {
|
||||
if (!RESTORABLE_VIEWS.has(view)) return false;
|
||||
if (!renderView(view, state, views)) {
|
||||
views.main.show();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderView,
|
||||
makeBackRenderer,
|
||||
needsAddress,
|
||||
hasValidAddress,
|
||||
};
|
||||
@@ -111,19 +111,12 @@ function updateDebugBanner(viewName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Callback that renders a view being navigated BACK onto. Set once by
|
||||
// index.js via setBackRenderer(), which routes the view through the same
|
||||
// per-view render and data guards restoreView() uses.
|
||||
//
|
||||
// It answers true when it took the navigation — the view is rendered and
|
||||
// shown, or its backing data was gone and it fell back — and false for a
|
||||
// view the popup does not render from persisted state. Those can only be
|
||||
// on the stack from this page load, because the stack is filtered on load,
|
||||
// so they have already been rendered and only need unhiding.
|
||||
let _renderBack = null;
|
||||
// Callback to re-render the main/home view when navigating back to it.
|
||||
// Set once by index.js via setRenderMain().
|
||||
let _renderMain = null;
|
||||
|
||||
function setBackRenderer(fn) {
|
||||
_renderBack = fn;
|
||||
function setRenderMain(fn) {
|
||||
_renderMain = fn;
|
||||
}
|
||||
|
||||
// Push the current view onto the navigation stack so goBack() can
|
||||
@@ -143,11 +136,9 @@ function goBack() {
|
||||
} else {
|
||||
target = "main";
|
||||
}
|
||||
// A popped view is landed on, not navigated to. If the popup has been
|
||||
// closed and reopened since the view was pushed, nothing has ever
|
||||
// rendered it in this page load and its template is still blank, so it
|
||||
// has to be rendered here rather than merely unhidden.
|
||||
if (_renderBack && _renderBack(target)) return;
|
||||
if (target === "main" && _renderMain) {
|
||||
_renderMain();
|
||||
}
|
||||
showView(target);
|
||||
}
|
||||
|
||||
@@ -479,7 +470,7 @@ module.exports = {
|
||||
showView,
|
||||
onViewLeave,
|
||||
updateDebugBanner,
|
||||
setBackRenderer,
|
||||
setRenderMain,
|
||||
pushCurrentView,
|
||||
goBack,
|
||||
clearViewStack,
|
||||
|
||||
@@ -602,6 +602,12 @@ const TX_STAGE_BROADCAST = "broadcast";
|
||||
// may yet succeed, so the one thing the popup must not say is "start again
|
||||
// from the site".
|
||||
const TX_STAGE_INFLIGHT = "inflight";
|
||||
// A transaction refused for a nonce that is already spoken for, either by the
|
||||
// node's own answer or by this wallet's record of what it has broadcast. It is
|
||||
// the one broadcast-stage failure that is not ambiguous: the transaction was
|
||||
// not taken, so the user is told it did not reach the network and to send it
|
||||
// again, rather than being warned that it might already be out there.
|
||||
const TX_STAGE_NONCE = "nonce";
|
||||
|
||||
function errorText(err) {
|
||||
if (typeof err === "string" && err !== "") return err;
|
||||
@@ -611,6 +617,59 @@ function errorText(err) {
|
||||
return "The transaction could not be sent.";
|
||||
}
|
||||
|
||||
// Every string a failure might carry its reason in. ethers reports the node's
|
||||
// own words in `shortMessage`, but a JSON-RPC error it could not classify is
|
||||
// nested under `error` or `info.error` with the node's message intact, and the
|
||||
// classification below has to see that too.
|
||||
function failureTexts(err) {
|
||||
if (typeof err === "string") return [err];
|
||||
if (!err || typeof err !== "object") return [];
|
||||
const texts = [];
|
||||
for (const text of [err.shortMessage, err.message, err.reason]) {
|
||||
if (text) texts.push(String(text));
|
||||
}
|
||||
const nested = err.error || (err.info && err.info.error);
|
||||
if (nested && nested.message) texts.push(String(nested.message));
|
||||
return texts;
|
||||
}
|
||||
|
||||
// What the Ethereum clients say when a transaction's nonce is already spoken
|
||||
// for: either it is below the account's next nonce, or another transaction is
|
||||
// sitting in the pool at that nonce and this one did not outbid it. Either way
|
||||
// the node answered, and its answer was that it did not take this transaction.
|
||||
//
|
||||
// "already known" is deliberately absent. A node that says it knows the
|
||||
// transaction has it, so that transaction did reach the network and the
|
||||
// ambiguous broadcast wording is the correct one for it.
|
||||
const NONCE_COLLISION_PATTERNS = [
|
||||
/nonce too low/i,
|
||||
/nonce has already been used/i,
|
||||
/invalid nonce/i,
|
||||
/oldnonce/i,
|
||||
/replacement transaction underpriced/i,
|
||||
/replacement fee too low/i,
|
||||
];
|
||||
|
||||
// ethers' own classification of the same two conditions.
|
||||
const NONCE_COLLISION_CODES = ["NONCE_EXPIRED", "REPLACEMENT_UNDERPRICED"];
|
||||
|
||||
// Whether a failed send is a nonce collision.
|
||||
function isNonceCollision(err) {
|
||||
if (!err) return false;
|
||||
if (err.code && NONCE_COLLISION_CODES.includes(err.code)) return true;
|
||||
return failureTexts(err).some((text) =>
|
||||
NONCE_COLLISION_PATTERNS.some((pattern) => pattern.test(text)),
|
||||
);
|
||||
}
|
||||
|
||||
// What both the requesting page and the popup are told about a nonce
|
||||
// collision. The node's own words ("nonce too low") are a fragment and are
|
||||
// replaced rather than passed through: they are not a sentence, and they say
|
||||
// less than the wallet knows.
|
||||
const NONCE_COLLISION_MESSAGE =
|
||||
"The transaction was not sent, because its nonce had already been used" +
|
||||
" by another transaction.";
|
||||
|
||||
// What the background does with a pending transaction approval after a failed
|
||||
// attempt: what it tells the popup, and whether the approval is spent
|
||||
// (resolved to the requesting page as an error and deleted) or left standing
|
||||
@@ -627,12 +686,31 @@ function errorText(err) {
|
||||
// that never left from one that is already in the mempool. The approval is
|
||||
// spent and the requesting page has been given its outcome; a second
|
||||
// attempt against it would report a second outcome for one request.
|
||||
// - nonce: terminal too, and the one case where the wallet does know the
|
||||
// transaction never left. The approval carries a nonce that is spent, so
|
||||
// the artifact signed against it can never be accepted and the user is told
|
||||
// to send it again from the site.
|
||||
//
|
||||
// The stage comes back out because a broadcast failure the node blamed on the
|
||||
// nonce is reclassified here; the caller reports the stage this returns rather
|
||||
// than the one it passed in.
|
||||
function describeTxFailure(stage, err) {
|
||||
if (
|
||||
stage === TX_STAGE_NONCE ||
|
||||
(stage === TX_STAGE_BROADCAST && isNonceCollision(err))
|
||||
) {
|
||||
return {
|
||||
error: NONCE_COLLISION_MESSAGE,
|
||||
retryable: false,
|
||||
spendApproval: true,
|
||||
stage: TX_STAGE_NONCE,
|
||||
};
|
||||
}
|
||||
const error = errorText(err);
|
||||
const retryable =
|
||||
stage === TX_STAGE_SIGN ||
|
||||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
|
||||
return { error, retryable, spendApproval: !retryable };
|
||||
return { error, retryable, spendApproval: !retryable, stage };
|
||||
}
|
||||
|
||||
// What the popup shows and does after the background reports a failed signing
|
||||
@@ -642,14 +720,20 @@ function describeTxFailure(stage, err) {
|
||||
//
|
||||
// A failed broadcast gets its own wording: the transaction may already be on
|
||||
// the network, so telling the user to start again from the site is exactly the
|
||||
// wrong instruction.
|
||||
// wrong instruction. A nonce collision is the exception to that exception —
|
||||
// the transaction demonstrably did not go out, and saying it might have would
|
||||
// send the user hunting for a transaction that does not exist.
|
||||
function describeSigningFailure(response, fallbackMessage) {
|
||||
let message = (response && response.error) || fallbackMessage;
|
||||
if (!/[.!?]$/.test(message)) message += ".";
|
||||
const retryable = !!(response && response.retryable);
|
||||
const stage = response && response.stage;
|
||||
if (!retryable) {
|
||||
if (stage === TX_STAGE_BROADCAST) {
|
||||
if (stage === TX_STAGE_NONCE) {
|
||||
message +=
|
||||
" The transaction did not reach the network." +
|
||||
" Please send it again from the site.";
|
||||
} else if (stage === TX_STAGE_BROADCAST) {
|
||||
message +=
|
||||
" The transaction may still have reached the network." +
|
||||
" Check the account before sending it again.";
|
||||
@@ -675,9 +759,11 @@ module.exports = {
|
||||
assertWithinCeilings,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
isNonceCollision,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
ApprovalMismatchError,
|
||||
NONCE_COLLISION_MESSAGE,
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
@@ -686,6 +772,7 @@ module.exports = {
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_INFLIGHT,
|
||||
TX_STAGE_NONCE,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
};
|
||||
|
||||
@@ -66,7 +66,12 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
||||
|
||||
const balances = [];
|
||||
for (const item of items) {
|
||||
if (item.token?.type !== "ERC-20") continue;
|
||||
// Case-insensitive: the token type is an explorer's label, not a
|
||||
// protocol value, and an exact comparison silently drops a real
|
||||
// holding if one ever writes "erc-20". Which types are admitted
|
||||
// is unchanged.
|
||||
const type = String(item.token?.type || "").toUpperCase();
|
||||
if (type !== "ERC-20") continue;
|
||||
const decimals = parseInt(item.token.decimals || "18", 10);
|
||||
const bal = formatTokenBalance(item.value || "0", decimals);
|
||||
if (bal === "0.0") continue;
|
||||
|
||||
@@ -8,11 +8,23 @@
|
||||
// either verdict alone, because the balance list is where the user forms
|
||||
// their belief about what they own (issue #235).
|
||||
//
|
||||
// KNOWN_SYMBOLS maps a symbol to the lowercased contract address that may
|
||||
// bear it, or to null. Null means the symbol belongs to the native asset,
|
||||
// which has no contract at all, so no contract may bear it and every one
|
||||
// that does is a spoof. "ETH" is the only such entry today; the rule is
|
||||
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
||||
// that may bear it, or to null. Null means the symbol belongs to the native
|
||||
// asset, which has no contract at all, so no contract may bear it and every
|
||||
// one that does is a spoof. "ETH" is the only such entry today; the rule is
|
||||
// written so that a second one needs no change here or at any call site.
|
||||
//
|
||||
// The value is a set because a ticker is not unique: seven symbols in the
|
||||
// bundled list belong to two real contracts each, and answering with one of
|
||||
// them hid the other one's holders' money (issue #276). Membership, not
|
||||
// equality, is therefore the question — but it is the same question, asked of
|
||||
// a table that can now state the truth. Every address in a set is one the
|
||||
// wallet ships as a real token; a contract outside the set is still a spoof.
|
||||
//
|
||||
// The symbol is attacker-controlled — it is whatever the ERC-20 contract
|
||||
// returns — so the lookup is done on a normalized form (issue #260): the
|
||||
// question is whether the symbol reaches the user's eye as a known one,
|
||||
// since that is what the user acts on.
|
||||
|
||||
const { KNOWN_SYMBOLS } = require("./tokenList");
|
||||
|
||||
@@ -22,6 +34,59 @@ function normalizeAddress(addr) {
|
||||
return (addr || "").toLowerCase();
|
||||
}
|
||||
|
||||
// Fold a symbol onto what a user actually sees, and no further:
|
||||
//
|
||||
// NFKC collapses compatibility variants that render as the ASCII
|
||||
// letters they imitate — fullwidth ETH, styled mathematical
|
||||
// letters — and maps the non-ASCII spaces onto U+0020.
|
||||
// strip drops what paints nothing: \p{Cf} plus
|
||||
// \p{Default_Ignorable_Code_Point} plus U+007F. That covers
|
||||
// the format characters (zero-width space, joiner and
|
||||
// non-joiner, word joiner, soft hyphen, byte-order mark, bidi
|
||||
// marks and overrides), the variation selectors, the Hangul
|
||||
// fillers, and DELETE. Removed everywhere, not merely at the
|
||||
// ends.
|
||||
// trim removes surrounding whitespace, which HTML collapses:
|
||||
// `" ETH "` is painted next to the user's real ETH as `ETH`.
|
||||
// toUpperCase makes the comparison case-insensitive, as before.
|
||||
//
|
||||
// The rule is "strip what paints nothing". The Unicode classes are how
|
||||
// that is spelled, not what it means, which is why U+007F is named on its
|
||||
// own: it is a control rather than a default-ignorable character, so no
|
||||
// class here reaches it, yet it paints nothing all the same. Measured in
|
||||
// the repo's pinned e2e Chromium (16px sans-serif, plain `ETH` = 32.00px,
|
||||
// so an invisible prefix leaves 32.00px):
|
||||
//
|
||||
// U+007F, U+3164, U+115F, U+FE0F, U+FE00 32.00px — invisible
|
||||
// U+FFA0 40.00px — a box
|
||||
// U+1160 48.00px — a box
|
||||
// U+0001, U+0085, U+0090 48.00px — a box
|
||||
//
|
||||
// U+1160 and U+FFA0 are `Default_Ignorable_Code_Point` members that font
|
||||
// fallback nonetheless draws, and they are stripped anyway: erring toward
|
||||
// hiding a token that does not look like `ETH` is the harmless direction of
|
||||
// the two. The other controls are left alone for the same reason read the
|
||||
// other way — a symbol carrying a visible box does not reach the eye as
|
||||
// `ETH`, so filtering it would hide a token the user could not have
|
||||
// confused with the native asset.
|
||||
//
|
||||
// Deliberately not folded, and asserted as open in tests/symbolSpoof.test.js:
|
||||
// interior whitespace (`E T H` renders as `E T H`, so folding it would filter
|
||||
// a token nobody could confuse with the native asset), confusables that are
|
||||
// distinct letters rather than compatibility variants (Cyrillic capital Ie,
|
||||
// U+0415; Greek capital Epsilon, U+0395), bidi reordering, which needs the
|
||||
// bidi algorithm rather than a character filter, and the visible controls.
|
||||
//
|
||||
// This decides only how the question is asked. Nothing here changes what a
|
||||
// surface displays; a token still shows the symbol it reports.
|
||||
function normalizeSymbol(symbol) {
|
||||
return String(symbol || "")
|
||||
.normalize("NFKC")
|
||||
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// True when a token bearing `symbol` from contract `contractAddress` is
|
||||
// impersonating a known symbol.
|
||||
//
|
||||
@@ -31,11 +96,11 @@ function normalizeAddress(addr) {
|
||||
function isSpoofedSymbol(symbol, contractAddress) {
|
||||
const contract = normalizeAddress(contractAddress);
|
||||
if (!contract) return false;
|
||||
const sym = (symbol || "").toUpperCase();
|
||||
const sym = normalizeSymbol(symbol);
|
||||
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
||||
const legit = KNOWN_SYMBOLS.get(sym);
|
||||
if (legit === null) return true;
|
||||
return contract !== normalizeAddress(legit);
|
||||
return !legit.has(contract);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -3607,14 +3607,33 @@ for (const t of TOKENS) {
|
||||
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
|
||||
}
|
||||
|
||||
// Build a map of symbol (uppercased) -> legitimate contract address (lowercased).
|
||||
// Used for spoofed-symbol detection. "ETH" maps to null (native token).
|
||||
// Build a map of symbol (uppercased) -> the set of contract addresses
|
||||
// (lowercased) that legitimately bear it. Used for spoofed-symbol detection.
|
||||
// "ETH" maps to null: the native asset has no contract, so no contract may
|
||||
// bear its symbol.
|
||||
//
|
||||
// The value is a set and not a single address because tickers are not unique
|
||||
// and the list above proves it: seven of these 512 tokens share a symbol with
|
||||
// another entry — FRAX, REUSD, TON, EURE, MSUSD, MUSD and JPYC — at two
|
||||
// different real contracts each, all of them from the same source fetch. A
|
||||
// one-address-per-symbol table can only answer that by picking a winner, and
|
||||
// the loser is then a token in our own bundled list that the spoof filter
|
||||
// hides from the balance list, the history and the send selector at its own
|
||||
// address, so the user cannot spend it (issue #276). Naming every address
|
||||
// that bears the symbol is the only shape that says what is true; it does not
|
||||
// loosen the rule, because a contract outside the set is still a spoof.
|
||||
const KNOWN_SYMBOLS = new Map();
|
||||
KNOWN_SYMBOLS.set("ETH", null);
|
||||
for (const t of TOKENS) {
|
||||
const upper = t.symbol.toUpperCase();
|
||||
if (!KNOWN_SYMBOLS.has(upper)) {
|
||||
KNOWN_SYMBOLS.set(upper, t.address.toLowerCase());
|
||||
KNOWN_SYMBOLS.set(upper, new Set());
|
||||
}
|
||||
const addresses = KNOWN_SYMBOLS.get(upper);
|
||||
// A null entry is the native asset and stays null: an ERC-20 that reports
|
||||
// the native symbol does not thereby become entitled to it.
|
||||
if (addresses !== null) {
|
||||
addresses.add(t.address.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ const {
|
||||
assertWithinCeilings,
|
||||
sameAddress,
|
||||
failureIsRetryable,
|
||||
isNonceCollision,
|
||||
describeTxFailure,
|
||||
describeSigningFailure,
|
||||
NONCE_COLLISION_MESSAGE,
|
||||
ALLOWED_TX_TYPES,
|
||||
SERIALIZED_FIELDS,
|
||||
FORBIDDEN_FIELDS,
|
||||
@@ -23,6 +25,7 @@ const {
|
||||
TX_STAGE_SIGN,
|
||||
TX_STAGE_VERIFY,
|
||||
TX_STAGE_BROADCAST,
|
||||
TX_STAGE_NONCE,
|
||||
MAX_GAS_LIMIT,
|
||||
MAX_FEE_PER_GAS,
|
||||
} = require("../src/shared/approvalVerify");
|
||||
@@ -1191,7 +1194,6 @@ describe("signing failure and retry", () => {
|
||||
"already known",
|
||||
"timeout of 30000ms exceeded",
|
||||
"could not coalesce error",
|
||||
"replacement transaction underpriced",
|
||||
]) {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_BROADCAST,
|
||||
@@ -1199,10 +1201,76 @@ describe("signing failure and retry", () => {
|
||||
);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
|
||||
expect(outcome.error).toBe(message);
|
||||
}
|
||||
});
|
||||
|
||||
// The one broadcast failure that is not ambiguous. The node answered, and
|
||||
// its answer was that the nonce was already spoken for, so this
|
||||
// transaction is not in a mempool anywhere.
|
||||
test("a nonce the node refused is classified however it was worded", () => {
|
||||
for (const err of [
|
||||
new Error("nonce too low"),
|
||||
new Error("replacement transaction underpriced"),
|
||||
Object.assign(new Error("could not coalesce error"), {
|
||||
code: "NONCE_EXPIRED",
|
||||
}),
|
||||
Object.assign(new Error("could not coalesce error"), {
|
||||
code: "REPLACEMENT_UNDERPRICED",
|
||||
}),
|
||||
// The shape ethers hands up when it could not classify the node's
|
||||
// error itself: the node's own words are nested underneath.
|
||||
Object.assign(new Error("could not coalesce error"), {
|
||||
info: { error: { code: -32000, message: "OldNonce" } },
|
||||
}),
|
||||
]) {
|
||||
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
|
||||
expect(
|
||||
describeSigningFailure(
|
||||
outcome,
|
||||
"The transaction could not be sent.",
|
||||
).message,
|
||||
).toMatch(/did not reach the network/);
|
||||
expect(outcome.retryable).toBe(false);
|
||||
expect(outcome.spendApproval).toBe(true);
|
||||
expect(outcome.error).toBe(NONCE_COLLISION_MESSAGE);
|
||||
expect(outcome.stage).toBe(TX_STAGE_NONCE);
|
||||
expect(isNonceCollision(err)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// A node that says it knows the transaction has it, so it did reach the
|
||||
// network and the ambiguous wording is the correct one.
|
||||
test("already known is not a nonce collision", () => {
|
||||
const err = new Error("already known");
|
||||
const outcome = describeTxFailure(TX_STAGE_BROADCAST, err);
|
||||
expect(
|
||||
describeSigningFailure(
|
||||
outcome,
|
||||
"The transaction could not be sent.",
|
||||
).message,
|
||||
).toMatch(/may still have reached the network/);
|
||||
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
|
||||
expect(isNonceCollision(err)).toBe(false);
|
||||
});
|
||||
|
||||
test("a nonce collision says the transaction did not reach the network", () => {
|
||||
const outcome = describeTxFailure(
|
||||
TX_STAGE_BROADCAST,
|
||||
new Error("nonce too low"),
|
||||
);
|
||||
const copy = describeSigningFailure(
|
||||
outcome,
|
||||
"The transaction could not be sent.",
|
||||
);
|
||||
expect(copy.retryable).toBe(false);
|
||||
expect(copy.message).toMatch(/did not reach the network/);
|
||||
expect(copy.message).not.toMatch(/may still have reached the network/);
|
||||
expect(copy.message).toMatch(/Please send it again from the site\.$/);
|
||||
expect(copy.message).toMatch(/^[A-Z].*\.$/);
|
||||
});
|
||||
|
||||
test("a failed broadcast does not tell the user to send it again", () => {
|
||||
const outcome = describeSigningFailure(
|
||||
{
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
// Back after reopening the popup (#268).
|
||||
//
|
||||
// A reopened popup renders the wallet list and the one view it restores
|
||||
// onto; every other view is still the blank static template from
|
||||
// index.html. goBack() used to only unhide its target, so Back landed on
|
||||
// that blank template for any view the popup had not rendered in this page
|
||||
// load. These tests drive the real goBack() with the real router wired to
|
||||
// recording view modules, so what is asserted is which view render ran —
|
||||
// the thing that was missing.
|
||||
//
|
||||
// The rendering itself is asserted against the real popup in a real
|
||||
// browser by tests/e2e/run.js; here the DOM is a stub, because goBack()
|
||||
// only needs showView() to work.
|
||||
|
||||
const els = new Map();
|
||||
|
||||
function fakeEl() {
|
||||
return {
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
classList: {
|
||||
toggle() {},
|
||||
add() {},
|
||||
remove() {},
|
||||
contains: () => false,
|
||||
},
|
||||
remove() {},
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.document = {
|
||||
getElementById(id) {
|
||||
if (!els.has(id)) els.set(id, fakeEl());
|
||||
return els.get(id);
|
||||
},
|
||||
};
|
||||
|
||||
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const {
|
||||
showView,
|
||||
goBack,
|
||||
setBackRenderer,
|
||||
pushCurrentView,
|
||||
} = require("../src/popup/views/helpers");
|
||||
const { makeBackRenderer } = require("../src/popup/viewRouter");
|
||||
const { state } = require("../src/shared/state");
|
||||
|
||||
const ADDRESS = "0x1111111111111111111111111111111111111111";
|
||||
const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||
|
||||
let calls;
|
||||
|
||||
// Stand-ins for the view modules. Each records itself and then shows its
|
||||
// view, which is what every real view render ends with — so the assertions
|
||||
// can tell "rendered and shown" apart from "merely unhidden".
|
||||
function recorder(name, view) {
|
||||
return () => {
|
||||
calls.push(name);
|
||||
showView(view);
|
||||
};
|
||||
}
|
||||
|
||||
function makeViews() {
|
||||
return {
|
||||
main: { show: recorder("main", "main") },
|
||||
addressDetail: { show: recorder("addressDetail", "address") },
|
||||
addressToken: { show: recorder("addressToken", "address-token") },
|
||||
receive: { show: recorder("receive", "receive") },
|
||||
settings: { show: recorder("settings", "settings") },
|
||||
settingsAddToken: {
|
||||
show: recorder("settingsAddToken", "settings-addtoken"),
|
||||
},
|
||||
confirmTx: { restore: recorder("confirmTx", "confirm-tx") },
|
||||
transactionDetail: {
|
||||
render: recorder("transactionDetail", "transaction"),
|
||||
},
|
||||
txStatus: {
|
||||
restoreWait: () => {
|
||||
calls.push("waitTx");
|
||||
showView("wait-tx");
|
||||
return true;
|
||||
},
|
||||
renderSuccess: recorder("successTx", "success-tx"),
|
||||
renderError: recorder("errorTx", "error-tx"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The popup as it stands just after a reopen: one wallet with one address,
|
||||
// the view the popup restored onto, and the stack behind it.
|
||||
function reopenedOn(view, stack, extra) {
|
||||
calls = [];
|
||||
state.wallets = [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
|
||||
},
|
||||
];
|
||||
state.selectedWallet = 0;
|
||||
state.selectedAddress = 0;
|
||||
state.selectedToken = null;
|
||||
state.viewData = null;
|
||||
state.currentView = view;
|
||||
state.viewStack = stack.slice();
|
||||
Object.assign(state, extra || {});
|
||||
setBackRenderer(makeBackRenderer(state, makeViews()));
|
||||
}
|
||||
|
||||
// The reproduction from the issue, step for step.
|
||||
describe("Back onto a view the reopened popup never rendered", () => {
|
||||
test("Back from settings renders the address detail underneath", () => {
|
||||
reopenedOn("settings", ["main", "address"]);
|
||||
goBack();
|
||||
expect(calls).toEqual(["addressDetail"]);
|
||||
expect(state.currentView).toBe("address");
|
||||
expect(state.viewStack).toEqual(["main"]);
|
||||
});
|
||||
|
||||
test("Back onto the token detail renders it", () => {
|
||||
reopenedOn("settings", ["main", "address", "address-token"], {
|
||||
selectedToken: TOKEN,
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["addressToken"]);
|
||||
expect(state.currentView).toBe("address-token");
|
||||
});
|
||||
|
||||
test("Back onto Receive renders it", () => {
|
||||
reopenedOn("settings", ["main", "address", "receive"]);
|
||||
goBack();
|
||||
expect(calls).toEqual(["receive"]);
|
||||
expect(state.currentView).toBe("receive");
|
||||
});
|
||||
|
||||
test("Back onto the transaction detail renders it", () => {
|
||||
reopenedOn("settings", ["main", "transaction"], {
|
||||
viewData: { tx: { hash: "0xdead" } },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["transactionDetail"]);
|
||||
expect(state.currentView).toBe("transaction");
|
||||
});
|
||||
|
||||
test("Back onto the transaction confirmation restores it", () => {
|
||||
reopenedOn("settings", ["main", "confirm-tx"], {
|
||||
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["confirmTx"]);
|
||||
expect(state.currentView).toBe("confirm-tx");
|
||||
});
|
||||
|
||||
test("Back onto Home renders the wallet list", () => {
|
||||
reopenedOn("settings", ["main"]);
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
|
||||
test("Back with an empty stack renders Home", () => {
|
||||
reopenedOn("settings", []);
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
// The guards are restoreView()'s, so a popped view whose backing data is
|
||||
// gone lands on Home rather than on an empty template.
|
||||
describe("Back onto a view whose backing data is gone", () => {
|
||||
test("the token detail with no token selected falls back to Home", () => {
|
||||
reopenedOn("settings", ["main", "address-token"]);
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
|
||||
test("the transaction detail with no transaction falls back to Home", () => {
|
||||
reopenedOn("settings", ["main", "transaction"]);
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
|
||||
test("the confirmation with no pending transaction falls back to Home", () => {
|
||||
reopenedOn("settings", ["main", "confirm-tx"]);
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
|
||||
test("an address view with no address selected falls back to Home", () => {
|
||||
reopenedOn("settings", ["main", "receive"], {
|
||||
selectedAddress: null,
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
|
||||
test("a wait that can no longer be resumed falls back to Home", () => {
|
||||
reopenedOn("settings", ["main", "wait-tx"]);
|
||||
const views = makeViews();
|
||||
views.txStatus.restoreWait = () => false;
|
||||
setBackRenderer(makeBackRenderer(state, views));
|
||||
goBack();
|
||||
expect(calls).toEqual(["main"]);
|
||||
expect(state.currentView).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
// Forward navigation renders as it goes. Re-rendering a second time would
|
||||
// re-fetch and clobber whatever the view has in flight, so the Back path is
|
||||
// the only place this happens — and it declines any view the popup does not
|
||||
// render from persisted state, since the restored stack is filtered against
|
||||
// that same set and such a view can only have been rendered in this page
|
||||
// load.
|
||||
describe("what the Back path leaves alone", () => {
|
||||
test("forward navigation renders nothing by itself", () => {
|
||||
reopenedOn("address", ["main"]);
|
||||
pushCurrentView();
|
||||
showView("send");
|
||||
expect(calls).toEqual([]);
|
||||
expect(state.viewStack).toEqual(["main", "address"]);
|
||||
});
|
||||
|
||||
test("Back onto a live-session view only unhides it", () => {
|
||||
reopenedOn("confirm-tx", ["main", "address", "send"]);
|
||||
goBack();
|
||||
expect(calls).toEqual([]);
|
||||
expect(state.currentView).toBe("send");
|
||||
});
|
||||
|
||||
test("Back renders its target exactly once", () => {
|
||||
reopenedOn("settings", ["main", "address"]);
|
||||
goBack();
|
||||
expect(calls.filter((c) => c === "addressDetail")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -206,6 +206,10 @@ function loadBackground(options) {
|
||||
// approval id back out of the popup URL the background opened.
|
||||
function requestTx(txParams) {
|
||||
let rpcResult = null;
|
||||
// The window this request opens, if it opens one. A request refused
|
||||
// before an approval is raised opens none, and the window belonging to
|
||||
// some other request must not be handed back as this one's.
|
||||
const windowIndex = created.length;
|
||||
const sendResponse = jest.fn((r) => {
|
||||
rpcResult = r;
|
||||
});
|
||||
@@ -219,7 +223,12 @@ function loadBackground(options) {
|
||||
sendResponse,
|
||||
);
|
||||
return {
|
||||
id: () => new URL(created[0].url).searchParams.get("approval"),
|
||||
id: () =>
|
||||
created.length > windowIndex
|
||||
? new URL(created[windowIndex].url).searchParams.get(
|
||||
"approval",
|
||||
)
|
||||
: null,
|
||||
result: () => rpcResult,
|
||||
};
|
||||
}
|
||||
@@ -444,6 +453,176 @@ describe("one approval, one broadcast", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Populating the transaction before the approval window opens is what makes
|
||||
// the displayed object the verified object. It also fixes the nonce before the
|
||||
// user has answered anything: two requests populated concurrently take the
|
||||
// same nonce from a node that has seen neither of them broadcast, and the
|
||||
// second can then never be sent, because the only way to give it a fresh nonce
|
||||
// is to populate it again after the user has read the old one off the screen.
|
||||
// So the second request is refused while the first is unanswered.
|
||||
describe("one transaction approval at a time", () => {
|
||||
test("a second eth_sendTransaction while one is pending is refused before it takes a nonce", async () => {
|
||||
const getTransactionCount = jest.fn(async () => NONCE);
|
||||
const bg = loadBackground({ provider: { getTransactionCount } });
|
||||
|
||||
const first = bg.requestTx();
|
||||
await settle();
|
||||
expect(first.id()).toBeTruthy();
|
||||
expect(getTransactionCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
expect(second.result()).toEqual({
|
||||
error: {
|
||||
code: -32002,
|
||||
message: expect.stringMatching(
|
||||
/already waiting to be approved/,
|
||||
),
|
||||
},
|
||||
});
|
||||
// Where the refusal happened matters as much as that it happened: no
|
||||
// second window, and the node was never asked for a second nonce.
|
||||
expect(bg.created).toHaveLength(1);
|
||||
expect(getTransactionCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The refusal leaves the pending approval untouched, and it still
|
||||
// sends.
|
||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: first.id(),
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(NONCE),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(first.result()).toEqual({ result: "0xfeed" });
|
||||
});
|
||||
|
||||
test("an answered approval frees the next request", async () => {
|
||||
const bg = loadBackground();
|
||||
const first = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
// The user closes the approval window, which rejects it.
|
||||
bg.closeWindow(1);
|
||||
await settle();
|
||||
expect(first.result()).toEqual({
|
||||
error: { code: 4001, message: "User rejected the request." },
|
||||
});
|
||||
|
||||
const second = bg.requestTx();
|
||||
await settle();
|
||||
expect(second.id()).toBeTruthy();
|
||||
expect(bg.created).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a signature request is not held up by a pending transaction", async () => {
|
||||
const bg = loadBackground();
|
||||
bg.requestTx();
|
||||
await settle();
|
||||
|
||||
// A signature consumes no nonce, so it has nothing to collide with.
|
||||
const signing = bg.requestSign();
|
||||
await settle();
|
||||
expect(signing.id()).toBeTruthy();
|
||||
expect(signing.result()).toBeNull();
|
||||
expect(bg.created).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// A nonce collision found before the transaction reaches the network is the
|
||||
// one send failure the wallet can speak about with certainty. The user is told
|
||||
// it did not go out and to send it again, rather than being warned it might
|
||||
// already be on the chain — which would send them looking for a transaction
|
||||
// that does not exist, and stop them retrying the one that never went.
|
||||
describe("a nonce collision is reported as a transaction that did not go out", () => {
|
||||
test("a broadcast the node refused for the nonce is not reported as possibly sent", async () => {
|
||||
const bg = loadBackground();
|
||||
const pending = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
bg.broadcastTransaction.mockRejectedValue(
|
||||
Object.assign(new Error("nonce too low"), {
|
||||
code: "NONCE_EXPIRED",
|
||||
}),
|
||||
);
|
||||
const answer = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: pending.id(),
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(NONCE),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||
error: expect.stringMatching(/nonce had already been used/),
|
||||
retryable: false,
|
||||
stage: "nonce",
|
||||
});
|
||||
expect(pending.result()).toEqual({
|
||||
error: {
|
||||
message: expect.stringMatching(
|
||||
/transaction was not sent, because its nonce/,
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("a nonce this wallet already broadcast is refused without asking the node again", async () => {
|
||||
const bg = loadBackground();
|
||||
const first = bg.requestTx();
|
||||
await settle();
|
||||
|
||||
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
|
||||
bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: first.id(),
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(NONCE),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
expect(first.result()).toEqual({ result: "0xfeed" });
|
||||
|
||||
// The stubbed node still reports NONCE as the next nonce — a pending
|
||||
// count that lags a broadcast the node has already taken — so this
|
||||
// second approval is populated at a nonce this worker has spent.
|
||||
const second = bg.requestTx();
|
||||
await settle();
|
||||
const answer = bg.send(
|
||||
{
|
||||
type: "AUTISTMASK_TX_RESPONSE",
|
||||
id: second.id(),
|
||||
approved: true,
|
||||
rawSignedTx: await signedAtNonce(NONCE),
|
||||
},
|
||||
{ url: bg.fromPopup.url },
|
||||
);
|
||||
await settle();
|
||||
|
||||
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
|
||||
expect(answer.sendResponse).toHaveBeenCalledWith({
|
||||
error: expect.stringMatching(/nonce had already been used/),
|
||||
retryable: false,
|
||||
stage: "nonce",
|
||||
});
|
||||
expect(second.result()).toEqual({
|
||||
error: {
|
||||
message: expect.stringMatching(/nonce had already been used/),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The approval carries the transaction the user was shown and the address it
|
||||
// was raised for, and the artifact is checked against both. Every case here is
|
||||
// one the old comparison — against the dApp's request, for the address that is
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
const { Transaction } = require("ethers");
|
||||
|
||||
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
||||
// must not collide with any entry in src/shared/tokenList.js, or
|
||||
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
|
||||
@@ -62,6 +64,87 @@ function word(value) {
|
||||
return "0x" + BigInt(value).toString(16).padStart(64, "0");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- dApp fixture
|
||||
//
|
||||
// The origin the EIP-1193 test page is served from, and the page itself.
|
||||
//
|
||||
// It is a fixture like every other one in this file: the route handler
|
||||
// fulfils the navigation from the string below, so the page never comes
|
||||
// from a remote origin and nothing about the dApp round trips leaves the
|
||||
// container. `.test` is reserved by RFC 6761 and has no owner to reach in
|
||||
// the first place; the launch arguments map every host to NOTFOUND anyway.
|
||||
//
|
||||
// What the page deliberately does NOT do is load a provider. window.ethereum
|
||||
// is put there by the shipped manifest's MAIN-world content script, exactly
|
||||
// as it is on any http(s) page a user visits, so what these tests speak to
|
||||
// is the real inpage provider and not a copy the harness wired up.
|
||||
const DAPP_ORIGIN = "https://dapp.e2e.test";
|
||||
const DAPP_URL = DAPP_ORIGIN + "/";
|
||||
|
||||
// Requests are parked rather than awaited. An approval prompt only exists
|
||||
// while its call is in flight, so a test that awaited the promise could
|
||||
// never drive the popup that has to settle it; start() files the promise
|
||||
// under a key and settle() collects it once the prompt has been dealt with.
|
||||
//
|
||||
// The rejection branch records the whole observable shape of the error as it
|
||||
// arrives — name, message, and whether a `code` is present at all as distinct
|
||||
// from its value. EIP-1193 says a user rejection is a ProviderRpcError
|
||||
// carrying code 4001; what the page can actually see is recorded here rather
|
||||
// than assumed, and asserted in run.js.
|
||||
//
|
||||
// The message log is the page's half of the boundary observation: every
|
||||
// AUTISTMASK_* message that crosses between this page and the content
|
||||
// script, in both directions, verbatim.
|
||||
const DAPP_HTML = [
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
"<head>",
|
||||
'<meta charset="utf-8">',
|
||||
"<title>AutistMask e2e dApp</title>",
|
||||
// Inline and empty: without it Chromium asks for /favicon.ico, which
|
||||
// the unstubbed-request guard would report as escaping traffic.
|
||||
'<link rel="icon" href="data:,">',
|
||||
"</head>",
|
||||
"<body>",
|
||||
"<h1>AutistMask e2e dApp</h1>",
|
||||
"<script>",
|
||||
"window.__dapp = {",
|
||||
" messages: [],",
|
||||
" calls: {},",
|
||||
" start: function (key, method, params) {",
|
||||
" window.__dapp.calls[key] = window.ethereum",
|
||||
" .request({ method: method, params: params })",
|
||||
" .then(",
|
||||
" function (result) {",
|
||||
" return { settled: 'resolved', result: result };",
|
||||
" },",
|
||||
" function (error) {",
|
||||
" return {",
|
||||
" settled: 'rejected',",
|
||||
" message: String((error && error.message) || error),",
|
||||
" name: error ? error.name : undefined,",
|
||||
" hasCode: !!error && 'code' in Object(error),",
|
||||
" code: error ? error.code : undefined,",
|
||||
" };",
|
||||
" },",
|
||||
" );",
|
||||
" },",
|
||||
" settle: function (key) {",
|
||||
" return window.__dapp.calls[key];",
|
||||
" },",
|
||||
"};",
|
||||
"window.addEventListener('message', function (event) {",
|
||||
" if (event.source !== window) return;",
|
||||
" var d = event.data;",
|
||||
" if (!d || typeof d.type !== 'string') return;",
|
||||
" if (d.type.indexOf('AUTISTMASK') !== 0) return;",
|
||||
" window.__dapp.messages.push(d);",
|
||||
"});",
|
||||
"</script>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
].join("\n");
|
||||
|
||||
// ------------------------------------------------------------ fee fixture
|
||||
//
|
||||
// The confirmation screen carries two different numbers for the same
|
||||
@@ -101,6 +184,11 @@ const RPC_RESULTS = {
|
||||
eth_estimateGas: hex(GAS_LIMIT),
|
||||
eth_getTransactionCount: "0x0",
|
||||
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
|
||||
// "not mined yet", which is what a node answers for a transaction it has
|
||||
// only just accepted. The wait screen the dApp transaction approval hands
|
||||
// off to polls this every 10 seconds; leaving it unstubbed would report
|
||||
// the poll as escaping traffic the moment a test outlived one tick.
|
||||
eth_getTransactionReceipt: null,
|
||||
};
|
||||
|
||||
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
|
||||
@@ -264,6 +352,31 @@ function rpcReply(req, opts, report) {
|
||||
if (req.method === "eth_getBlockByNumber") {
|
||||
return Object.assign(envelope, { result: latestBlock() });
|
||||
}
|
||||
// The end of the dApp transaction round trip: the raw signed transaction
|
||||
// the background hands to the node. It is recorded verbatim so a test can
|
||||
// recover the signer from the exact bytes that were broadcast, rather than
|
||||
// from anything the extension reported about them.
|
||||
//
|
||||
// The reply must be the transaction's real hash. ethers compares the hash
|
||||
// the node returns against the one it computes itself and throws on a
|
||||
// mismatch, so a constant here would fail the broadcast for a reason that
|
||||
// has nothing to do with what is being tested.
|
||||
if (req.method === "eth_sendRawTransaction") {
|
||||
const raw = Array.isArray(req.params) ? req.params[0] : null;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = Transaction.from(raw);
|
||||
} catch {
|
||||
report("eth_sendRawTransaction with an undecodable transaction");
|
||||
return Object.assign(envelope, {
|
||||
error: { code: -32000, message: "undecodable transaction" },
|
||||
});
|
||||
}
|
||||
if (Array.isArray(opts.broadcastTransactions)) {
|
||||
opts.broadcastTransactions.push(raw);
|
||||
}
|
||||
return Object.assign(envelope, { result: parsed.hash });
|
||||
}
|
||||
if (req.method === "eth_estimateGas" && opts.failGasEstimate) {
|
||||
// A refusal the node itself would produce, not a transport error:
|
||||
// this is the shape the confirmation screen has to turn into
|
||||
@@ -375,6 +488,8 @@ function traceEnabled(raw) {
|
||||
* node-side refusal.
|
||||
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
||||
* eth_estimateGas until this is cleared again.
|
||||
* @param {string[]} [opts.broadcastTransactions] every raw signed
|
||||
* transaction handed to eth_sendRawTransaction, appended in order.
|
||||
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||
* Promise<string|null>}>}
|
||||
*/
|
||||
@@ -420,6 +535,18 @@ async function installNetworkStubs(ctx, opts) {
|
||||
return handleRpc(route, req.postData(), opts, report);
|
||||
}
|
||||
|
||||
// The local EIP-1193 test page. Served from here so the dApp round
|
||||
// trips run against a real http(s) origin — which is what makes the
|
||||
// shipped content scripts inject at all — without any remote origin
|
||||
// being involved.
|
||||
if (url.origin === DAPP_ORIGIN && p === "/") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
body: DAPP_HTML,
|
||||
});
|
||||
}
|
||||
|
||||
// Blockscout v2
|
||||
if (p.includes("/api/v2/")) {
|
||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
||||
@@ -508,6 +635,8 @@ async function installNetworkStubs(ctx, opts) {
|
||||
|
||||
module.exports = {
|
||||
installNetworkStubs,
|
||||
DAPP_ORIGIN,
|
||||
DAPP_URL,
|
||||
FEE_ESTIMATE_WEI,
|
||||
FEE_RESERVE_WEI,
|
||||
STUB_COUNTERPARTY,
|
||||
|
||||
1132
tests/e2e/run.js
1132
tests/e2e/run.js
File diff suppressed because it is too large
Load Diff
310
tests/inpageErrors.test.js
Normal file
310
tests/inpageErrors.test.js
Normal file
@@ -0,0 +1,310 @@
|
||||
// The EIP-1193 error the page actually catches (src/content/inpage.js).
|
||||
//
|
||||
// The bug this pins down (issue #274): the provider rebuilt every failure as
|
||||
// `new Error(error.message)`, so the `code` the background produced and the
|
||||
// content script relayed intact was thrown away in the last hop. A dApp
|
||||
// checking `err.code === 4001` — the standard way to tell "the user said no"
|
||||
// from "the wallet broke" — saw undefined, and well-behaved sites showed an
|
||||
// error or retried instead of accepting the refusal.
|
||||
//
|
||||
// inpage.js is a bare IIFE injected into the page's JS context, not a module:
|
||||
// it takes no import and exports nothing, and reaches for `window` at load.
|
||||
// So it is evaluated here the way the browser evaluates it, against a stub
|
||||
// window, and the provider is collected from `window.ethereum`. The globals it
|
||||
// touches are passed in as function parameters rather than assigned to
|
||||
// globalThis: nothing leaks between tests, and the source is compiled in this
|
||||
// realm, so the errors it constructs are comparable against this file's own
|
||||
// `Error` — which a second realm's intrinsics would silently defeat.
|
||||
//
|
||||
// There is no jsdom in this repo; see tests/txStatus.test.js.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { webcrypto } = require("crypto");
|
||||
|
||||
const SOURCE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "content", "inpage.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const loadInto = new Function(
|
||||
"window",
|
||||
"self",
|
||||
"crypto",
|
||||
"Event",
|
||||
"CustomEvent",
|
||||
SOURCE,
|
||||
);
|
||||
|
||||
class StubEvent {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
class StubCustomEvent extends StubEvent {
|
||||
constructor(type, init) {
|
||||
super(type);
|
||||
this.detail = init && init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
// Every code the background emits on the RPC path today, read out of
|
||||
// src/background/index.js. The provider must not know this list — it passes
|
||||
// through whatever arrived — but the cases below are the real ones.
|
||||
const REJECTED = 4001; // user rejected the request
|
||||
const UNAUTHORIZED = 4100; // site not connected / wrong address
|
||||
const UNRECOGNIZED_CHAIN = 4902; // switch/add to an unsupported chain
|
||||
|
||||
// A stub window with the four things inpage.js touches: message listeners,
|
||||
// postMessage out to the content script, window.ethereum, and dispatchEvent
|
||||
// for the EIP-6963 announcement.
|
||||
function loadProvider() {
|
||||
const messageListeners = [];
|
||||
const posted = [];
|
||||
|
||||
const win = {
|
||||
addEventListener(type, fn) {
|
||||
if (type === "message") messageListeners.push(fn);
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
const i = messageListeners.indexOf(fn);
|
||||
if (type === "message" && i !== -1) messageListeners.splice(i, 1);
|
||||
},
|
||||
postMessage(data) {
|
||||
posted.push(data);
|
||||
},
|
||||
dispatchEvent() {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
win.window = win;
|
||||
|
||||
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
|
||||
|
||||
// Deliver the content script's answer to an outstanding request. The id is
|
||||
// read back off the wire rather than assumed: inpage.js issues its own
|
||||
// eth_chainId at load, so the first id a test sees is not 1.
|
||||
function respond(response) {
|
||||
const request = posted
|
||||
.filter((m) => m.type === "AUTISTMASK_REQUEST")
|
||||
.pop();
|
||||
expect(request).toBeDefined();
|
||||
const event = {
|
||||
source: win,
|
||||
data: { type: "AUTISTMASK_RESPONSE", id: request.id, ...response },
|
||||
};
|
||||
for (const fn of messageListeners.slice()) fn(event);
|
||||
}
|
||||
|
||||
return { provider: win.ethereum, posted, respond };
|
||||
}
|
||||
|
||||
// Start a request, answer it with `response`, and hand back the rejection.
|
||||
// Fails the test if the call resolves instead.
|
||||
async function rejectionFrom(start, response) {
|
||||
const { provider, respond } = loadProvider();
|
||||
const settled = start(provider).then(
|
||||
(result) => ({ resolved: result }),
|
||||
(error) => ({ error }),
|
||||
);
|
||||
// The provider posts synchronously, so the request is already on the wire.
|
||||
respond(response);
|
||||
const outcome = await settled;
|
||||
expect(outcome).not.toHaveProperty("resolved");
|
||||
return outcome.error;
|
||||
}
|
||||
|
||||
describe("an EIP-1193 code reaches the page", () => {
|
||||
test("a user rejection arrives as code 4001", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_requestAccounts" }),
|
||||
{
|
||||
error: {
|
||||
code: REJECTED,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
expect(err.message).toBe("User rejected the request.");
|
||||
});
|
||||
|
||||
test("it is a ProviderRpcError, and an Error", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_requestAccounts" }),
|
||||
{
|
||||
error: {
|
||||
code: REJECTED,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.name).toBe("ProviderRpcError");
|
||||
});
|
||||
|
||||
test("4100 unauthorized arrives intact", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "personal_sign", params: ["0x00"] }),
|
||||
{ error: { code: UNAUTHORIZED, message: "Unauthorized" } },
|
||||
);
|
||||
expect(err.code).toBe(UNAUTHORIZED);
|
||||
expect(err.message).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
test("4902 unrecognized chain arrives intact", async () => {
|
||||
const message =
|
||||
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.";
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "wallet_switchEthereumChain" }),
|
||||
{ error: { code: UNRECOGNIZED_CHAIN, message } },
|
||||
);
|
||||
expect(err.code).toBe(UNRECOGNIZED_CHAIN);
|
||||
expect(err.message).toBe(message);
|
||||
});
|
||||
|
||||
// The provider is not allowed to know the list above: a code added to the
|
||||
// background later must reach the page without this file being edited.
|
||||
test("a code the provider has never heard of is passed through", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_accounts" }),
|
||||
{ error: { code: 4900, message: "Disconnected" } },
|
||||
);
|
||||
expect(err.code).toBe(4900);
|
||||
});
|
||||
|
||||
test("data is carried when the boundary sent it", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_call" }),
|
||||
{
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "execution reverted",
|
||||
data: "0x08c379a0",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(err.code).toBe(-32000);
|
||||
expect(err.data).toBe("0x08c379a0");
|
||||
});
|
||||
|
||||
test("no data property is invented when the boundary sent none", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_requestAccounts" }),
|
||||
{
|
||||
error: {
|
||||
code: REJECTED,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect("data" in err).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the message is untouched", () => {
|
||||
test("a coded error keeps the message byte for byte", async () => {
|
||||
const message =
|
||||
"This site asked to sign as an address that is not " +
|
||||
"the active one.";
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "personal_sign" }),
|
||||
{ error: { code: UNAUTHORIZED, message } },
|
||||
);
|
||||
expect(err.message).toBe(message);
|
||||
});
|
||||
|
||||
test("an error the background sent with no code keeps its message", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_sendTransaction" }),
|
||||
{ error: { message: "No accounts available" } },
|
||||
);
|
||||
expect(err.message).toBe("No accounts available");
|
||||
});
|
||||
|
||||
// A ProviderRpcError whose code is undefined would claim a conformance it
|
||||
// does not have, and `'code' in err` is exactly what a careful dApp asks.
|
||||
test("an error with no code gets no code property at all", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_sendTransaction" }),
|
||||
{ error: { message: "No accounts available" } },
|
||||
);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect("code" in err).toBe(false);
|
||||
});
|
||||
|
||||
test("an error with no message keeps the generic fallback", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_sendTransaction" }),
|
||||
{ error: { code: REJECTED } },
|
||||
);
|
||||
expect(err.message).toBe("Request failed");
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
});
|
||||
|
||||
// Every entry point the provider exposes, not just eth_requestAccounts. They
|
||||
// all funnel through the same response listener, and this is what says so.
|
||||
describe("every request path carries the code", () => {
|
||||
const rejection = {
|
||||
error: { code: REJECTED, message: "User rejected the request." },
|
||||
};
|
||||
|
||||
test("request()", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.request({ method: "eth_requestAccounts" }),
|
||||
rejection,
|
||||
);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
|
||||
test("enable()", async () => {
|
||||
const err = await rejectionFrom((p) => p.enable(), rejection);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
|
||||
test("send(method, params)", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.send("eth_requestAccounts", []),
|
||||
rejection,
|
||||
);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
|
||||
test("send({ method, params })", async () => {
|
||||
const err = await rejectionFrom(
|
||||
(p) => p.send({ method: "personal_sign", params: ["0x00"] }),
|
||||
rejection,
|
||||
);
|
||||
expect(err.code).toBe(REJECTED);
|
||||
});
|
||||
|
||||
test("sendAsync() hands the code to its callback", async () => {
|
||||
const { provider, respond } = loadProvider();
|
||||
const called = new Promise((resolve) => {
|
||||
provider.sendAsync({ id: 1, method: "eth_requestAccounts" }, (e) =>
|
||||
resolve(e),
|
||||
);
|
||||
});
|
||||
respond(rejection);
|
||||
const err = await called;
|
||||
expect(err.name).toBe("ProviderRpcError");
|
||||
expect(err.code).toBe(REJECTED);
|
||||
expect(err.message).toBe("User rejected the request.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the success path is unchanged", () => {
|
||||
test("a result still resolves", async () => {
|
||||
const { provider, respond } = loadProvider();
|
||||
const settled = provider.request({ method: "eth_requestAccounts" });
|
||||
respond({ result: ["0xb61264DEFB0c4B8afb3D73724be15310036743a5"] });
|
||||
await expect(settled).resolves.toEqual([
|
||||
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
|
||||
]);
|
||||
expect(provider.selectedAddress).toBe(
|
||||
"0xb61264DEFB0c4B8afb3D73724be15310036743a5",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,7 @@ global.chrome = {
|
||||
};
|
||||
|
||||
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
|
||||
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
||||
const { TOKENS, KNOWN_SYMBOLS } = require("../src/shared/tokenList");
|
||||
const { filterTransactions } = require("../src/shared/transactions");
|
||||
const {
|
||||
fetchTokenBalances,
|
||||
@@ -124,6 +124,301 @@ describe("the shared rule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #260: the symbol is whatever the ERC-20 contract returns, and HTML
|
||||
// collapses leading and trailing whitespace, so a token calling itself
|
||||
// `" ETH "` reaches the user's eye as `ETH` while missing a raw
|
||||
// KNOWN_SYMBOLS lookup. Normalizing inside the shared rule fixes all three
|
||||
// surfaces at once, which is what consolidating the rule bought.
|
||||
//
|
||||
// Every character under test here is built from its code point rather than
|
||||
// pasted in: most of them are invisible, and an invisible character in a
|
||||
// test file is unreviewable.
|
||||
const cp = (...codes) => String.fromCodePoint(...codes);
|
||||
const NBSP = cp(0x00a0); // no-break space
|
||||
const FIGURE_SPACE = cp(0x2007);
|
||||
const IDEOGRAPHIC_SPACE = cp(0x3000);
|
||||
const ZWSP = cp(0x200b); // zero-width space
|
||||
const BOM = cp(0xfeff); // zero-width no-break space
|
||||
const WORD_JOINER = cp(0x2060);
|
||||
const SOFT_HYPHEN = cp(0x00ad);
|
||||
const LRM = cp(0x200e); // left-to-right mark
|
||||
const RLO = cp(0x202e); // right-to-left override
|
||||
const HANGUL_FILLER = cp(0x3164);
|
||||
const CHOSEONG_FILLER = cp(0x115f);
|
||||
const VS16 = cp(0xfe0f); // variation selector-16
|
||||
const VS1 = cp(0xfe00); // variation selector-1
|
||||
const NEL = cp(0x0085); // next line, a C1 control
|
||||
const DEL = cp(0x007f);
|
||||
const FULLWIDTH_ETH = cp(0xff25, 0xff34, 0xff28);
|
||||
const FULLWIDTH_USDC = cp(0xff55, 0xff53, 0xff44, 0xff43); // lowercase
|
||||
const CYRILLIC_CAPITAL_IE = cp(0x0415);
|
||||
|
||||
describe("the shared rule: symbols that render as a known symbol", () => {
|
||||
test("ASCII padding does not buy a pass", () => {
|
||||
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
});
|
||||
|
||||
test("non-breaking and other Unicode spaces do not either", () => {
|
||||
expect(isSpoofedSymbol(NBSP + "ETH" + NBSP, FAKE_ETH_CONTRACT)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isSpoofedSymbol(
|
||||
FIGURE_SPACE + "ETH" + IDEOGRAPHIC_SPACE,
|
||||
FAKE_ETH_CONTRACT,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// These render as nothing at all, in any position, so they are removed
|
||||
// wherever they sit rather than only at the ends.
|
||||
test("zero-width characters are stripped wherever they sit", () => {
|
||||
expect(isSpoofedSymbol("E" + ZWSP + "TH", FAKE_ETH_CONTRACT)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSpoofedSymbol(BOM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
expect(
|
||||
isSpoofedSymbol("ET" + WORD_JOINER + "H", FAKE_ETH_CONTRACT),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSpoofedSymbol("E" + SOFT_HYPHEN + "TH", FAKE_ETH_CONTRACT),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// An LRM is invisible and, in all-Latin text, moves nothing: dropping it
|
||||
// leaves exactly the string the user saw.
|
||||
test("an invisible bidi mark does not hide a known symbol", () => {
|
||||
expect(isSpoofedSymbol(LRM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
});
|
||||
|
||||
// Invisibility is not confined to \p{Cf}. A Hangul filler is Lo and a
|
||||
// variation selector is Mn, yet each of these four measures 32.00px in
|
||||
// the repo's pinned e2e Chromium at 16px sans-serif — exactly the width
|
||||
// of a plain `ETH` — so each reaches the user's eye as `ETH`. They are
|
||||
// caught by \p{Default_Ignorable_Code_Point}, not by \p{Cf}.
|
||||
test("invisible non-format characters are stripped too", () => {
|
||||
expect(isSpoofedSymbol(HANGUL_FILLER + "ETH", FAKE_ETH_CONTRACT)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isSpoofedSymbol(CHOSEONG_FILLER + "ETH", FAKE_ETH_CONTRACT),
|
||||
).toBe(true);
|
||||
expect(isSpoofedSymbol("ETH" + VS16, FAKE_ETH_CONTRACT)).toBe(true);
|
||||
expect(isSpoofedSymbol("E" + VS1 + "TH", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
});
|
||||
|
||||
// Nor is it confined to the Unicode classes. U+007F is a control (Cc)
|
||||
// and is not default-ignorable, so neither class reaches it, but it
|
||||
// measures 32.00px in the same browser — it paints nothing, so a
|
||||
// symbol carrying it reaches the eye as `ETH`. It is named on its own
|
||||
// in the strip for exactly that reason.
|
||||
test("U+007F paints nothing and is stripped", () => {
|
||||
expect(isSpoofedSymbol(DEL + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
|
||||
});
|
||||
|
||||
// The other side of the boundary, which is not the class boundary but
|
||||
// the visibility one: the remaining C0 and C1 controls render as a
|
||||
// visible 48.00px box in the same browser, so a symbol carrying one
|
||||
// does not look like `ETH` and must not be judged a spoof. Widening
|
||||
// the strip to \p{Cc} — the obvious over-correction once U+007F is in
|
||||
// it — fails this test.
|
||||
test("visible control characters do not make a symbol a spoof", () => {
|
||||
expect(isSpoofedSymbol(NEL + "ETH", FAKE_ETH_CONTRACT)).toBe(false);
|
||||
expect(isSpoofedSymbol(cp(0x0001) + "ETH", FAKE_ETH_CONTRACT)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isSpoofedSymbol(cp(0x0090) + "ETH", FAKE_ETH_CONTRACT)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("compatibility forms fold onto the symbol they imitate", () => {
|
||||
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
|
||||
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
|
||||
});
|
||||
|
||||
// The two knowingly open classes, asserted here so that the boundary is
|
||||
// a fact in the suite and not a claim in a PR body. A Cyrillic capital
|
||||
// Ie is a distinct letter rather than a compatibility variant, so NFKC
|
||||
// leaves it alone; and a right-to-left override reverses the rendering
|
||||
// of what follows it, which dropping the control character does not
|
||||
// undo. Closing either needs a confusables table or a bidi resolver,
|
||||
// and both are a separate change from this one.
|
||||
test("a Cyrillic homoglyph is knowingly still not caught", () => {
|
||||
expect(
|
||||
isSpoofedSymbol(CYRILLIC_CAPITAL_IE + "TH", FAKE_ETH_CONTRACT),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("a bidi-reordered symbol is knowingly still not caught", () => {
|
||||
expect(isSpoofedSymbol(RLO + "HTE", FAKE_ETH_CONTRACT)).toBe(false);
|
||||
});
|
||||
|
||||
// Normalization does not reach the native-asset exemption, which turns
|
||||
// on the absence of a contract address and never on the symbol.
|
||||
test("a padded symbol with no contract is still not a spoof", () => {
|
||||
expect(isSpoofedSymbol(" ETH ", null)).toBe(false);
|
||||
expect(isSpoofedSymbol(NBSP + "ETH", "")).toBe(false);
|
||||
});
|
||||
|
||||
test("a genuine contract still bears its own padded symbol", () => {
|
||||
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
|
||||
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
|
||||
});
|
||||
|
||||
// Normalization must not invent a match. Interior ASCII whitespace is
|
||||
// left alone: `E T H` renders as `E T H`, not as `ETH`, so folding it
|
||||
// would filter a token no user could confuse with the native asset.
|
||||
test("a symbol that renders differently is not judged a spoof", () => {
|
||||
expect(isSpoofedSymbol("E T H", FAKE_ETH_CONTRACT)).toBe(false);
|
||||
expect(isSpoofedSymbol("ETH2", FAKE_ETH_CONTRACT)).toBe(false);
|
||||
expect(isSpoofedSymbol("MY ETH", FAKE_ETH_CONTRACT)).toBe(false);
|
||||
});
|
||||
|
||||
// The false-positive question, answered against the shipped data rather
|
||||
// than by assertion: no bundled symbol carries whitespace or a
|
||||
// non-ASCII character, so the normalization cannot newly filter one.
|
||||
// The character class starts at `!` rather than at the space so that it
|
||||
// asserts the claim it stands for — `[ -~]` would admit an interior
|
||||
// space and let a whitespace-bearing entry through the guard.
|
||||
test("no bundled symbol is touched by the normalization", () => {
|
||||
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
|
||||
expect(symbol).toBe(symbol.trim());
|
||||
expect(symbol).toMatch(/^[!-~]+$/);
|
||||
if (addresses === null) continue;
|
||||
for (const address of addresses) {
|
||||
expect(isSpoofedSymbol(symbol, address)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #276: the guard that was missing. The suite walked KNOWN_SYMBOLS,
|
||||
// which is built from TOKENS, so it could only ever assert that the table
|
||||
// agrees with itself. Seven symbols appear twice in the bundled list at two
|
||||
// different real contracts, and the table kept whichever came first, so the
|
||||
// other seven contracts — tokens in our own shipped list, at their own
|
||||
// addresses — were judged spoofs and hidden from the balance list, the
|
||||
// history and the send selector. That is the over-filtering direction: it
|
||||
// hides a holding the user cannot then spend.
|
||||
//
|
||||
// This walk is over TOKENS, the data the wallet actually ships, so it fails
|
||||
// whenever a bundled token would be filtered at its own address no matter
|
||||
// which side of the table the mistake is on.
|
||||
describe("the shipped token list", () => {
|
||||
test("no bundled token is filtered at its own address", () => {
|
||||
const filtered = TOKENS.filter((t) =>
|
||||
isSpoofedSymbol(t.symbol, t.address),
|
||||
).map((t) => t.symbol + " @ " + t.address);
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
|
||||
// The third failure mode the issue asks about: a symbol whose table entry
|
||||
// names an address that is in neither the table nor the list would be a
|
||||
// contract we vouch for and do not ship. There is none, and the table is
|
||||
// built from the list, so this asserts the derivation has not acquired a
|
||||
// hand-written entry.
|
||||
test("every address the table vouches for is a bundled token", () => {
|
||||
const bundled = new Set(TOKENS.map((t) => t.address.toLowerCase()));
|
||||
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
|
||||
if (addresses === null) continue;
|
||||
expect(addresses.size).toBeGreaterThan(0);
|
||||
for (const address of addresses) {
|
||||
expect(address).toBe(address.toLowerCase());
|
||||
expect(bundled.has(address)).toBe(true);
|
||||
// And it is the token that actually reports that symbol.
|
||||
const token = TOKENS.find(
|
||||
(t) => t.address.toLowerCase() === address,
|
||||
);
|
||||
expect(token.symbol.toUpperCase()).toBe(symbol);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Both contracts behind a shared ticker must pass, from either side: a
|
||||
// rule that admits only the one the table happens to visit first is the
|
||||
// bug, not the fix.
|
||||
test("both contracts behind a shared ticker are admitted", () => {
|
||||
const bySymbol = new Map();
|
||||
for (const t of TOKENS) {
|
||||
const upper = t.symbol.toUpperCase();
|
||||
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
|
||||
bySymbol.get(upper).push(t);
|
||||
}
|
||||
const shared = [...bySymbol].filter(([, list]) => list.length > 1);
|
||||
// The shared tickers are a fact about the shipped data; if a future
|
||||
// list has none, this test would silently assert nothing.
|
||||
expect(shared.length).toBeGreaterThan(0);
|
||||
for (const [, list] of shared) {
|
||||
for (const t of list) {
|
||||
expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The seven from issue #276, named so that the reconciliation is a fact
|
||||
// in the suite: each is two real contracts from the same source fetch,
|
||||
// and the table now holds both rather than the one that came first.
|
||||
test("the seven shared tickers each name both bundled contracts", () => {
|
||||
const expected = {
|
||||
TON: [
|
||||
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1", // Toncoin
|
||||
"0x2be5e8c109e2197d077d13a82daead6a9b3433c5", // Tokamak Network
|
||||
],
|
||||
FRAX: [
|
||||
"0x853d955acef822db058eb8505911ed77f175b99e", // Legacy Frax Dollar
|
||||
"0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0", // Frax (prev. FXS)
|
||||
],
|
||||
REUSD: [
|
||||
"0x5086bf358635b81d8c47c66d1c8b9e567db70c72", // Re Protocol reUSD
|
||||
"0x57ab1e0003f623289cd798b1824be09a793e4bec", // Resupply USD
|
||||
],
|
||||
EURE: [
|
||||
"0x39b8b6385416f4ca36a20319f70d28621895279d", // Monerium EUR emoney
|
||||
"0x3231cb76718cdef2155fc47b5286d82e6eda273f", // Monerium EUR emoney [OLD]
|
||||
],
|
||||
MSUSD: [
|
||||
"0x4ba01f22827018b4772cd326c7627fb4956a7c00", // Main Street USD
|
||||
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
|
||||
],
|
||||
MUSD: [
|
||||
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD
|
||||
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
|
||||
],
|
||||
JPYC: [
|
||||
"0x431d5dff03120afa4bdf332c61a6e1766ef37bdb", // JPY Coin
|
||||
"0x2370f9d504c7a6e775bf6e14b3f12846b594cd53", // JPY Coin v1
|
||||
],
|
||||
};
|
||||
for (const [symbol, addresses] of Object.entries(expected)) {
|
||||
expect([...KNOWN_SYMBOLS.get(symbol)].sort()).toEqual(
|
||||
[...addresses].sort(),
|
||||
);
|
||||
for (const address of addresses) {
|
||||
expect(isSpoofedSymbol(symbol, address)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The other direction, on the same symbols: widening the table to hold
|
||||
// every bundled address for a ticker must not turn it into a pass for
|
||||
// any other contract.
|
||||
test("a shared ticker from a third contract is still a spoof", () => {
|
||||
const bySymbol = new Map();
|
||||
for (const t of TOKENS) {
|
||||
const upper = t.symbol.toUpperCase();
|
||||
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
|
||||
bySymbol.get(upper).push(t);
|
||||
}
|
||||
for (const [symbol, list] of bySymbol) {
|
||||
if (list.length < 2) continue;
|
||||
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("surface 1: the transaction history", () => {
|
||||
function fakeEthTransfer() {
|
||||
return {
|
||||
@@ -147,6 +442,22 @@ describe("surface 1: the transaction history", () => {
|
||||
expect(result.transactions).toEqual([]);
|
||||
});
|
||||
|
||||
// Issue #260 on this surface: the same transfer with a padded symbol.
|
||||
test("a padded fake ETH token transfer is filtered too", () => {
|
||||
const padded = { ...fakeEthTransfer(), symbol: " ETH " };
|
||||
const result = filterTransactions([padded], {
|
||||
hideSpoofedSymbols: true,
|
||||
hideFraudContracts: true,
|
||||
hideLowHolderTokens: true,
|
||||
hideDustTransactions: true,
|
||||
dustThresholdGwei: 100000,
|
||||
});
|
||||
expect(result.transactions).toEqual([]);
|
||||
// The contract is learned as fraudulent, exactly as for the
|
||||
// unpadded symbol: the padding must not cost the blocklist entry.
|
||||
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
||||
});
|
||||
|
||||
test("a real native ETH transfer survives", () => {
|
||||
const native = {
|
||||
hash: "0x" + "2".repeat(64),
|
||||
@@ -201,6 +512,36 @@ describe("surface 2: the Send token selector", () => {
|
||||
expect(select.children).toEqual([]);
|
||||
});
|
||||
|
||||
// Issue #260 on this surface: the option text is rendered into HTML,
|
||||
// which collapses the padding, so an unfiltered padded token would sit
|
||||
// in the selector reading exactly `ETH`.
|
||||
test("a padded fake ETH token is not selectable either", () => {
|
||||
render([
|
||||
{
|
||||
address: FAKE_ETH_CONTRACT,
|
||||
symbol: " ETH ",
|
||||
decimals: 18,
|
||||
balance: "0.005",
|
||||
holders: 900000,
|
||||
},
|
||||
]);
|
||||
expect(select.children).toEqual([]);
|
||||
});
|
||||
|
||||
test("a genuine token with a padded symbol stays selectable", () => {
|
||||
render([
|
||||
{
|
||||
address: USDC_CONTRACT,
|
||||
symbol: " USDC ",
|
||||
decimals: 6,
|
||||
balance: "12.5",
|
||||
holders: 900000,
|
||||
},
|
||||
]);
|
||||
expect(select.children).toHaveLength(1);
|
||||
expect(select.children[0].value).toBe(USDC_CONTRACT);
|
||||
});
|
||||
|
||||
test("native ETH remains the always-present option", () => {
|
||||
render([]);
|
||||
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
|
||||
@@ -251,6 +592,35 @@ describe("surface 3: the balance list", () => {
|
||||
expect(balances).toEqual([]);
|
||||
});
|
||||
|
||||
// Issue #260 on this surface: the balance list is where the user forms
|
||||
// their belief about what they own, and it renders the symbol into HTML.
|
||||
test("a padded fake ETH token is filtered too", async () => {
|
||||
respondWith([fakeEthItem({ symbol: " ETH " })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("a fake ETH token padded with a no-break space is filtered", async () => {
|
||||
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
// The false-positive direction on the surface that matters most: a real
|
||||
// holding whose symbol happens to carry padding is still listed, and the
|
||||
// list still shows the symbol the token actually reports.
|
||||
test("a genuine token with a padded symbol is not newly filtered", async () => {
|
||||
respondWith([
|
||||
fakeEthItem({
|
||||
address_hash: USDC_CONTRACT,
|
||||
symbol: " USDC ",
|
||||
name: "USD Coin",
|
||||
decimals: "6",
|
||||
}),
|
||||
]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].symbol).toBe(" USDC ");
|
||||
});
|
||||
|
||||
test("a genuine token keeps its place in the list", async () => {
|
||||
respondWith([
|
||||
fakeEthItem({
|
||||
@@ -274,6 +644,33 @@ describe("surface 3: the balance list", () => {
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
// The adjacent finding from the same review as issue #260: the type gate
|
||||
// compared exactly, so an explorer that ever varied the casing would
|
||||
// silently drop a real holding before any filter ran. The comparison is
|
||||
// now case-insensitive, which changes nothing about which types are
|
||||
// admitted.
|
||||
test("a differently-cased ERC-20 type still lists a real holding", async () => {
|
||||
respondWith([
|
||||
fakeEthItem({
|
||||
type: "erc-20",
|
||||
address_hash: USDC_CONTRACT,
|
||||
symbol: "USDC",
|
||||
name: "USD Coin",
|
||||
decimals: "6",
|
||||
}),
|
||||
]);
|
||||
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||
expect(balances).toHaveLength(1);
|
||||
expect(balances[0].symbol).toBe("USDC");
|
||||
});
|
||||
|
||||
test("case insensitivity does not admit another token type", async () => {
|
||||
respondWith([fakeEthItem({ type: "erc-721" })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
|
||||
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||
});
|
||||
|
||||
// The money test: the user holds real ETH and has been airdropped a fake
|
||||
// ETH ERC-20. The fake is gone from the list of tokens; the real balance
|
||||
// is exactly what the node reported.
|
||||
|
||||
@@ -207,8 +207,8 @@ describe("token list assumptions the fixtures rely on", () => {
|
||||
});
|
||||
|
||||
test("USDC and WETH map to their genuine lowercased contracts", () => {
|
||||
expect(KNOWN_SYMBOLS.get("USDC")).toBe(USDC_CONTRACT);
|
||||
expect(KNOWN_SYMBOLS.get("WETH")).toBe(WETH_CONTRACT);
|
||||
expect([...KNOWN_SYMBOLS.get("USDC")]).toEqual([USDC_CONTRACT]);
|
||||
expect([...KNOWN_SYMBOLS.get("WETH")]).toEqual([WETH_CONTRACT]);
|
||||
});
|
||||
|
||||
test("the spam fixture symbol is not in the known token list", () => {
|
||||
|
||||
Reference in New Issue
Block a user