TxApproval built its From, Contract and decoded-action address links for the active network, so a Sepolia transaction reviewed with mainnet active linked to the mainnet explorer. approvalAddressHtml() now takes the transaction's chain id and passes it to renderAddressHtml(), as SuccessTx and ErrorTx do. A chain id that names no network has no explorer, so its address lines are drawn without a link and the screen still shows. SignApproval and SiteApproval still link for the active network. The walletDefects fixture's numeric chain id, which names no network, is now the hex string the background sends. Model: opus-5-5
271 lines
9.4 KiB
JavaScript
271 lines
9.4 KiB
JavaScript
// The approval screen for a site's Sepolia token transfer while Ethereum
|
|
// Mainnet is active.
|
|
//
|
|
// The screen named the token, and scaled its amount, from the active network's
|
|
// tracked tokens and balances, so a token the wallet tracks on Sepolia read as
|
|
// an unknown token with its amount in base units, and the screens after it
|
|
// labelled the amount `?` (https://git.eeqj.de/sneak/AutistMask/issues/629).
|
|
// It now uses what the wallet knows of the tokens on the network the
|
|
// transaction is for.
|
|
//
|
|
// Its address links opened the active network's explorer
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/631). They now open the
|
|
// explorer of the network the transaction is for.
|
|
//
|
|
// Driven through the real approval screen, password and Confirm included,
|
|
// against a minimal DOM stub in the shape tests/swapStatusAmount.test.js uses.
|
|
// The vault is mocked so that no password has to be hashed.
|
|
|
|
jest.mock("../src/shared/vault", () => ({
|
|
decryptWithPassword: jest.fn(),
|
|
}));
|
|
|
|
globalThis.chrome = {
|
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
};
|
|
|
|
const { Interface } = require("ethers");
|
|
const { state } = require("../src/shared/state");
|
|
const { NETWORKS } = require("../src/shared/networks");
|
|
const { decryptWithPassword } = require("../src/shared/vault");
|
|
const approval = require("../src/popup/views/approval");
|
|
const txStatus = require("../src/popup/views/txStatus");
|
|
|
|
// A well-known test phrase, and its first address.
|
|
const PHRASE = "test test test test test test test test test test test junk";
|
|
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
|
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
|
|
// A token the bundled token list does not name.
|
|
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
|
|
|
|
// FROM sends 1234.5 of the 6-decimal token to RECIPIENT.
|
|
const TRANSFER_DATA = new Interface([
|
|
"function transfer(address to, uint256 amount)",
|
|
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]);
|
|
|
|
function makeElement(id) {
|
|
const classes = new Set();
|
|
const el = {
|
|
id,
|
|
textContent: "",
|
|
value: "",
|
|
innerHTML: "",
|
|
disabled: false,
|
|
style: {},
|
|
dataset: {},
|
|
listeners: {},
|
|
classList: {
|
|
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
contains: (n) => classes.has(n),
|
|
toggle: (n, force) => {
|
|
const on = force === undefined ? !classes.has(n) : force;
|
|
if (on) classes.add(n);
|
|
else classes.delete(n);
|
|
return on;
|
|
},
|
|
},
|
|
addEventListener: (name, fn) => {
|
|
el.listeners[name] = el.listeners[name] || [];
|
|
el.listeners[name].push(fn);
|
|
},
|
|
querySelectorAll: () => [],
|
|
appendChild: () => {},
|
|
};
|
|
// Views reach for .parentElement to hide whole sections.
|
|
Object.defineProperty(el, "parentElement", {
|
|
get: () => node(id + "-parent"),
|
|
});
|
|
return el;
|
|
}
|
|
|
|
function makeDocument() {
|
|
const els = new Map();
|
|
return {
|
|
getElementById(id) {
|
|
// The debug banner is created on demand by helpers.js; absent
|
|
// is the state a non-debug, non-testnet popup is in.
|
|
if (id === "debug-banner") return null;
|
|
if (!els.has(id)) els.set(id, makeElement(id));
|
|
return els.get(id);
|
|
},
|
|
createElement: () => makeElement("created"),
|
|
body: { prepend: () => {} },
|
|
};
|
|
}
|
|
|
|
function node(id) {
|
|
return globalThis.document.getElementById(id);
|
|
}
|
|
|
|
function click(id) {
|
|
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
|
|
}
|
|
|
|
// One line of the decoded details: its label and what is drawn under it.
|
|
function decodedLine(label) {
|
|
return node("approve-tx-decoded-details")
|
|
.innerHTML.split('<div class="mb-2">')
|
|
.find((line) =>
|
|
line.startsWith(`<div class="text-muted">${label}</div>`),
|
|
);
|
|
}
|
|
|
|
// Open the approval screen, with Ethereum Mainnet active and FROM holding
|
|
// `tokenBalances` there, for the site's transaction sending TRANSFER_DATA to
|
|
// the token, on Sepolia unless `chainId` names another chain. Confirm answers
|
|
// the signed transaction with a failure, which leads to the error screen.
|
|
async function showSepoliaTransfer(
|
|
tokenBalances = [],
|
|
chainId = NETWORKS.sepolia.chainId,
|
|
) {
|
|
globalThis.document = makeDocument();
|
|
globalThis.window = { location: { search: "" }, close: () => {} };
|
|
globalThis.chrome.runtime = {
|
|
connect: () => ({ postMessage: () => {} }),
|
|
sendMessage: (msg, reply) => {
|
|
if (!reply) return;
|
|
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
|
|
return reply({
|
|
error: "The transaction could not be sent.",
|
|
retryable: false,
|
|
});
|
|
}
|
|
reply({
|
|
type: "tx",
|
|
origin: "https://dapp.example",
|
|
isPhishingDomain: false,
|
|
approvedFrom: FROM,
|
|
approvedTx: {
|
|
type: 2,
|
|
from: FROM,
|
|
chainId,
|
|
nonce: "0x7",
|
|
gasLimit: "0x30d40",
|
|
maxPriorityFeePerGas: "0x3b9aca00",
|
|
maxFeePerGas: "0x77359400",
|
|
to: TOKEN,
|
|
value: "0x0",
|
|
data: TRANSFER_DATA,
|
|
accessList: [],
|
|
},
|
|
});
|
|
},
|
|
};
|
|
state.networkId = "mainnet";
|
|
state.activeAddress = FROM;
|
|
state.wallets = [
|
|
{
|
|
type: "hd",
|
|
name: "Wallet 1",
|
|
xpub: "xpub-wallet-1",
|
|
encryptedSecret: "encrypted-secret-1",
|
|
nextIndex: 1,
|
|
addresses: [
|
|
{ address: FROM, index: 0, balance: "0", tokenBalances },
|
|
],
|
|
},
|
|
];
|
|
state.currentView = null;
|
|
txStatus.init({ doRefreshAndRender: () => {} });
|
|
approval.init({});
|
|
await approval.show(1);
|
|
}
|
|
|
|
async function confirm() {
|
|
decryptWithPassword.mockResolvedValue(PHRASE);
|
|
node("approve-tx-password").value = "any password";
|
|
await click("btn-approve-tx");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
state.trackedTokens = [];
|
|
state.networkTrackedTokens = {};
|
|
});
|
|
|
|
describe("a token tracked on Sepolia only", () => {
|
|
beforeEach(() => {
|
|
state.networkTrackedTokens = {
|
|
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
|
|
};
|
|
});
|
|
|
|
test("is named on the Contract line and the decoded Token line, with its amount at its scale", async () => {
|
|
await showSepoliaTransfer();
|
|
expect(node("approve-tx-to").innerHTML).toContain(
|
|
'<div class="font-bold mb-1">TDOL</div>',
|
|
);
|
|
expect(decodedLine("Token")).toContain(
|
|
'<div class="font-bold">TDOL</div>',
|
|
);
|
|
expect(decodedLine("Amount")).toContain(
|
|
'<div class="font-bold">1234.5000 TDOL</div>',
|
|
);
|
|
});
|
|
|
|
test("labels the amount on the error screen that follows", async () => {
|
|
await showSepoliaTransfer();
|
|
await confirm();
|
|
expect(state.currentView).toBe("error-tx");
|
|
expect(node("error-tx-summary").textContent).toBe("1234.5000 TDOL");
|
|
});
|
|
});
|
|
|
|
// The same contract address on mainnet is another token, so what the wallet
|
|
// tracks and holds there names nothing on Sepolia.
|
|
test("a token tracked and held on mainnet only is not named, and its amount is in base units", async () => {
|
|
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
|
|
await showSepoliaTransfer([
|
|
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
|
|
]);
|
|
expect(node("approve-tx-to").innerHTML).not.toContain("MDOL");
|
|
expect(decodedLine("Token")).toContain(
|
|
'<div class="font-bold">Unknown token</div>',
|
|
);
|
|
expect(decodedLine("Amount")).toContain(
|
|
'<div class="font-bold">1234500000 base units (decimals unknown)</div>',
|
|
);
|
|
});
|
|
|
|
// The From and Contract lines, and the decoded Token and Recipient lines.
|
|
test("every address links to the Sepolia explorer", async () => {
|
|
await showSepoliaTransfer();
|
|
const sepolia = NETWORKS.sepolia.explorerUrl;
|
|
expect(node("approve-tx-from").innerHTML).toContain(
|
|
`href="${sepolia}/address/${FROM}"`,
|
|
);
|
|
expect(node("approve-tx-to").innerHTML).toContain(
|
|
`href="${sepolia}/address/${TOKEN}"`,
|
|
);
|
|
expect(decodedLine("Token")).toContain(
|
|
`href="${sepolia}/address/${TOKEN}"`,
|
|
);
|
|
expect(decodedLine("Recipient")).toContain(
|
|
`href="${sepolia}/address/${RECIPIENT}"`,
|
|
);
|
|
for (const id of [
|
|
"approve-tx-from",
|
|
"approve-tx-to",
|
|
"approve-tx-decoded-details",
|
|
]) {
|
|
expect(node(id).innerHTML).not.toContain(NETWORKS.mainnet.explorerUrl);
|
|
}
|
|
});
|
|
|
|
// Chain id 1337 names no network here, so it has no explorer to link to.
|
|
test("a transaction on a chain id that names no network is shown with no address links", async () => {
|
|
await showSepoliaTransfer([], "0x539");
|
|
expect(state.currentView).toBe("approve-tx");
|
|
expect(node("approve-tx-network").textContent).toBe(
|
|
"Unknown network (chain id 1337)",
|
|
);
|
|
expect(node("approve-tx-from").innerHTML).toContain(FROM);
|
|
for (const id of [
|
|
"approve-tx-from",
|
|
"approve-tx-to",
|
|
"approve-tx-decoded-details",
|
|
]) {
|
|
expect(node(id).innerHTML).not.toContain("href=");
|
|
}
|
|
});
|