Compare commits

..

1 Commits

Author SHA1 Message Date
dbd83fee02 test: drive the EIP-1193 dApp approval round trips in the browser (closes #183)
All checks were successful
check / check (push) Successful in 30s
The seam between the content script, the inpage provider, the background worker
and the approval popup had no coverage at all. The unit suite covers each side
in isolation, so a wallet that signed the wrong payload, handed back a signature
from the wrong key, hung on a rejected prompt, or put the user's password back
on the extension messaging boundary would have passed everything.

Ten end-to-end tests now drive it. The route handler serves a local test page on
a reserved-TLD origin; window.ethereum arrives there from the shipped MAIN-world
content script, not from anything the fixture installs, and the page's own
EIP-6963 announcement is required to be that provider by object identity. The
page then speaks eth_requestAccounts, personal_sign, eth_signTypedData_v4 and
eth_sendTransaction through the real prompts, approved and rejected.

The assertions are the point:

  - every signature is recovered in the runner with verifyMessage,
    verifyTypedData and Transaction.from(), and compared against the active
    address read out of extension storage. The background verifies too; nothing
    here leans on its verdict.
  - the transaction is checked against the raw signed bytes captured at
    eth_sendRawTransaction, which the RPC stub now records and answers with the
    transaction's real hash. Signer, recipient, value, call data and chain id
    are all compared there, and the hash the page received is required to be the
    hash of those bytes.
  - a rejected prompt must reach the page as a rejection rather than hang or
    resolve, and must carry EIP-1193 code 4001 across the boundary. The code is
    asserted on the wire because that is where it survives: the inpage provider
    rebuilds the rejection as a bare Error, so the calling page catches a message
    and no code. Reported, not asserted either way.
  - the password must appear in no message the approval window sends to the
    background, observed directly by wrapping chrome.runtime.sendMessage before
    Approve is clicked, with the response message that would carry it required
    to be present so the check cannot pass on an empty record.

Every one of those was run against a deliberately broken variant and seen to
fail: a corrupted recovered signer, a payload carrying the password again, a
provider that resolves instead of rejecting, and approval screens displaying the
wrong message, value and call data.

Two honest limits. The RPC is stubbed throughout, so this does not discharge a
real dApp with real funds against a real network. And the site-connection prompt
goes through chrome.action.openPopup(), whose browser-action popup headless
Chromium will not expose as a page, so that one prompt is driven at the URL the
extension puts on the action instead — same page, same approval id, but a real
toolbar click is not observable from a headless harness. Both are stated in
README.md rather than presented as covered.

Test-only: nothing under src/ changes.
2026-08-12 11:18:24 +00:00
14 changed files with 96 additions and 1119 deletions

View File

@@ -1163,11 +1163,7 @@ 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. 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.
failed back to the site.
- **Elements**:
- "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing

45
TODO.md
View File

@@ -45,51 +45,6 @@ undefined identifiers, which is how
# Completed Steps
- 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

View File

@@ -24,7 +24,6 @@ const {
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
} = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx");
const {
@@ -58,81 +57,6 @@ 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 (
@@ -661,46 +585,10 @@ async function handleRpc(method, params, origin) {
}
if (method === "eth_sendTransaction") {
// Synchronous, before any await: two requests delivered in the same
// tick must not both get past this.
if (!reserveTxApprovalSlot()) {
return {
error: {
code: TX_APPROVAL_PENDING_CODE,
message: TX_APPROVAL_PENDING_MESSAGE,
},
};
}
try {
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();
}
}
// Proxy safe read-only methods to the RPC node
if (PROXY_METHODS.includes(method)) {
try {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
return { error: { message: e.message } };
}
}
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" } };
if (!activeAddress)
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
@@ -759,6 +647,19 @@ async function handleSendTransaction(params, origin) {
);
if (decision.error) return { error: decision.error };
return { result: decision.txHash };
}
// Proxy safe read-only methods to the RPC node
if (PROXY_METHODS.includes(method)) {
try {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
return { error: { message: e.message } };
}
}
return { error: { message: "Unsupported method: " + method } };
}
// Broadcast chainChanged to all tabs when the network is switched.
@@ -1058,7 +959,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: outcome.stage,
stage: TX_STAGE_SIGN,
});
return false;
}
@@ -1118,28 +1019,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
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,
stage: TX_STAGE_VERIFY,
});
return;
}
@@ -1147,7 +1027,6 @@ 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 },
@@ -1160,11 +1039,6 @@ 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,
@@ -1174,7 +1048,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: outcome.stage,
stage: TX_STAGE_BROADCAST,
});
}
})();

View File

@@ -11,39 +11,6 @@
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;
@@ -53,7 +20,7 @@
if (!p) return;
delete pending[id];
if (error) {
p.reject(toPageError(error));
p.reject(new Error(error.message || "Request failed"));
} else {
p.resolve(result);
}

View File

