Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93cc072a0b | ||
|
|
a68f30c480 | ||
|
|
43c236451f | ||
|
|
1247c24c4d |
+3
-3
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)"
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user