refactor: move the calldata decoder to src/shared/calldataDecoder.js (closes #759)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

decodeCalldata() and tokenAmountText() move out of the TxApproval view into
src/shared/calldataDecoder.js. TxApproval, TransactionDetail and the decoder
tests import them from there.

tokenSourcesByChainId() moves from the popup view helpers to
src/shared/state.js, beside currentNetwork(), so the decoder requires nothing
under src/popup/. It reads only the state singleton and the network table.

Model: opus-5-5
This commit was merged in pull request #765.
This commit is contained in:
clawbot committed 2026-11-10 22:33:26 +01:00
1 parent c150b83322
commit 6d158bf1b1
14 files changed
+219 -185

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
// the one exception to it, in one place. Three call sites had grown their own
// copy of the truncation — the history and balance lists
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
// (`src/popup/views/approval.js`) and its Uniswap swap detail lines
// (`src/shared/calldataDecoder.js`) and its Uniswap swap detail lines
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
// two showing a different number for the same value.
//
+155
View File
@@ -0,0 +1,155 @@
// Decode a transaction's calldata into the summary that TxApproval and
// TransactionDetail show: ERC-20 `approve` and `transfer` calls here, and
// Uniswap Universal Router calls through uniswap.js. The summary is data; the
// views turn its addresses into HTML.
const { formatUnits, Interface } = require("ethers");
const { ERC20_ABI } = require("./constants");
const { tokenSourcesByChainId } = require("./state");
const {
resolveTokenDecimals,
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("./approvalAmount");
// Four decimals, with the nonzero floor the approval screens hold.
const { truncateAmountNeverZero } = require("./amountDisplay");
const uniswap = require("./uniswap");
const erc20Iface = new Interface(ERC20_ABI);
// The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what
// showed a 5,000-token transfer as `0.0000`. `raw` is what the status screens
// carry, `display` is what the approval screen shows.
function tokenAmountText(rawAmount, decimals, symbol) {
if (decimals === null) {
const unknown = unknownDecimalsAmount(rawAmount);
return { raw: unknown, display: unknown };
}
const formatted = truncateAmountNeverZero(formatUnits(rawAmount, decimals));
return {
raw: formatted,
display: formatted + (symbol ? " " + symbol : ""),
};
}
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
// with chain id `chainId`, using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress, chainId, fromAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's name and scale are looked for, for every decoder below:
// what the wallet knows of the tokens on the network with `chainId`, which
// need not be the active one. The ERC-20 amount line and the swap's Amount
// and Min. received lines resolve the scale the same way, and refuse to
// format the same way when it is nowhere.
const decimalsSources = tokenSourcesByChainId(chainId);
// Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
if (parsed) {
const tokenSymbol = resolveTokenSymbol(toAddress, decimalsSources);
// null when no source knows this token's scale. It is not
// defaulted to 18: an amount formatted with a guessed scale is
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(
toAddress,
decimalsSources,
);
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
if (parsed.name === "approve") {
const spender = parsed.args[0];
const rawAmount = parsed.args[1];
const maxUint = BigInt(
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
// An unbounded allowance needs no scale to describe, so it is
// still named rather than refused.
const amount = isUnlimited
? { raw: "Unlimited", display: "Unlimited" }
: tokenAmountText(rawAmount, tokenDecimals, tokenSymbol);
return {
name: "Token Approval",
description: tokenSymbol
? "Approve spending of your " + tokenSymbol
: "Approve spending of an ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{
label: "Spender",
value: spender,
address: spender,
},
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
},
],
};
}
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amount = tokenAmountText(
rawAmount,
tokenDecimals,
tokenSymbol,
);
return {
name: "Token Transfer",
description: tokenSymbol
? "Transfer " + tokenSymbol
: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amount.display,
rawValue: amount.raw,
},
],
};
}
}
} catch {
// Not ERC-20 — fall through
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(
data,
toAddress,
chainId,
decimalsSources,
fromAddress,
);
if (routerResult) return routerResult;
return null;
}
module.exports = { decodeCalldata, tokenAmountText };
+28 -1
View File
@@ -22,7 +22,7 @@
// same thing in the editor is fast feedback in front of the build, not the
// guarantee.
const { networkById } = require("./networks");
const { networkById, networkByChainId } = require("./networks");
const { copyChainSwitchFields } = require("./chainSwitchFields");
const {
DEFAULT_STATE,
@@ -125,6 +125,32 @@ function currentNetwork() {
return networkById(state.networkId);
}
// What the wallet knows of the tokens on the network with this chain id, or
// on the active network when no chain id is given, as the `sources` that
// resolveTokenSymbol() and resolveTokenDecimals() take. A contract address
// names a token on one network only, and every token balance held is the
// active network's, so another network gets the tokens tracked on it
// (state.networkTrackedTokens, see applyChainSwitchFields()) and no balances.
//
// The wallets' balances hold what Blockscout reported for each token. So the
// wallets are returned only while "Use Blockscout" is on.
function tokenSourcesByChainId(chainId) {
const chainIdOrActive = chainId || currentNetwork().chainId;
const net = networkByChainId(chainIdOrActive);
if (net && net.id === state.networkId) {
return {
chainId: chainIdOrActive,
trackedTokens: state.trackedTokens,
wallets: state.useBlockscout ? state.wallets : [],
};
}
return {
chainId: chainIdOrActive,
trackedTokens: (net && state.networkTrackedTokens[net.id]) || [],
wallets: [],
};
}
// `baseline` holds the persisted fields as this page held them when its last
// loadState() finished, or when its last successful saveState() began.
// takeStoredNetwork(), takeStoredTrackedTokens() and
@@ -820,5 +846,6 @@ module.exports = {
loadState,
currentAddress,
currentNetwork,
tokenSourcesByChainId,
StateNotLoadedError,
};