@@ -602,12 +602,6 @@ 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;
@@ -617,59 +611,6 @@ 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
@@ -686,31 +627,12 @@ const NONCE_COLLISION_MESSAGE =
// 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, stage };
return { error, retryable, spendApproval: !retryable };
}
// What the popup shows and does after the background reports a failed signing
@@ -720,20 +642,14 @@ 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. 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.
// wrong instruction.
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_NONCE) {
message +=
" The transaction did not reach the network." +
" Please send it again from the site.";
} else if (stage === TX_STAGE_BROADCAST) {
if (stage === TX_STAGE_BROADCAST) {
message +=
" The transaction may still have reached the network." +
" Check the account before sending it again.";
@@ -759,11 +675,9 @@ module.exports = {
assertWithinCeilings,
sameAddress,
failureIsRetryable,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
ApprovalMismatchError,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
@@ -772,7 +686,6 @@ module.exports = {
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
};

View File

@@ -8,19 +8,12 @@
// 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 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
// 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
// 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,
@@ -100,7 +93,7 @@ function isSpoofedSymbol(symbol, contractAddress) {
if (!KNOWN_SYMBOLS.has(sym)) return false;
const legit = KNOWN_SYMBOLS.get(sym);
if (legit === null) return true;
return !legit.has(contract);
return contract !== normalizeAddress(legit);
}
module.exports = {

View File

@@ -3607,33 +3607,14 @@ for (const t of TOKENS) {
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
}
// 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.
// Build a map of symbol (uppercased) -> legitimate contract address (lowercased).
// Used for spoofed-symbol detection. "ETH" maps to null (native token).
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, 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());
KNOWN_SYMBOLS.set(upper, t.address.toLowerCase());
}
}

View File

@@ -14,10 +14,8 @@ const {
assertWithinCeilings,
sameAddress,
failureIsRetryable,
isNonceCollision,
describeTxFailure,
describeSigningFailure,
NONCE_COLLISION_MESSAGE,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
@@ -25,7 +23,6 @@ const {
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_NONCE,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify");
@@ -1194,6 +1191,7 @@ describe("signing failure and retry", () => {
"already known",
"timeout of 30000ms exceeded",
"could not coalesce error",
"replacement transaction underpriced",
]) {
const outcome = describeTxFailure(
TX_STAGE_BROADCAST,
@@ -1201,76 +1199,10 @@ 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(
{

View File

@@ -206,10 +206,6 @@ 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;
});
@@ -223,12 +219,7 @@ function loadBackground(options) {
sendResponse,
);
return {
id: () =>
created.length > windowIndex
? new URL(created[windowIndex].url).searchParams.get(
"approval",
)
: null,
id: () => new URL(created[0].url).searchParams.get("approval"),
result: () => rpcResult,
};
}
@@ -453,176 +444,6 @@ 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

View File

@@ -86,11 +86,9 @@ const DAPP_URL = DAPP_ORIGIN + "/";
// 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 rejection branch records `code` as it arrives. 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
@@ -122,7 +120,6 @@ const DAPP_HTML = [
" 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,",
" };",

View File

@@ -1591,14 +1591,15 @@ async function lastResponseError(page) {
}
// A rejected prompt, asserted at both ends: the page's promise rejected
// rather than hanging or resolving, and EIP-1193 code 4001 is present both
// on the wire and on the Error the calling page catches.
// rather than hanging or resolving, and the response that crossed the
// boundary carried EIP-1193 code 4001.
//
// Both ends matter because they used to disagree. The code crossed the
// boundary correctly and src/content/inpage.js then threw it away, rebuilding
// every rejection as `new Error(error.message)` — so a dApp branching on
// `err.code === 4001` saw undefined and could not tell a refusal from a
// failure (#274). Asserting only the wire would leave that gap invisible.
// The code is asserted on the wire because that is the only place it
// survives. src/content/inpage.js rebuilds the rejection as `new
// Error(error.message)`, so the Error the calling page catches carries the
// message and no code. That is reported rather than asserted either way —
// locking in the current behaviour would make the gap permanent, and
// asserting the code on the Error would fail today.
async function assertUserRejection(page, key, label) {
const outcome = await settleRequest(page, key);
assert(
@@ -1620,32 +1621,15 @@ async function assertUserRejection(page, key, label) {
" did not carry EIP-1193 code 4001 across the boundary: " +
JSON.stringify(error),
);
assert(
outcome.hasCode,
label +
" reached the page as an error with no code property at all, so a " +
"dApp cannot tell the user's refusal from a failure: " +
JSON.stringify(outcome),
);
assert(
outcome.code === 4001,
label +
" reached the page with code " +
JSON.stringify(outcome.code) +
" rather than EIP-1193 4001",
);
assert(
outcome.name === "ProviderRpcError",
label +
" reached the page as " +
JSON.stringify(outcome.name) +
" rather than an EIP-1193 ProviderRpcError",
);
console.log(
"# " +
label +
": code 4001 on the wire and on the page's " +
outcome.name,
": boundary code=" +
error.code +
" page Error.code=" +
JSON.stringify(outcome.code) +
" page Error carries a code=" +
outcome.hasCode,
);
return outcome;
}

View File

@@ -1,310 +0,0 @@
// 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",
);
});
});

View File

@@ -56,7 +56,7 @@ global.chrome = {
};
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
const { TOKENS, KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { filterTransactions } = require("../src/shared/transactions");
const {
fetchTokenBalances,
@@ -284,138 +284,12 @@ describe("the shared rule: symbols that render as a known symbol", () => {
// 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) {
for (const [symbol, address] of KNOWN_SYMBOLS) {
expect(symbol).toBe(symbol.trim());
expect(symbol).toMatch(/^[!-~]+$/);
if (addresses === null) continue;
for (const address of addresses) {
if (address === null) continue;
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);
}
});
});

View File

@@ -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")]).toEqual([USDC_CONTRACT]);
expect([...KNOWN_SYMBOLS.get("WETH")]).toEqual([WETH_CONTRACT]);
expect(KNOWN_SYMBOLS.get("USDC")).toBe(USDC_CONTRACT);
expect(KNOWN_SYMBOLS.get("WETH")).toBe(WETH_CONTRACT);
});
test("the spam fixture symbol is not in the known token list", () => {