TxApproval and TransactionDetail cut off a long symbol, amount or description at the edge of the screen. These lines now wrap. SuccessTx hid its Amount and To lines whenever it had a decoded action, even one that showed neither. Each line is now hidden only when a readable decoded line shows the same thing. A swap with no Amount line now shows its amount on SuccessTx, as WaitTx and ErrorTx do. Model: opus-5-5
311 lines
11 KiB
JavaScript
311 lines
11 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 fs = require("fs");
|
|
const path = require("path");
|
|
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 showTokenTransfer(
|
|
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 showTokenTransfer();
|
|
expect(node("approve-tx-to").innerHTML).toContain(
|
|
'<div class="font-bold mb-1 break-words">TDOL</div>',
|
|
);
|
|
expect(decodedLine("Token")).toContain(
|
|
'<div class="font-bold break-words">TDOL</div>',
|
|
);
|
|
expect(decodedLine("Amount")).toContain(
|
|
'<div class="font-bold break-words">1234.5000 TDOL</div>',
|
|
);
|
|
});
|
|
|
|
test("labels the amount on the error screen that follows", async () => {
|
|
await showTokenTransfer();
|
|
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 showTokenTransfer([
|
|
{ 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 break-words">Unknown token</div>',
|
|
);
|
|
expect(decodedLine("Amount")).toContain(
|
|
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
|
|
);
|
|
});
|
|
|
|
// Nothing limits the length of a token's symbol. Without the break-words class
|
|
// a long one runs past the right edge of the screen and is cut off.
|
|
describe("a long token symbol", () => {
|
|
const LONG = "L".repeat(60);
|
|
|
|
test("wraps on every decoded line and on the Contract line", async () => {
|
|
state.networkTrackedTokens = {
|
|
sepolia: [{ address: TOKEN, symbol: LONG, decimals: 6 }],
|
|
};
|
|
await showTokenTransfer();
|
|
expect(node("approve-tx-to").innerHTML).toContain(
|
|
`<div class="font-bold mb-1 break-words">${LONG}</div>`,
|
|
);
|
|
expect(node("approve-tx-decoded-details").innerHTML).toContain(
|
|
`<div class="mb-2 break-words">Transfer ${LONG}</div>`,
|
|
);
|
|
expect(decodedLine("Token")).toContain(
|
|
`<div class="font-bold break-words">${LONG}</div>`,
|
|
);
|
|
expect(decodedLine("Amount")).toContain(
|
|
`<div class="font-bold break-words">1234.5000 ${LONG}</div>`,
|
|
);
|
|
});
|
|
|
|
test("wraps on the Action line, and a long amount wraps on the Value line", () => {
|
|
const html = fs.readFileSync(
|
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
|
"utf8",
|
|
);
|
|
expect(html).toMatch(
|
|
/id="approve-tx-action"\s+class="text-xs font-bold mb-2 break-words"/,
|
|
);
|
|
expect(html).toMatch(
|
|
/id="approve-tx-value"\s+class="text-xs font-bold break-words"/,
|
|
);
|
|
});
|
|
});
|
|
|
|
// The From and Contract lines, and the decoded Token and Recipient lines.
|
|
test("every address links to the Sepolia explorer", async () => {
|
|
await showTokenTransfer();
|
|
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 showTokenTransfer([], "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=");
|
|
}
|
|
});
|