Compare commits
5 Commits
681f2bf83c
...
issue-271-
| Author | SHA1 | Date | |
|---|---|---|---|
| 73db8eee43 | |||
| 9dcd875dd4 | |||
| c755a5e944 | |||
| d5595c0151 | |||
| e4c3708b84 |
34
README.md
34
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
|
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
|
||||||
previously correct by reading only.
|
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
|
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
|
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
|
consumes exactly one matching record, and a declaration nothing matched fails
|
||||||
@@ -1135,7 +1163,11 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
|
|||||||
opening the window, so the screen shows a complete transaction and the signed
|
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
|
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
|
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**:
|
- **Elements**:
|
||||||
- "Transaction Request" heading
|
- "Transaction Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
|
|||||||
56
TODO.md
56
TODO.md
@@ -45,6 +45,62 @@ undefined identifiers, which is how
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
||||||
|
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
|
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
|
||||||
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
|
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
|
||||||
character that paints nothing removed (the format and default-ignorable
|
character that paints nothing removed (the format and default-ignorable
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const {
|
|||||||
TX_STAGE_VERIFY,
|
TX_STAGE_VERIFY,
|
||||||
TX_STAGE_BROADCAST,
|
TX_STAGE_BROADCAST,
|
||||||
TX_STAGE_INFLIGHT,
|
TX_STAGE_INFLIGHT,
|
||||||
|
TX_STAGE_NONCE,
|
||||||
} = require("../shared/approvalVerify");
|
} = require("../shared/approvalVerify");
|
||||||
const { prepareApprovalTx } = require("../shared/approvalTx");
|
const { prepareApprovalTx } = require("../shared/approvalTx");
|
||||||
const {
|
const {
|
||||||
@@ -57,6 +58,81 @@ const connectedSites = {};
|
|||||||
// Pending approval requests: { id: { origin, hostname, resolve } }
|
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||||
const pendingApprovals = {};
|
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() {
|
async function getState() {
|
||||||
const result = await storageApi.get("autistmask");
|
const result = await storageApi.get("autistmask");
|
||||||
return (
|
return (
|
||||||
@@ -585,68 +661,24 @@ async function handleRpc(method, params, origin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === "eth_sendTransaction") {
|
if (method === "eth_sendTransaction") {
|
||||||
const s = await getState();
|
// Synchronous, before any await: two requests delivered in the same
|
||||||
const activeAddress = await getActiveAddress();
|
// tick must not both get past this.
|
||||||
if (!activeAddress)
|
if (!reserveTxApprovalSlot()) {
|
||||||
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 {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: 4100,
|
code: TX_APPROVAL_PENDING_CODE,
|
||||||
message:
|
message: TX_APPROVAL_PENDING_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 {
|
try {
|
||||||
approvedTx = await prepareApprovalTx(
|
return await handleSendTransaction(params, origin);
|
||||||
getProvider(await getRpcUrl()),
|
} finally {
|
||||||
activeAddress,
|
// Held until the page has its answer — the approval was broadcast,
|
||||||
txParams,
|
// rejected, or retired by a closed window — because until then its
|
||||||
);
|
// nonce is allocated and unspent.
|
||||||
} catch (e) {
|
releaseTxApprovalSlot();
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy safe read-only methods to the RPC node
|
// 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 } };
|
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.
|
// Broadcast chainChanged to all tabs when the network is switched.
|
||||||
function broadcastChainChanged(chainId) {
|
function broadcastChainChanged(chainId) {
|
||||||
tabsApi.query({}, (tabs) => {
|
tabsApi.query({}, (tabs) => {
|
||||||
@@ -959,7 +1058,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
sendResponse({
|
sendResponse({
|
||||||
error: outcome.error,
|
error: outcome.error,
|
||||||
retryable: outcome.retryable,
|
retryable: outcome.retryable,
|
||||||
stage: TX_STAGE_SIGN,
|
stage: outcome.stage,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1019,7 +1118,28 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
sendResponse({
|
sendResponse({
|
||||||
error: outcome.error,
|
error: outcome.error,
|
||||||
retryable: outcome.retryable,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1027,6 +1147,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
try {
|
try {
|
||||||
const provider = getProvider(state.rpcUrl);
|
const provider = getProvider(state.rpcUrl);
|
||||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||||
|
if (nonce !== null) spent.add(nonce);
|
||||||
settleApproval(
|
settleApproval(
|
||||||
msg.id,
|
msg.id,
|
||||||
{ txHash: tx.hash },
|
{ txHash: tx.hash },
|
||||||
@@ -1039,6 +1160,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
// tell a transaction that never left from one already in the
|
// tell a transaction that never left from one already in the
|
||||||
// mempool. The page has been given its outcome for this
|
// mempool. The page has been given its outcome for this
|
||||||
// request; a second attempt would report a second one.
|
// 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);
|
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
|
||||||
settleApproval(
|
settleApproval(
|
||||||
msg.id,
|
msg.id,
|
||||||
@@ -1048,7 +1174,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
sendResponse({
|
sendResponse({
|
||||||
error: outcome.error,
|
error: outcome.error,
|
||||||
retryable: outcome.retryable,
|
retryable: outcome.retryable,
|
||||||
stage: TX_STAGE_BROADCAST,
|
stage: outcome.stage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -11,6 +11,39 @@
|
|||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
const pending = {};
|
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
|
// Listen for responses from the content script
|
||||||
window.addEventListener("message", function onUuid(event) {
|
window.addEventListener("message", function onUuid(event) {
|
||||||
if (event.source !== window) return;
|
if (event.source !== window) return;
|
||||||
@@ -20,7 +53,7 @@
|
|||||||
if (!p) return;
|
if (!p) return;
|
||||||
delete pending[id];
|
delete pending[id];
|
||||||
if (error) {
|
if (error) {
|
||||||
p.reject(new Error(error.message || "Request failed"));
|
p.reject(toPageError(error));
|
||||||
} else {
|
} else {
|
||||||
p.resolve(result);
|
p.resolve(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -602,6 +602,12 @@ const TX_STAGE_BROADCAST = "broadcast";
|
|||||||
// may yet succeed, so the one thing the popup must not say is "start again
|
// may yet succeed, so the one thing the popup must not say is "start again
|
||||||
// from the site".
|
// from the site".
|
||||||
const TX_STAGE_INFLIGHT = "inflight";
|
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) {
|
function errorText(err) {
|
||||||
if (typeof err === "string" && err !== "") return err;
|
if (typeof err === "string" && err !== "") return err;
|
||||||
@@ -611,6 +617,59 @@ function errorText(err) {
|
|||||||
return "The transaction could not be sent.";
|
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
|
// What the background does with a pending transaction approval after a failed
|
||||||
// attempt: what it tells the popup, and whether the approval is spent
|
// 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
|
// (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
|
// 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
|
// spent and the requesting page has been given its outcome; a second
|
||||||
// attempt against it would report a second outcome for one request.
|
// 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) {
|
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 error = errorText(err);
|
||||||
const retryable =
|
const retryable =
|
||||||
stage === TX_STAGE_SIGN ||
|
stage === TX_STAGE_SIGN ||
|
||||||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
|
(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
|
// 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
|
// 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
|
// 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) {
|
function describeSigningFailure(response, fallbackMessage) {
|
||||||
let message = (response && response.error) || fallbackMessage;
|
let message = (response && response.error) || fallbackMessage;
|
||||||
if (!/[.!?]$/.test(message)) message += ".";
|
if (!/[.!?]$/.test(message)) message += ".";
|
||||||
const retryable = !!(response && response.retryable);
|
const retryable = !!(response && response.retryable);
|
||||||
const stage = response && response.stage;
|
const stage = response && response.stage;
|
||||||
if (!retryable) {
|
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 +=
|
message +=
|
||||||
" The transaction may still have reached the network." +
|
" The transaction may still have reached the network." +
|
||||||
" Check the account before sending it again.";
|
" Check the account before sending it again.";
|
||||||
@@ -675,9 +759,11 @@ module.exports = {
|
|||||||
assertWithinCeilings,
|
assertWithinCeilings,
|
||||||
sameAddress,
|
sameAddress,
|
||||||
failureIsRetryable,
|
failureIsRetryable,
|
||||||
|
isNonceCollision,
|
||||||
describeTxFailure,
|
describeTxFailure,
|
||||||
describeSigningFailure,
|
describeSigningFailure,
|
||||||
ApprovalMismatchError,
|
ApprovalMismatchError,
|
||||||
|
NONCE_COLLISION_MESSAGE,
|
||||||
ALLOWED_TX_TYPES,
|
ALLOWED_TX_TYPES,
|
||||||
SERIALIZED_FIELDS,
|
SERIALIZED_FIELDS,
|
||||||
FORBIDDEN_FIELDS,
|
FORBIDDEN_FIELDS,
|
||||||
@@ -686,6 +772,7 @@ module.exports = {
|
|||||||
TX_STAGE_VERIFY,
|
TX_STAGE_VERIFY,
|
||||||
TX_STAGE_BROADCAST,
|
TX_STAGE_BROADCAST,
|
||||||
TX_STAGE_INFLIGHT,
|
TX_STAGE_INFLIGHT,
|
||||||
|
TX_STAGE_NONCE,
|
||||||
MAX_GAS_LIMIT,
|
MAX_GAS_LIMIT,
|
||||||
MAX_FEE_PER_GAS,
|
MAX_FEE_PER_GAS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,12 +8,19 @@
|
|||||||
// either verdict alone, because the balance list is where the user forms
|
// either verdict alone, because the balance list is where the user forms
|
||||||
// their belief about what they own (issue #235).
|
// their belief about what they own (issue #235).
|
||||||
//
|
//
|
||||||
// KNOWN_SYMBOLS maps a symbol to the lowercased contract address that may
|
// KNOWN_SYMBOLS maps a symbol to the set of lowercased contract addresses
|
||||||
// bear it, or to null. Null means the symbol belongs to the native asset,
|
// that may bear it, or to null. Null means the symbol belongs to the native
|
||||||
// which has no contract at all, so no contract may bear it and every one
|
// asset, which has no contract at all, so no contract may bear it and every
|
||||||
// that does is a spoof. "ETH" is the only such entry today; the rule is
|
// 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.
|
// 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
|
// 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
|
// 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,
|
// question is whether the symbol reaches the user's eye as a known one,
|
||||||
@@ -93,7 +100,7 @@ function isSpoofedSymbol(symbol, contractAddress) {
|
|||||||
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
if (!KNOWN_SYMBOLS.has(sym)) return false;
|
||||||
const legit = KNOWN_SYMBOLS.get(sym);
|
const legit = KNOWN_SYMBOLS.get(sym);
|
||||||
if (legit === null) return true;
|
if (legit === null) return true;
|
||||||
return contract !== normalizeAddress(legit);
|
return !legit.has(contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -3607,14 +3607,33 @@ for (const t of TOKENS) {
|
|||||||
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
|
TOKEN_BY_ADDRESS.set(t.address.toLowerCase(), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a map of symbol (uppercased) -> legitimate contract address (lowercased).
|
// Build a map of symbol (uppercased) -> the set of contract addresses
|
||||||
// Used for spoofed-symbol detection. "ETH" maps to null (native token).
|
// (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();
|
const KNOWN_SYMBOLS = new Map();
|
||||||
KNOWN_SYMBOLS.set("ETH", null);
|
KNOWN_SYMBOLS.set("ETH", null);
|
||||||
for (const t of TOKENS) {
|
for (const t of TOKENS) {
|
||||||
const upper = t.symbol.toUpperCase();
|
const upper = t.symbol.toUpperCase();
|
||||||
if (!KNOWN_SYMBOLS.has(upper)) {
|
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,
|
assertWithinCeilings,
|
||||||
sameAddress,
|
sameAddress,
|
||||||
failureIsRetryable,
|
failureIsRetryable,
|
||||||
|
isNonceCollision,
|
||||||
describeTxFailure,
|
describeTxFailure,
|
||||||
describeSigningFailure,
|
describeSigningFailure,
|
||||||
|
NONCE_COLLISION_MESSAGE,
|
||||||
ALLOWED_TX_TYPES,
|
ALLOWED_TX_TYPES,
|
||||||
SERIALIZED_FIELDS,
|
SERIALIZED_FIELDS,
|
||||||
FORBIDDEN_FIELDS,
|
FORBIDDEN_FIELDS,
|
||||||
@@ -23,6 +25,7 @@ const {
|
|||||||
TX_STAGE_SIGN,
|
TX_STAGE_SIGN,
|
||||||
TX_STAGE_VERIFY,
|
TX_STAGE_VERIFY,
|
||||||
TX_STAGE_BROADCAST,
|
TX_STAGE_BROADCAST,
|
||||||
|
TX_STAGE_NONCE,
|
||||||
MAX_GAS_LIMIT,
|
MAX_GAS_LIMIT,
|
||||||
MAX_FEE_PER_GAS,
|
MAX_FEE_PER_GAS,
|
||||||
} = require("../src/shared/approvalVerify");
|
} = require("../src/shared/approvalVerify");
|
||||||
@@ -1191,7 +1194,6 @@ describe("signing failure and retry", () => {
|
|||||||
"already known",
|
"already known",
|
||||||
"timeout of 30000ms exceeded",
|
"timeout of 30000ms exceeded",
|
||||||
"could not coalesce error",
|
"could not coalesce error",
|
||||||
"replacement transaction underpriced",
|
|
||||||
]) {
|
]) {
|
||||||
const outcome = describeTxFailure(
|
const outcome = describeTxFailure(
|
||||||
TX_STAGE_BROADCAST,
|
TX_STAGE_BROADCAST,
|
||||||
@@ -1199,10 +1201,76 @@ describe("signing failure and retry", () => {
|
|||||||
);
|
);
|
||||||
expect(outcome.retryable).toBe(false);
|
expect(outcome.retryable).toBe(false);
|
||||||
expect(outcome.spendApproval).toBe(true);
|
expect(outcome.spendApproval).toBe(true);
|
||||||
|
expect(outcome.stage).toBe(TX_STAGE_BROADCAST);
|
||||||
expect(outcome.error).toBe(message);
|
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", () => {
|
test("a failed broadcast does not tell the user to send it again", () => {
|
||||||
const outcome = describeSigningFailure(
|
const outcome = describeSigningFailure(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -206,6 +206,10 @@ function loadBackground(options) {
|
|||||||
// approval id back out of the popup URL the background opened.
|
// approval id back out of the popup URL the background opened.
|
||||||
function requestTx(txParams) {
|
function requestTx(txParams) {
|
||||||
let rpcResult = null;
|
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) => {
|
const sendResponse = jest.fn((r) => {
|
||||||
rpcResult = r;
|
rpcResult = r;
|
||||||
});
|
});
|
||||||
@@ -219,7 +223,12 @@ function loadBackground(options) {
|
|||||||
sendResponse,
|
sendResponse,
|
||||||
);
|
);
|
||||||
return {
|
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,
|
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
|
// 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
|
// 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
|
// one the old comparison — against the dApp's request, for the address that is
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
const { Transaction } = require("ethers");
|
||||||
|
|
||||||
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
// Fictional ERC-20 used to seed the transaction-detail test. The symbol
|
||||||
// must not collide with any entry in src/shared/tokenList.js, or
|
// must not collide with any entry in src/shared/tokenList.js, or
|
||||||
// isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a
|
// 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");
|
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
|
// ------------------------------------------------------------ fee fixture
|
||||||
//
|
//
|
||||||
// The confirmation screen carries two different numbers for the same
|
// The confirmation screen carries two different numbers for the same
|
||||||
@@ -101,6 +184,11 @@ const RPC_RESULTS = {
|
|||||||
eth_estimateGas: hex(GAS_LIMIT),
|
eth_estimateGas: hex(GAS_LIMIT),
|
||||||
eth_getTransactionCount: "0x0",
|
eth_getTransactionCount: "0x0",
|
||||||
eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI),
|
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
|
// The "latest" block, which ethers' getFeeData() reads baseFeePerGas from
|
||||||
@@ -264,6 +352,31 @@ function rpcReply(req, opts, report) {
|
|||||||
if (req.method === "eth_getBlockByNumber") {
|
if (req.method === "eth_getBlockByNumber") {
|
||||||
return Object.assign(envelope, { result: latestBlock() });
|
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) {
|
if (req.method === "eth_estimateGas" && opts.failGasEstimate) {
|
||||||
// A refusal the node itself would produce, not a transport error:
|
// A refusal the node itself would produce, not a transport error:
|
||||||
// this is the shape the confirmation screen has to turn into
|
// this is the shape the confirmation screen has to turn into
|
||||||
@@ -375,6 +488,8 @@ function traceEnabled(raw) {
|
|||||||
* node-side refusal.
|
* node-side refusal.
|
||||||
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
* @param {boolean} [opts.holdGasEstimate] hold every batch containing an
|
||||||
* eth_estimateGas until this is cleared again.
|
* 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) =>
|
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||||
* Promise<string|null>}>}
|
* Promise<string|null>}>}
|
||||||
*/
|
*/
|
||||||
@@ -420,6 +535,18 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
return handleRpc(route, req.postData(), opts, report);
|
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
|
// Blockscout v2
|
||||||
if (p.includes("/api/v2/")) {
|
if (p.includes("/api/v2/")) {
|
||||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) {
|
||||||
@@ -508,6 +635,8 @@ async function installNetworkStubs(ctx, opts) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
installNetworkStubs,
|
installNetworkStubs,
|
||||||
|
DAPP_ORIGIN,
|
||||||
|
DAPP_URL,
|
||||||
FEE_ESTIMATE_WEI,
|
FEE_ESTIMATE_WEI,
|
||||||
FEE_RESERVE_WEI,
|
FEE_RESERVE_WEI,
|
||||||
STUB_COUNTERPARTY,
|
STUB_COUNTERPARTY,
|
||||||
|
|||||||
1002
tests/e2e/run.js
1002
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 { 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 { filterTransactions } = require("../src/shared/transactions");
|
||||||
const {
|
const {
|
||||||
fetchTokenBalances,
|
fetchTokenBalances,
|
||||||
@@ -284,11 +284,137 @@ describe("the shared rule: symbols that render as a known symbol", () => {
|
|||||||
// asserts the claim it stands for — `[ -~]` would admit an interior
|
// asserts the claim it stands for — `[ -~]` would admit an interior
|
||||||
// space and let a whitespace-bearing entry through the guard.
|
// space and let a whitespace-bearing entry through the guard.
|
||||||
test("no bundled symbol is touched by the normalization", () => {
|
test("no bundled symbol is touched by the normalization", () => {
|
||||||
for (const [symbol, address] of KNOWN_SYMBOLS) {
|
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
|
||||||
expect(symbol).toBe(symbol.trim());
|
expect(symbol).toBe(symbol.trim());
|
||||||
expect(symbol).toMatch(/^[!-~]+$/);
|
expect(symbol).toMatch(/^[!-~]+$/);
|
||||||
if (address === null) continue;
|
if (addresses === null) continue;
|
||||||
expect(isSpoofedSymbol(symbol, address)).toBe(false);
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -207,8 +207,8 @@ describe("token list assumptions the fixtures rely on", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("USDC and WETH map to their genuine lowercased contracts", () => {
|
test("USDC and WETH map to their genuine lowercased contracts", () => {
|
||||||
expect(KNOWN_SYMBOLS.get("USDC")).toBe(USDC_CONTRACT);
|
expect([...KNOWN_SYMBOLS.get("USDC")]).toEqual([USDC_CONTRACT]);
|
||||||
expect(KNOWN_SYMBOLS.get("WETH")).toBe(WETH_CONTRACT);
|
expect([...KNOWN_SYMBOLS.get("WETH")]).toEqual([WETH_CONTRACT]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the spam fixture symbol is not in the known token list", () => {
|
test("the spam fixture symbol is not in the known token list", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user