Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 39729465d0 fix: decode Uniswap V2 exact-out swaps, input amount shown as a maximum (closes #283)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 1s
decode() had no arm for Universal Router command 0x09, so the approval
screen showed no token or amount for it. It now takes the path's first
token and amountInMax as the input side, the last token and amountOut as
the output side. With such a step, the Amount figure reads "Up to
<amount>" whichever step set it, on the approval, wait, success and error
screens, unless it reads "Unlimited", as an unbounded PERMIT2_PERMIT does,
or "All available (V4 open delta)". In every swap, UNWRAP_WETH makes
Token Out ETH only when the output side is WETH, on mainnet or Sepolia, or
no step set it; otherwise the output keeps its own token and figure.
decodeV2SwapExactOut() loses its eslint-disable comment.

Model: opus-5-5
2026-10-04 10:27:43 +00:00
clawbot bec20aa2bb fix: say a contract creation has no recipient instead of a blank line (closes #250)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
A transaction with no `to` showed a blank address, with a colour dot whose
colour was `undefined`, on the wait, success and error screens, the
transaction detail view and the history rows on Home, AddressDetail and
AddressToken. The approval screen showed "(contract creation)".

All of them now say "This transaction creates a new contract. It has no
recipient." The three history lists draw a row's counterparty lines through
one helper in helpers.js. A transaction with a real `to` is unchanged. The
new test drives each screen and list both ways.

Model: opus-5-5
2026-10-04 12:24:39 +02:00
12 changed files with 700 additions and 44 deletions
+36 -15
View File
@@ -962,16 +962,25 @@ read:
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in
or `WRAP_ETH` amount that large, which is not an allowance. The router's
whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount.
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in,
V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such
amount.
- `Up to <amount>`: the swap's `Amount` line, when the transaction has a V2
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
not necessarily all of it; the wait, success and error screens show it with
the same words. `Unlimited` and `All available (V4 open delta)` keep their
wording. When a V2 exact-out step sets `Min. received`, that line shows its
`amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
`WRAP_ETH` or V4 swap step, a V4 swap step giving the `amountIn` of its first
readable exact-in action.
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
@@ -980,9 +989,14 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for
that side. The token permission warning on the signature screen has its own
amount wording, including `Unknown`; the SignApproval section below describes
it.
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it.
#### Partial USD totals
@@ -1258,7 +1272,10 @@ view would leave a wallet one click from deletion.
of every wallet, deduplicated by hash and filtered. Each row is three
lines: age and direction, then the counterparty's colour dot (with our own
name for it, where it is one of our addresses) and the amount, then the
counterparty's full address on a row of its own
counterparty's full address on a row of its own. A contract creation has
no counterparty: its second line is the amount alone and its third line
says "This transaction creates a new contract. It has no recipient." The
transaction lists on AddressDetail and AddressToken draw the same rows
- "Add additional wallet..." link at bottom
- **Transitions**:
- Tap address row → sets the active address and broadcasts
@@ -1451,7 +1468,9 @@ view would leave a wallet one click from deletion.
- **Elements**:
- "Transaction Broadcast" heading (no back button — tx is irreversible)
- Amount + symbol
- To: color dot + full address + etherscan link
- To: color dot + full address + etherscan link; for a contract creation,
which has no recipient, "This transaction creates a new contract. It has
no recipient." instead
- Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
@@ -1480,7 +1499,8 @@ view would leave a wallet one click from deletion.
- Decoded action well (shown when the transaction carried recognized
calldata; the top-level Amount and To are hidden in that case)
- Amount + symbol
- To: color dot + full address + etherscan link
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- Block number
- Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button
@@ -1495,7 +1515,8 @@ view would leave a wallet one click from deletion.
- **Elements**:
- "Transaction Failed" heading
- Amount + symbol
- To: color dot + full address + etherscan link
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- Error message (dashed border box)
- Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link
@@ -1533,7 +1554,7 @@ view would leave a wallet one click from deletion.
- From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
name if available
name if available. For a contract creation, the same sentence as on WaitTx
- Time: ISO datetime + relative age in parentheses
- Block: block number (tap to copy) + etherscan block link
- Amount: value + symbol (bold)
@@ -1844,8 +1865,8 @@ view would leave a wallet one click from deletion.
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link (or "contract
creation"), token symbol label if known
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
- Value: amount in ETH (4 decimal places, USD in parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
in parentheses), with the gas limit and the fee per gas in gwei below it
+23
View File
@@ -45,6 +45,29 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is
decoded on the approval screen
([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in
`src/shared/uniswap.js` had no arm for it, so the screen named the step and
showed no token or amount. The input side is the path's first token with
`amountInMax`, the output side the last token with `amountOut`. When the
transaction has such a step, the `Amount` figure reads `Up to <amount>`,
whichever step set it, there and on the wait, success and error screens,
except where it reads `Unlimited` (an unbounded `PERMIT2_PERMIT`, or any
amount at or above the `uint160` maximum) or `All available (V4 open delta)`.
In every swap, `UNWRAP_WETH` makes `Token Out` ETH only when the output side
is WETH, on mainnet or Sepolia, or when no step set the output side; otherwise
`Token Out` and `Min. received` keep the output side's own token and figure.
V3 exact-out (`0x01`) is still not decoded.
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
contract. It has no recipient." on its recipient line and in its transaction
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
wait, success and error screens, the transaction detail view and the history
rows on Home, AddressDetail and AddressToken showed a blank address there,
with a colour dot whose colour was `undefined`; the approval screen showed
"(contract creation)". A transaction with a real `to` is unchanged.
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a
token balance or a network fee below 0.000001 as zero
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
+2 -5
View File
@@ -3,7 +3,7 @@ const {
showView,
showFlash,
balanceLinesForAddress,
addressDotHtml,
txCounterpartyHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -233,16 +233,13 @@ function renderTransactions(txs) {
// is shown whole; the title or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`;
i++;
}
+2 -5
View File
@@ -6,7 +6,7 @@ const {
showView,
showFlash,
flashCopyFeedback,
addressDotHtml,
txCounterpartyHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -309,16 +309,13 @@ function renderTransactions(txs) {
// is shown whole; the title or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `</div>`;
i++;
}
+2 -1
View File
@@ -1,6 +1,7 @@
const {
$,
addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml,
showView,
showError,
@@ -318,7 +319,7 @@ function showTxApproval(details) {
toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml;
} else {
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
const ethValueFormatted = formatTxValue(
+25
View File
@@ -431,6 +431,29 @@ function addressTitle(address, wallets) {
return null;
}
// What every recipient line and history row says for a transaction with no
// `to`. Such a transaction creates a contract, so there is no address to show,
// and a blank line on these screens reads as a rendering fault.
const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient.";
// The last two lines of a transaction history row: the counterparty's colour
// dot and name beside the amount, then its full address. A contract creation
// the user sent has no counterparty (its `to` is ""), so its row has the
// amount alone and the contract creation sentence in place of the address.
function txCounterpartyHtml(address, nameHtml, amountHtml) {
if (!address) {
return (
`<div class="flex justify-between"><span></span><span>${amountHtml}</span></div>` +
`<div>${escapeHtml(CONTRACT_CREATION_TEXT)}</div>`
);
}
return (
`<div class="flex justify-between"><span class="flex items-center">${addressDotHtml(address)}${nameHtml}</span><span>${amountHtml}</span></div>` +
`<div class="am-address">${escapeHtml(address)}</div>`
);
}
// Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above
// the full address.
@@ -643,6 +666,8 @@ module.exports = {
escapeHtml,
displaySymbol,
addressTitle,
CONTRACT_CREATION_TEXT,
txCounterpartyHtml,
formatAddressHtml,
renderAddressHtml,
copyableHtml,
+2 -4
View File
@@ -6,6 +6,7 @@ const {
isoDate,
timeAgo,
addressDotHtml,
txCounterpartyHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -122,16 +123,13 @@ function renderHomeTxList(ctx) {
// names it on the line above rather than replacing it.
const title = addressTitle(counterparty, state.wallets);
const titleStr = title ? escapeHtml(title) : "";
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `</div>`;
i++;
}
+8 -2
View File
@@ -7,6 +7,7 @@ const {
showFlash,
flashCopyFeedback,
addressTitle,
CONTRACT_CREATION_TEXT,
addressDotHtml,
escapeHtml,
isoDate,
@@ -94,13 +95,18 @@ function render() {
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
const fromTitle = addressTitle(tx.from, state.wallets);
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
// A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
} else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
// Exact amount (full precision, copyable)
const detailSym = displaySymbol(tx.symbol);
+7 -2
View File
@@ -4,6 +4,7 @@ const {
$,
showView,
addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
@@ -58,7 +59,10 @@ function endWait() {
}
}
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`).
function toAddressHtml(address) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title });
}
@@ -202,8 +206,9 @@ function restoreWait() {
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
// A string is the whole requirement: the empty string is what a
// contract-deployment approval persists (approval.js writes `to: toAddr
// || ""`), and both fields render harmlessly when empty, so refusing it
// would abandon a wait the live path itself created.
// || ""`), an empty `to` renders as a contract creation and an empty
// amount renders harmlessly, so refusing it would abandon a wait the live
// path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
+41 -10
View File
@@ -100,6 +100,13 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
// decode() is not told the network, so it takes either.
const WETH_ADDRESSES = [
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
];
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
@@ -198,11 +205,6 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(
@@ -447,6 +449,7 @@ function decode(data, toAddress, sources) {
let outputToken = null;
let minOutput = null;
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
@@ -521,6 +524,16 @@ function decode(data, toAddress, sources) {
}
}
if (cmdId === 0x09) {
// Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut);
}
}
if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w) {
@@ -559,12 +572,19 @@ function decode(data, toAddress, sources) {
// Resolve token info. A null token on either side means the calldata
// named no currency for it; tokenInfo() refuses rather than calling it
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
// decode, and it is answered here rather than left to that rule.
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// when the output side is WETH, or when no step set the output side.
// Any other output keeps its own token and figure: a swap that buys
// USDC and then unwraps the WETH it did not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset = !present(outputToken) && !present(minOutput);
const inInfo = tokenInfo(inputToken, sources);
const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -613,6 +633,17 @@ function decode(data, toAddress, sources) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) {
// A V2 exact-out swap spends at most this figure, whichever
// step set the line (its amountInMax, the WRAP_ETH of a swap
// paid in ETH, a permit), so it is said to be a maximum, in
// `raw` too: the wait, success and error screens show `raw` as
// the transaction's amount.
const most = amountText(inputAmount, inInfo);
amount = {
raw: "Up to " + most.raw,
display: "Up to " + most.display,
};
} else {
amount = amountText(inputAmount, inInfo);
}
+315
View File
@@ -0,0 +1,315 @@
// The recipient line of a contract creation
// (https://git.eeqj.de/sneak/AutistMask/issues/250).
//
// A transaction with no `to` creates a contract. The approval screen, the
// wait, success and error screens, the transaction detail view and the
// transaction history rows each say so in a sentence, where they used to show
// a blank line (an empty address, with a colour dot whose colour was
// `undefined`) or, on the approval screen, "(contract creation)". A
// transaction with a real `to` still shows that address.
//
// Driven against a minimal DOM stub in the shape
// tests/typedDataPermit.test.js uses.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The transaction detail view fetches on-chain details after drawing; an
// answer that is not ok leaves the drawn lines as they are.
debugFetch: async () => ({ ok: false }),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The wait screen polls for a receipt; this one never arrives.
jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: () => new Promise(() => {}) }),
refreshBalances: () => {},
}));
// The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory and no name
// resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory,
}));
jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
const transactionDetail = require("../src/popup/views/transactionDetail");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const SENTENCE =
"This transaction creates a new contract. It has no recipient.";
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
// Init code for a contract creation's data.
const INIT_CODE = "0x600160005500";
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
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: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
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);
}
// The line a transaction with a real `to` shows: that address, and nothing
// left over from an empty one.
function expectAddressLine(html) {
expect(html).toContain(RECIPIENT);
expect(html).not.toContain(SENTENCE);
expect(html).not.toContain("undefined");
}
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.wallets = [];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
});
afterEach(() => {
txStatus.endWait();
});
// Open the transaction approval screen the way the popup does: the background
// hands over the populated transaction and show() draws it.
async function openTxApproval(to, data) {
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "tx",
hostname: "dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to,
value: "0x0",
data,
accessList: [],
},
});
},
};
approval.init({});
await approval.show(1);
}
describe("the transaction approval screen", () => {
test("a contract creation says so instead of naming a contract", async () => {
await openTxApproval(null, INIT_CODE);
expect(node("approve-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address", async () => {
await openTxApproval(RECIPIENT, "0x");
expectAddressLine(node("approve-tx-to").innerHTML);
});
});
// approval.js carries a contract creation to these screens with `to` as "".
describe("the wait, success and error screens", () => {
const creation = {
to: "",
amount: "0.0000",
token: "ETH",
tokenSymbol: null,
};
const transfer = { ...creation, to: RECIPIENT };
test("a contract creation says so on the wait screen", () => {
txStatus.showWait(creation, TX_HASH);
expect(node("wait-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address on the wait screen", () => {
txStatus.showWait(transfer, TX_HASH);
expectAddressLine(node("wait-tx-to").innerHTML);
});
test("a contract creation says so on the success and error screens", () => {
state.viewData = {
amount: "0.0000",
symbol: "ETH",
to: "",
hash: TX_HASH,
blockNumber: 1,
};
txStatus.renderSuccess();
expect(node("success-tx-to").innerHTML).toBe(SENTENCE);
txStatus.showError(creation, TX_HASH, "The transaction failed.");
expect(node("error-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address on the success and error screens", () => {
state.viewData = {
amount: "0.0050",
symbol: "ETH",
to: RECIPIENT,
hash: TX_HASH,
blockNumber: 1,
};
txStatus.renderSuccess();
expectAddressLine(node("success-tx-to").innerHTML);
txStatus.showError(transfer, TX_HASH, "The transaction failed.");
expectAddressLine(node("error-tx-to").innerHTML);
});
});
// A transaction FROM sent, as the history lists hold it. The explorer reports a
// contract creation with no `to`, which src/shared/transactions.js turns into
// `to: ""`.
function historyTx(to) {
return {
hash: TX_HASH,
from: FROM,
to,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
}
// The detail view is opened with the transaction a history row holds.
describe("the transaction detail view", () => {
test("a contract creation says so", () => {
transactionDetail.show(historyTx(""));
expect(node("tx-detail-to").innerHTML).toBe(SENTENCE);
expect(node("tx-detail-type").textContent).toBe("Contract Creation");
});
test("a transaction with a recipient shows its address", () => {
transactionDetail.show(historyTx(RECIPIENT));
expectAddressLine(node("tx-detail-to").innerHTML);
});
});
// The same rows are drawn on Home, AddressDetail and AddressToken (for ETH).
describe.each([
["Home", "home-tx-list", () => home.render({})],
["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("the transaction history on %s", (_name, listId, open) => {
async function rowsFor(tx) {
mockHistory = [tx];
open();
// The list is drawn once the history has been fetched.
await new Promise((resolve) => setTimeout(resolve, 0));
return node(listId).innerHTML;
}
beforeEach(() => {
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
});
test("a contract creation's row says so, with no colour dot and no address line", async () => {
const html = await rowsFor(historyTx(""));
expect(html).toContain(SENTENCE);
expect(html).not.toContain("background:");
expect(html).not.toContain("am-address");
expect(html).not.toContain("undefined");
});
test("a transaction with a recipient shows its colour dot and address", async () => {
const html = await rowsFor(historyTx(RECIPIENT));
expectAddressLine(html);
expect(html).toContain("background:#");
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
});
});
+237
View File
@@ -5,6 +5,8 @@ const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27.
@@ -75,6 +77,14 @@ function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) {
);
}
// Helper: encode a V2_SWAP_EXACT_OUT input (command 0x09)
function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) {
return coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[recipient, amountOut, amountInMax, pathAddrs, true],
);
}
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
// V3 path: token(20) + fee(3) + token(20) ...
@@ -223,6 +233,233 @@ describe("uniswap decoder", () => {
expect(minOut.value).toContain("WETH");
});
// Buy exactly 0.5 WETH for at most 1,500 USDC, paid by the user, sent to
// the caller (the router's MSG_SENDER recipient, address(1)).
test("decodes V2_SWAP_EXACT_OUT, stating the input amount as a maximum", () => {
const data = buildExecute(
"0x09", // V2_SWAP_EXACT_OUT
[
encodeV2SwapExactOut(
"0x0000000000000000000000000000000000000001",
500000000000000000n, // amountOut: 0.5 WETH
1500000000n, // amountInMax: 1,500 USDC (6 decimals)
[USDC_ADDR, WETH_ADDR],
),
],
1767225600n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDC → WETH");
expect(detail(result, "Token In").address).toBe(USDC_ADDR);
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
// The wait, success and error screens show rawValue as the amount, so
// it says "Up to" as well.
const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 1500.0000 USDC");
expect(amount.rawValue).toBe("Up to 1500.0000");
expect(detail(result, "Min. received").value).toBe("0.5000 WETH");
});
// Buy exactly 1,500 USDC for at most 0.5 ETH: WRAP_ETH of the maximum, the
// swap, then UNWRAP_WETH of 0, which returns the ETH the swap did not spend.
test("a V2 exact-out swap paid in ETH shows the token it buys and a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0b, 0x09, 0x0c]),
[
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
encodeV2SwapExactOut(
USER_ADDR,
1500000000n, // amountOut: 1,500 USDC
500000000000000000n, // amountInMax: 0.5 WETH
[WETH_ADDR, USDC_ADDR],
),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap ETH → USDC");
const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 0.5000 ETH");
expect(amount.rawValue).toBe("Up to 0.5000");
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
});
// Buy exactly 0.5 ETH for at most 1,500 USDC under a permit: the swap buys
// WETH and UNWRAP_WETH turns it into ETH.
test("a V2 exact-out swap that buys ETH shows ETH and the permit as a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
[
encodePermit2(USDC_ADDR, 1500000000n, ROUTER_ADDR),
encodeV2SwapExactOut(
ROUTER_ADDR,
500000000000000000n, // amountOut: 0.5 WETH
1500000000n, // amountInMax: 1,500 USDC
[USDC_ADDR, WETH_ADDR],
),
encodeWrapEth(USER_ADDR, 500000000000000000n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDC → ETH");
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
});
// Paid in a token, an UNWRAP_WETH of 0 after a swap that buys something
// other than WETH leaves the output side as it is: Token Out and Min.
// received are the token bought and its figure, not ETH.
test.each([
{
paidIn: "WETH",
tokenIn: WETH_ADDR,
amountInMax: 500000000000000000n, // 0.5 WETH
tokenOut: USDC_ADDR,
amountOut: 1300000000n, // 1,300 USDC
minReceived: "1300.0000 USDC",
},
{
paidIn: "USDC",
tokenIn: USDC_ADDR,
amountInMax: 8000000n, // 8 USDC
tokenOut: DAI_ADDR,
amountOut: 7000000000000000000n, // 7 DAI
minReceived: "7.0000 DAI",
},
])(
"a V2 exact-out swap paid in $paidIn, then UNWRAP_WETH, shows the token it buys",
({ tokenIn, amountInMax, tokenOut, amountOut, minReceived }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
[
encodePermit2(tokenIn, amountInMax, ROUTER_ADDR),
encodeV2SwapExactOut(USER_ADDR, amountOut, amountInMax, [
tokenIn,
tokenOut,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").address).toBe(tokenOut);
expect(detail(result, "Min. received").value).toBe(minReceived);
},
);
// An exact-in swap is held to the same rule: buying USDC, then UNWRAP_WETH,
// receives USDC.
test("an exact-in swap to a token other than WETH, then UNWRAP_WETH, shows that token", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
[
encodeV2SwapExactIn(USER_ADDR, 2000000n, 1900000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
});
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH.
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(
["uint8", "uint8", "uint8", "uint8"],
[0x0b, 0x09, 0x08, 0x0c],
),
[
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
encodeV2SwapExactOut(
ROUTER_ADDR,
1500000000n, // amountOut: 1,500 USDC
500000000000000000n, // amountInMax: 0.5 WETH
[WETH_ADDR, USDC_ADDR],
),
encodeV2SwapExactIn(
ROUTER_ADDR,
1500000000n, // amountIn: 1,500 USDC
400000000000000000n, // amountOutMin: 0.4 WETH
[USDC_ADDR, WETH_ADDR],
),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
});
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
test("a swap to Sepolia WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDC_ADDR,
SEPOLIA_WETH_ADDR,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
});
// A step that states a Min. received figure but names no output token has
// set the output side, so UNWRAP_WETH does not make it ETH: nothing says
// the figure is counted in WETH.
test("a step with a minimum but no output token, then UNWRAP_WETH, names no token", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
encodeV4ExactIn(
USDC_ADDR,
[], // no path: this step names no output currency
1000000000n, // 1,000 USDC
400000000000000000n, // amountOutMin
),
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received").value).toBe(
"400000000000000000 base units (decimals unknown)",
);
});
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute(
"0x00", // V3_SWAP_EXACT_IN