Compare commits

..
4 Commits
Author SHA1 Message Date
sneak 93cc072a0b fix: say an unknown-scale balance the same way on Send and on confirm (closes #377)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
When two addresses' explorer reports disagree on a token's decimals, the
Send screen showed the stored figure while the confirmation screen said
the balance was unknown. One function in send.js now gives both the
balance and scale, so both read `unknown (SYMBOL)`.

The confirmation screen's fee-unknown message names its cause: for an
unknown scale it says the wallet does not know the token's decimal
places and the transaction cannot be sent, instead of asking for a
retry that cannot help. Other causes keep the old sentence.

Model: opus-5-5
2026-10-04 13:26:47 +00:00
clawbot a68f30c480 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 2s
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 15:09:09 +02:00
clawbot 43c236451f chore: run jest in three worker processes (closes #426)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
The test and test:verbose scripts ran jest with one worker per CPU core,
about 47 processes and 7-8 GiB per run on the shared 48-core build host.
They now pass --maxWorkers=3. On that host the suite takes 23-29s, inside
the unchanged 30-second cap in script/test but not by much: one test
file, tests/persistedFieldContract.test.js, takes most of it. One or two
workers went past the cap, so this departs from the issue's two-process
limit. make check, the pre-commit hook and script/cibuild all reach jest
through these scripts; the timings in the script/test and Dockerfile
comments are updated to match.

Model: opus-5-5
2026-10-04 14:26:03 +02:00
clawbot 1247c24c4d fix: keep the dApp approval error containers to their reserved height (closes #297)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 2s
#approve-tx-error and #approve-sign-error reserved 20px, but their border
and padding took 10px of it, so a one-line error grew them to 26px and
pushed the Confirm/Sign buttons down 6px. They now reserve 30px, the same
4px to spare over one line that the other password error containers have.

A new end-to-end test shows each of the six password error containers on
its own screen at the popup viewport, empty and then with an error, and
fails if one changes height or the element below it moves.

Model: opus-5-5
2026-10-04 12:58:26 +02:00
9 changed files with 428 additions and 32 deletions
+3 -3
View File
@@ -9,9 +9,9 @@ WORKDIR /app
ENV AUTISTMASK_LINT_NATIVE=1 ENV AUTISTMASK_LINT_NATIVE=1
# script/test's default 30s bound is the host figure, against a suite that # script/test's default 30s bound is the host figure, against a suite that
# runs in about 8s there. In here the same suite starts on a cold jest cache # takes 23-29s there with three jest workers. In here the same suite starts on
# and shares the runner with the rest of the build, so 30s is marginal rather # a cold jest cache and shares the runner with the rest of the build, so 30s
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s # is too tight — it killed a healthy suite at 30.6s on a cold CI cache. 180s
# still catches a hang in three minutes and cannot be tripped by a suite that # still catches a hang in three minutes and cannot be tripped by a suite that
# is merely running on contended hardware. # is merely running on contended hardware.
ENV AUTISTMASK_TEST_TIMEOUT=180 ENV AUTISTMASK_TEST_TIMEOUT=180
+22 -8
View File
@@ -969,16 +969,25 @@ read:
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256` - `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 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` 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 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 V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount. 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 - `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 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 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 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, `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 V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
readable exact-in action. `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 - `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 minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before `BALANCE_CHECK_ERC20` step's `minBalance`. Before
@@ -987,9 +996,14 @@ read:
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each 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 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 that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
amount wording, including `Unknown`; the SignApproval section below describes is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
it. `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 #### Partial USD totals
+36
View File
@@ -57,6 +57,42 @@ but the review is broader than any of them.
of asking the user to go back and try again, which cannot help. For any other of asking the user to go back and try again, which cannot help. For any other
cause it is unchanged. cause it is unchanged.
- 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: `make test` runs jest in three worker processes
([#426](https://git.eeqj.de/sneak/AutistMask/issues/426)). The `test` and
`test:verbose` scripts in `package.json` ran `jest --forceExit`, which starts
one worker per CPU core: about 47 processes and 7-8 GiB per run on the shared
48-core build host. They now pass `--maxWorkers=3`, and the suite takes 23-29s
there: inside the 30-second cap in `script/test`, which is unchanged, but not
by much, because `tests/persistedFieldContract.test.js` alone takes most of it
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). One or two workers
went past the cap. `make check`, the pre-commit hook and `script/cibuild` all
run the suite through these scripts.
- 2026-10-04: The error container on each dApp approval screen keeps its height
when an error appears
([#297](https://git.eeqj.de/sneak/AutistMask/issues/297)). `#approve-tx-error`
and `#approve-sign-error` reserved 20px, but their border and padding took
10px of it, so a one-line error grew them to 26px and pushed the buttons below
down 6px. They now reserve 30px. A new test in `tests/e2e/run.js` shows each
of the six password error containers on its own screen, empty and then with an
error, and fails if one changes height or the element below it moves. Some of
the longer messages these two containers can show still take two lines.
- 2026-10-04: A transaction with no `to` says "This transaction creates a new - 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 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 history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"test": "jest --forceExit", "test": "jest --forceExit --maxWorkers=3",
"test:verbose": "jest --forceExit --verbose", "test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
+10 -7
View File
@@ -1,13 +1,16 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite. # script/test: run the test suite.
# #
# The timeout bounds a hung suite; it is not a performance budget. On a # jest runs three worker processes (package.json), not one per CPU core: on a
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is # many-core shared host one per core took gigabytes of RAM per run.
# the bound. Inside the image the same suite also pays a cold jest cache and #
# shares the runner with the rest of the build, which is not what that budget # The timeout bounds a hung suite; it is not a performance budget. On the busy
# describes, so the Dockerfile raises the bound through # shared build host the suite takes 23-29s with three workers, so
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache # REPO_POLICIES' 30s cap is tight there, not comfortable. Inside the image the
# produces a red that means nothing, and teaches "just run it again". # same suite also pays a cold jest cache and shares the runner with the rest of
# the build, which is not what that budget describes, so the Dockerfile raises
# the bound through AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a
# cold cache produces a red that means nothing, and teaches "just run it again".
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+2 -2
View File
@@ -1631,7 +1631,7 @@
</div> </div>
<div <div
id="approve-tx-error" id="approve-tx-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]" class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
style="visibility: hidden" style="visibility: hidden"
></div> ></div>
<div class="flex justify-between"> <div class="flex justify-between">
@@ -1708,7 +1708,7 @@
</div> </div>
<div <div
id="approve-sign-error" id="approve-sign-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]" class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
style="visibility: hidden" style="visibility: hidden"
></div> ></div>
<div class="flex justify-between"> <div class="flex justify-between">
+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". // Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); 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 // `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 // 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 // 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. // Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser) // 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) { function decodeV2SwapExactOut(input) {
try { try {
const d = coder.decode( const d = coder.decode(
@@ -447,6 +449,7 @@ function decode(data, toAddress, sources) {
let outputToken = null; let outputToken = null;
let minOutput = null; let minOutput = null;
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
let hasV2ExactOut = false;
const commandNames = []; const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come // 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) { if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w) {
@@ -559,12 +572,19 @@ function decode(data, toAddress, sources) {
// Resolve token info. A null token on either side means the calldata // Resolve token info. A null token on either side means the calldata
// named no currency for it; tokenInfo() refuses rather than calling it // 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 // ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// decode, and it is answered here rather than left to that rule. // 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 inInfo = tokenInfo(inputToken, sources);
const outInfo = hasUnwrapWeth const outInfo =
? { symbol: "ETH", decimals: 18, address: null } hasUnwrapWeth && (outputIsWeth || outputUnset)
: tokenInfo(outputToken, sources); ? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol; const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol; const outSymbol = outInfo.symbol;
@@ -613,6 +633,17 @@ function decode(data, toAddress, sources) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount >= MAX_UINT160) { } else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" }; 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 { } else {
amount = amountText(inputAmount, inInfo); amount = amountText(inputAmount, inInfo);
} }
+75
View File
@@ -1449,6 +1449,81 @@ test("an over-long flash message keeps to one line (#252)", async (env) => {
} }
}); });
// --------------------------------------- password error containers (#297)
// Every screen that asks for a password reserves room for one line of error.
// The two on the dApp approval screens also have a border and padding, which
// that reserved height has to cover too.
const PASSWORD_ERROR_CONTAINERS = [
"approve-tx-error",
"approve-sign-error",
"export-privkey-flash",
"show-phrase-flash",
"delete-wallet-flash",
"confirm-tx-password-error",
];
// Shows only the screen holding the container, then measures the container
// and the element below it empty and again filled the way showError() in
// src/popup/views/helpers.js fills it. Runs in the page.
function measurePasswordError(id) {
const container = document.getElementById(id);
const screen = container.closest(".view");
for (const view of document.querySelectorAll(".view")) {
view.classList.toggle("hidden", view !== screen);
}
const below = container.nextElementSibling;
const measure = () => ({
height: container.getBoundingClientRect().height,
belowTop: below.getBoundingClientRect().top + window.scrollY,
belowHeight: below.getBoundingClientRect().height,
});
const empty = measure();
container.textContent = "Please enter your password.";
container.style.visibility = "visible";
const filled = measure();
container.textContent = "";
container.style.visibility = "hidden";
return { empty, filled };
}
test("a password error moves nothing on any screen (#297)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
for (const id of PASSWORD_ERROR_CONTAINERS) {
const { empty, filled } = await page.evaluate(
measurePasswordError,
id,
);
assert(
empty.belowHeight > 0,
"nothing is shown below #" + id + ", so nothing was measured",
);
assert(
filled.height === empty.height,
"#" +
id +
" is " +
empty.height +
"px empty and " +
filled.height +
"px with an error",
);
assert(
filled.belowTop === empty.belowTop,
"the element below #" +
id +
" moved " +
(filled.belowTop - empty.belowTop) +
"px when the error appeared",
);
}
} finally {
await page.close();
}
});
// --------------------------------------------- confirmation screen (#238) // --------------------------------------------- confirmation screen (#238)
// //
// The screen that decides what gets signed. The arithmetic underneath it // The screen that decides what gets signed. The arithmetic underneath it
+237
View File
@@ -5,6 +5,8 @@ const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27. // 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) // Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) { function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
// V3 path: token(20) + fee(3) + token(20) ... // V3 path: token(20) + fee(3) + token(20) ...
@@ -223,6 +233,233 @@ describe("uniswap decoder", () => {
expect(minOut.value).toContain("WETH"); 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", () => { test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute( const data = buildExecute(
"0x00", // V3_SWAP_EXACT_IN "0x00", // V3_SWAP_EXACT_IN