Compare commits

..
Author SHA1 Message Date
sneak fe5b9609ea fix: the transaction detail screen decodes a token transfer against the token contract (closes #515)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The screen decoded a transaction's data against the history entry's `to`,
which for a token transfer row is the transfer's recipient. The decoded
Token line named the recipient, and the amount read "decimals unknown" for
a token the wallet tracks. It now decodes against the address the
explorer's answer for that transaction says it was sent to, which is the
right address for every kind of row. tests/tokenTransferDetail.test.js
opens a row made by the real history parser and checks the Token,
Recipient and Amount lines.

Model: opus-5-5
2026-10-14 04:46:27 +00:00
clawbot adff1e0b9d docs: a change adds no Completed Steps entry to TODO.md (closes #222)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Every change used to add an entry at the top of Completed Steps in its
own commit, so each merge into `next` left every other open PR
conflicting on that list. The Workflow section now says to add no
entry: what landed is recorded on the tracker and in `git log`, where
each commit title names the issue it closes. The step that moved Next
Step into Completed Steps and pulled the top of Future Steps into Next
Step goes with it. The entries already in Completed Steps stay.

Model: opus-5-5
2026-10-14 05:15:05 +02:00
4 changed files with 188 additions and 10 deletions
+3 -4
View File
@@ -2,10 +2,9 @@
- `git pull` `next` and cut a branch from it — one branch per issue, named
`issue-<N>-<slug>`. Never branch from `main`.
- Do the work as one commit whose title ends with ` (closes #N)`, with the
`TODO.md` update in that same commit.
- Move Next Step to the top of Completed Steps; move the top item of Future
Steps into Next Step.
- Do the work as one commit whose title ends with ` (closes #N)`.
- Add no entry to Completed Steps. What landed is recorded on the tracker and in
`git log`, where each commit title names the issue it closes.
- Run `make fmt`, then `make check`. A feature branch may be red; `next` and
`main` may not.
- Rebase onto current `next` immediately before pushing — other branches land on
+3 -3
View File
@@ -632,8 +632,8 @@ function isExtensionSender(sender) {
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect, and the windows.onRemoved listener below settles them when their
// window closes.
// disconnect — the user can reopen the toolbar popup — and are rejected by the
// windows.onRemoved listener below.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
@@ -661,7 +661,7 @@ runtime.onConnect.addListener((port) => {
const approval = pendingApprovals[id];
if (approval) {
if (approval.type === "tx" || approval.type === "sign") {
// windows.onRemoved settles it when its window closes.
// Keep pending — user can reopen the toolbar popup
return;
}
settleApproval(id, { approved: false, remember: false });
+6 -3
View File
@@ -183,7 +183,7 @@ function render() {
if (el) el.classList.add("hidden");
}
loadFullTxDetails(tx.hash, tx.to, tx.chainId);
loadFullTxDetails(tx.hash, tx.chainId);
const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML =
@@ -291,7 +291,7 @@ function populateOnChainDetails(txData, chainId) {
}
}
async function loadFullTxDetails(txHash, toAddress, chainId) {
async function loadFullTxDetails(txHash, chainId) {
const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details");
@@ -313,7 +313,10 @@ async function loadFullTxDetails(txHash, toAddress, chainId) {
const inputData = txData.raw_input || txData.input || null;
if (!inputData || inputData === "0x") return;
const decoded = decodeCalldata(inputData, toAddress || "");
// Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract.
const decoded = decodeCalldata(inputData, txData.to?.hash || "");
if (decoded) {
// Render decoded calldata matching approval view style
actionEl.textContent = decoded.name;
+176
View File
@@ -0,0 +1,176 @@
// 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.
//
// 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) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://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]);
// 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 };
}
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.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: HOLDER, balance: "0.0000" }],
},
];
state.trackedTokens = [{ address: TOKEN, symbol: "TDOL", decimals: 6 }];
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, 1);
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>',
);
});