// 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('
') .find((line) => line.startsWith(`
${label}
`), ); } // Open the approval screen, with Ethereum Mainnet active and FROM holding // `tokenBalances` there, for the site's Sepolia transaction sending // TRANSFER_DATA to the token. Confirm answers the signed transaction with a // failure, which leads to the error screen. async function showSepoliaTransfer(tokenBalances = []) { 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: NETWORKS.sepolia.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( '
TDOL
', ); expect(decodedLine("Token")).toContain( '
TDOL
', ); expect(decodedLine("Amount")).toContain( '
1234.5000 TDOL
', ); }); 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( '
Unknown token
', ); expect(decodedLine("Amount")).toContain( '
1234500000 base units (decimals unknown)
', ); }); // 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); } });