decodeCalldata() looked a token's symbol and decimals up in the active network's tracked tokens and balances, whatever chain id it was given, so TransactionDetail and TxApproval showed a token tracked on another network by address only, with its amount in base units. The lookup now uses what the wallet knows of the tokens on the transaction's network: the active network's tracked tokens and balances, or for another network the tokens tracked on it and no balances. TxApproval's token labels, the symbol it hands the wait, success and error screens, and SuccessTx's decoded token label use the same lookup. Model: opus-5-5
294 lines
11 KiB
JavaScript
294 lines
11 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 { 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,
|
|
}));
|
|
|
|
globalThis.chrome = {
|
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
};
|
|
|
|
const { state } = require("../src/shared/state");
|
|
const { NETWORKS } = require("../src/shared/networks");
|
|
const { fetchRecentTransactions } = require("../src/shared/transactions");
|
|
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.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">TDOL</div>');
|
|
expect(token).toContain(TOKEN);
|
|
expect(token).not.toContain(RECIPIENT);
|
|
expect(decodedLine("Recipient")).toContain(RECIPIENT);
|
|
expect(decodedLine("Amount")).toContain(
|
|
'<div class="font-bold">1234.5000 TDOL</div>',
|
|
);
|
|
});
|
|
|
|
// 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">TDOL</div>');
|
|
expect(decodedLine("Amount")).toContain(
|
|
'<div class="font-bold">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">1234500000 base units (decimals unknown)</div>',
|
|
);
|
|
});
|