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
354 lines
13 KiB
JavaScript
354 lines
13 KiB
JavaScript
// The decoded details on the transaction detail screen of a token transfer
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/515).
|
|
//
|
|
// A history row for a token transfer holds the transfer's recipient as its
|
|
// `to`, not the contract the transaction was sent to. The screen used to decode
|
|
// the transaction's data against that `to`, so its Token line named the
|
|
// recipient, and the amount read "decimals unknown" for a token the wallet
|
|
// tracks. It decodes against the address the explorer reports the transaction
|
|
// was sent to.
|
|
//
|
|
// Its links open the explorer of the network the transaction was sent on,
|
|
// whichever network is active
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is
|
|
// read from that network's Blockscout, the one Settings has for it
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/623). Its decoded details name
|
|
// the token, and scale its amount, from what the wallet knows of that
|
|
// network's tokens (https://git.eeqj.de/sneak/AutistMask/issues/629).
|
|
//
|
|
// The row is the one the real history parser makes from the explorer's answer,
|
|
// opened through transactionDetail.show(), which is what a click on the row
|
|
// calls. Driven against a minimal DOM stub in the shape
|
|
// tests/contractCreation.test.js uses.
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { Interface } = require("ethers");
|
|
|
|
jest.mock("../src/shared/log", () => ({
|
|
log: {
|
|
debugf: () => {},
|
|
infof: () => {},
|
|
warnf: () => {},
|
|
errorf: () => {},
|
|
},
|
|
debugFetch: async (url) => {
|
|
mockAsked.push(url);
|
|
return { ok: true, json: async () => mockExplorerAnswer(url) };
|
|
},
|
|
setRuntimeDebug: () => {},
|
|
isDebug: () => false,
|
|
}));
|
|
|
|
// Blockscout is asked only while the stored "Use Blockscout" is on.
|
|
globalThis.chrome = {
|
|
storage: {
|
|
local: {
|
|
get: async () => ({ autistmask: { useBlockscout: true } }),
|
|
set: async () => {},
|
|
},
|
|
},
|
|
};
|
|
|
|
const { state } = require("../src/shared/state");
|
|
const { NETWORKS } = require("../src/shared/networks");
|
|
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
|
const { displaySymbol } = require("../src/shared/symbolDisplay");
|
|
const transactionDetail = require("../src/popup/views/transactionDetail");
|
|
|
|
const EXPLORER = "https://explorer.example/api/v2";
|
|
// A Blockscout that knows nothing of the transaction.
|
|
const OTHER_EXPLORER = "https://other-explorer.example/api/v2";
|
|
const HOLDER = "0x0000000000000000000000000000000000000a11";
|
|
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
|
|
// A token the wallet tracks and the bundled token list does not name.
|
|
const TOKEN = "0x74de5d4FCbf63E00296fd95d33236B9794016631";
|
|
const TX_HASH =
|
|
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
|
|
|
// HOLDER 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]);
|
|
|
|
// Every URL asked of a Blockscout, in order.
|
|
const mockAsked = [];
|
|
|
|
// The explorer's answers: the transfer in HOLDER's token transfers, and the
|
|
// transaction itself, sent to the token contract.
|
|
function mockExplorerAnswer(url) {
|
|
if (
|
|
url ===
|
|
EXPLORER + "/addresses/" + HOLDER + "/token-transfers?type=ERC-20"
|
|
) {
|
|
return {
|
|
items: [
|
|
{
|
|
transaction_hash: TX_HASH,
|
|
block_number: 100,
|
|
timestamp: "2026-10-01T00:00:00Z",
|
|
from: { hash: HOLDER },
|
|
to: { hash: RECIPIENT },
|
|
total: { value: "1234500000", decimals: "6" },
|
|
token: {
|
|
address_hash: TOKEN,
|
|
symbol: "TDOL",
|
|
holders_count: "5000",
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (url === EXPLORER + "/transactions/" + TX_HASH) {
|
|
return {
|
|
to: { hash: TOKEN },
|
|
raw_input: TRANSFER_DATA,
|
|
block_number: 100,
|
|
};
|
|
}
|
|
return { items: [] };
|
|
}
|
|
|
|
function makeElement(id) {
|
|
const classes = new Set();
|
|
const el = {
|
|
id,
|
|
textContent: "",
|
|
value: "",
|
|
innerHTML: "",
|
|
style: {},
|
|
dataset: {},
|
|
classList: {
|
|
add: (...names) => names.forEach((n) => classes.add(n)),
|
|
remove: (...names) => names.forEach((n) => classes.delete(n)),
|
|
contains: (n) => classes.has(n),
|
|
toggle: () => false,
|
|
},
|
|
addEventListener: () => {},
|
|
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);
|
|
}
|
|
|
|
// One line of the decoded details: its label and what is drawn under it.
|
|
function decodedLine(label) {
|
|
return node("tx-detail-calldata-details")
|
|
.innerHTML.split('<div class="mb-2">')
|
|
.find((line) =>
|
|
line.startsWith(`<div class="text-muted">${label}</div>`),
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
globalThis.document = makeDocument();
|
|
globalThis.window = { location: { search: "" } };
|
|
state.blockscoutUrl = EXPLORER;
|
|
state.useBlockscout = true;
|
|
state.networkEndpoints = {};
|
|
state.wallets = [
|
|
{
|
|
name: "Main",
|
|
type: "key",
|
|
addresses: [{ address: HOLDER, balance: "0.0000" }],
|
|
},
|
|
];
|
|
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
|
|
state.networkTrackedTokens = {};
|
|
state.viewData = {};
|
|
state.viewStack = [];
|
|
state.currentView = null;
|
|
});
|
|
|
|
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
|
|
const [row] = await fetchRecentTransactions(
|
|
HOLDER,
|
|
EXPLORER,
|
|
NETWORKS.mainnet.chainId,
|
|
);
|
|
expect(row.to).toBe(RECIPIENT);
|
|
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
|
|
|
|
transactionDetail.show(row);
|
|
// The decoded details are drawn once the explorer has answered.
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(node("tx-detail-calldata-action").textContent).toBe(
|
|
"Token Transfer",
|
|
);
|
|
const token = decodedLine("Token");
|
|
expect(token).toContain('<div class="font-bold break-words">TDOL</div>');
|
|
expect(token).toContain(TOKEN);
|
|
expect(token).not.toContain(RECIPIENT);
|
|
expect(decodedLine("Recipient")).toContain(RECIPIENT);
|
|
expect(decodedLine("Amount")).toContain(
|
|
'<div class="font-bold break-words">1234.5000 TDOL</div>',
|
|
);
|
|
});
|
|
|
|
// Nothing limits the length of a symbol the explorer reports. 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 the amount, native quantity and decoded lines", async () => {
|
|
state.trackedTokens = [{ address: TOKEN, symbol: LONG, decimals: 6 }];
|
|
const [row] = await fetchRecentTransactions(
|
|
HOLDER,
|
|
EXPLORER,
|
|
NETWORKS.mainnet.chainId,
|
|
);
|
|
transactionDetail.show({
|
|
...row,
|
|
symbol: LONG,
|
|
rawUnit: LONG + " base units (10^-6)",
|
|
});
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(node("tx-detail-value").innerHTML).toContain(
|
|
`font-bold break-words" data-copy="1234.5 ${displaySymbol(LONG)}"`,
|
|
);
|
|
expect(node("tx-detail-native").innerHTML).toContain(
|
|
`break-words" data-copy="1234500000 ${LONG} base units (10^-6)"`,
|
|
);
|
|
expect(node("tx-detail-calldata-details").innerHTML).toContain(
|
|
`<div class="mb-2 break-words">Transfer ${LONG}</div>`,
|
|
);
|
|
expect(decodedLine("Token")).toContain(
|
|
`<div class="font-bold break-words">${displaySymbol(LONG)}</div>`,
|
|
);
|
|
expect(decodedLine("Amount")).toContain(
|
|
`<div class="font-bold break-words">1234.5000 ${LONG}</div>`,
|
|
);
|
|
});
|
|
|
|
test("wraps on the Action line", () => {
|
|
const html = fs.readFileSync(
|
|
path.join(__dirname, "..", "src", "popup", "index.html"),
|
|
"utf8",
|
|
);
|
|
expect(html).toMatch(
|
|
/id="tx-detail-calldata-action"\s+class="text-xs font-bold mb-2 break-words"/,
|
|
);
|
|
});
|
|
});
|
|
|
|
// Mainnet is active, and its Blockscout knows nothing of the transaction. The
|
|
// row was read from the Blockscout Settings remembers for Sepolia, and the
|
|
// screen reads the transaction from there too, so every link on it is drawn.
|
|
test("with mainnet active, a Sepolia transaction is read from Sepolia's Blockscout and its links open the Sepolia explorer", async () => {
|
|
state.networkId = "mainnet";
|
|
state.blockscoutUrl = OTHER_EXPLORER;
|
|
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
|
|
const [row] = await fetchRecentTransactions(
|
|
HOLDER,
|
|
EXPLORER,
|
|
NETWORKS.sepolia.chainId,
|
|
);
|
|
|
|
transactionDetail.show(row);
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
|
|
const sepolia = "https://sepolia.etherscan.io";
|
|
const links = (id) =>
|
|
[...node(id).innerHTML.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
|
|
expect(links("tx-detail-hash")).toEqual([`${sepolia}/tx/${TX_HASH}`]);
|
|
expect(links("tx-detail-from")).toEqual([`${sepolia}/address/${HOLDER}`]);
|
|
expect(links("tx-detail-to")).toEqual([`${sepolia}/address/${RECIPIENT}`]);
|
|
expect(links("tx-detail-token-contract")).toEqual([
|
|
`${sepolia}/token/${TOKEN.toLowerCase()}`,
|
|
]);
|
|
expect(links("tx-detail-block")).toEqual([`${sepolia}/block/100`]);
|
|
expect(links("tx-detail-calldata-details")).toEqual([
|
|
`${sepolia}/address/${TOKEN}`,
|
|
`${sepolia}/address/${RECIPIENT}`,
|
|
]);
|
|
});
|
|
|
|
test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia transaction is asked of Sepolia's default Blockscout", async () => {
|
|
state.networkId = "mainnet";
|
|
state.blockscoutUrl = OTHER_EXPLORER;
|
|
const [row] = await fetchRecentTransactions(
|
|
HOLDER,
|
|
EXPLORER,
|
|
NETWORKS.sepolia.chainId,
|
|
);
|
|
mockAsked.length = 0;
|
|
|
|
transactionDetail.show(row);
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
|
|
expect(mockAsked).toEqual([
|
|
NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH,
|
|
]);
|
|
});
|
|
|
|
// Open the Sepolia transfer's detail screen with mainnet active, and wait for
|
|
// its decoded details.
|
|
async function showSepoliaTransferWithMainnetActive() {
|
|
state.networkId = "mainnet";
|
|
state.blockscoutUrl = OTHER_EXPLORER;
|
|
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
|
|
const [row] = await fetchRecentTransactions(
|
|
HOLDER,
|
|
EXPLORER,
|
|
NETWORKS.sepolia.chainId,
|
|
);
|
|
transactionDetail.show(row);
|
|
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
|
|
}
|
|
|
|
test("with mainnet active, a Sepolia token transfer's decoded details name the token tracked on Sepolia and its amount at its scale", async () => {
|
|
state.trackedTokens = [];
|
|
state.networkTrackedTokens = {
|
|
sepolia: [{ address: TOKEN, symbol: "TDOL", decimals: 6 }],
|
|
};
|
|
await showSepoliaTransferWithMainnetActive();
|
|
|
|
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>',
|
|
);
|
|
});
|
|
|
|
// The same contract address on mainnet is another token, so what the wallet
|
|
// tracks and holds there names nothing on Sepolia.
|
|
test("with mainnet active, a Sepolia token transfer's decoded details take nothing from mainnet's tracked tokens or balances", async () => {
|
|
state.trackedTokens = [{ address: TOKEN, symbol: "MDOL", decimals: 18 }];
|
|
state.wallets[0].addresses[0].tokenBalances = [
|
|
{ address: TOKEN, symbol: "MDOL", decimals: 18, balance: "1.0" },
|
|
];
|
|
await showSepoliaTransferWithMainnetActive();
|
|
|
|
expect(decodedLine("Token")).not.toContain("MDOL");
|
|
expect(decodedLine("Amount")).toContain(
|
|
'<div class="font-bold break-words">1234500000 base units (decimals unknown)</div>',
|
|
);
|
|
});
|