Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39729465d0 |
@@ -949,13 +949,6 @@ and compare against. Reading the stored field directly instead answers `null`
|
||||
for a bundled or tracked token the explorer merely omitted, which is not a
|
||||
refusal the wallet has any reason to make.
|
||||
|
||||
The Send screen also consults every address's explorer reports, so a contract
|
||||
two addresses report different `decimals` for has no scale there, and the stored
|
||||
balance, formatted at one of those scales, is withdrawn with it. The Send
|
||||
screen's `Current balance` and the confirmation screen's balance line then both
|
||||
read `unknown (SYMBOL)`. The balance list formats each explorer row as it is
|
||||
fetched, without that cross-address check, and shows the row's figure.
|
||||
|
||||
**Decoded amount lines on the transaction approval screen:** the `Amount` line
|
||||
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
|
||||
decoded swap (see TxApproval below), do not always read as a number. They can
|
||||
@@ -969,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
|
||||
@@ -987,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
|
||||
|
||||
@@ -1402,9 +1409,7 @@ view would leave a wallet one click from deletion.
|
||||
- What to send: token dropdown (or static display with contract address when
|
||||
locked from AddressToken)
|
||||
- To: address or ENS name input, with an inline validation message
|
||||
- Amount input with current balance display, which reads
|
||||
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
|
||||
ConfirmTx's balance line does (see Unknown token scale)
|
||||
- Amount input with current balance display
|
||||
- "Review" button, disabled until the recipient validates
|
||||
- **Transitions**:
|
||||
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
|
||||
@@ -1422,8 +1427,7 @@ view would leave a wallet one click from deletion.
|
||||
- From: blockie + color dot + full address + etherscan link + wallet title
|
||||
- To: blockie + color dot + full address + etherscan link + ENS name
|
||||
- Amount: value + symbol (USD in parentheses)
|
||||
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
|
||||
for a token whose scale is unknown
|
||||
- Your balance: value + symbol (USD in parentheses)
|
||||
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
|
||||
fetched async. The first line is what the transfer is expected to cost,
|
||||
`gasLimit * gasPrice` (USD in parentheses); the second is the
|
||||
@@ -1439,11 +1443,7 @@ view would leave a wallet one click from deletion.
|
||||
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
|
||||
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
|
||||
estimated. The first two are mutually exclusive per transfer type, so only
|
||||
the applicable one holds space. The last names its cause: for a token
|
||||
whose scale is unknown the fee can never be estimated, and it says the
|
||||
wallet does not know how many decimal places the token uses and that the
|
||||
transaction cannot be sent; for any other failure it asks the user to go
|
||||
back and try again
|
||||
the applicable one holds space
|
||||
- Password: an inline field on this screen, not a modal, with its own error
|
||||
line
|
||||
- "Sign & Send" button (disabled if errors, and while the network fee
|
||||
|
||||
@@ -45,17 +45,20 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-04: A token whose scale is unknown reads the same on the Send screen
|
||||
as on the confirmation screen
|
||||
([#377](https://git.eeqj.de/sneak/AutistMask/issues/377)). When two addresses'
|
||||
explorer reports disagree on a token's `decimals`, the Send screen showed the
|
||||
stored figure while the confirmation screen it leads to said
|
||||
`unknown (SYMBOL)`; both now say `unknown (SYMBOL)`, from one function in
|
||||
`src/popup/views/send.js`. The confirmation screen's fee-unknown message names
|
||||
its cause: for an unknown scale it says the wallet does not know how many
|
||||
decimal places the token uses and that the transaction cannot be sent, instead
|
||||
of asking the user to go back and try again, which cannot help. For any other
|
||||
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: A transaction with no `to` says "This transaction creates a new
|
||||
contract. It has no recipient." on its recipient line and in its transaction
|
||||
|
||||
@@ -698,13 +698,15 @@
|
||||
You do not have enough ETH to pay the network fee for this
|
||||
transfer. Please add ETH to this address and try again.
|
||||
</div>
|
||||
<!-- Its sentence names why the fee could not be estimated,
|
||||
so show() in confirmTx.js sets it. -->
|
||||
<div
|
||||
id="confirm-fee-unknown-error"
|
||||
class="mb-2 border border-border border-dashed p-2 text-xs"
|
||||
style="visibility: hidden"
|
||||
></div>
|
||||
>
|
||||
The network fee could not be estimated, so this transaction
|
||||
cannot be checked against your balance. Please go back and
|
||||
try again.
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="block mb-1 text-xs">Password</label>
|
||||
<input
|
||||
|
||||
@@ -198,19 +198,6 @@ function show(txInfo) {
|
||||
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
|
||||
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
|
||||
|
||||
// The fee-unknown message names its cause, which is also known here.
|
||||
// Without the token's scale estimateGas() cannot encode the transfer, so
|
||||
// the estimate fails every time and going back cannot help; any other
|
||||
// failure may clear on a retry.
|
||||
$("confirm-fee-unknown-error").textContent =
|
||||
isErc20 && txInfo.tokenDecimals == null
|
||||
? "The network fee could not be estimated, because this wallet" +
|
||||
" does not know how many decimal places this token uses, so" +
|
||||
" this transaction cannot be sent."
|
||||
: "The network fee could not be estimated, so this transaction" +
|
||||
" cannot be checked against your balance. Please go back and" +
|
||||
" try again.";
|
||||
|
||||
renderValidation(txInfo);
|
||||
|
||||
// Reset password field and error
|
||||
@@ -257,8 +244,7 @@ function renderValidation(txInfo) {
|
||||
});
|
||||
|
||||
// Messages carrying the user's own numbers are built here; the fixed
|
||||
// sentences live in the reserved elements in index.html, except the
|
||||
// fee-unknown one, which show() sets.
|
||||
// sentences live in the reserved elements in index.html.
|
||||
const messages = [];
|
||||
if (codes.includes(CODES.AMOUNT_INVALID)) {
|
||||
messages.push("Please enter a valid amount to send.");
|
||||
|
||||
+52
-52
@@ -146,50 +146,6 @@ function renderSendTokenSelect(addr) {
|
||||
}
|
||||
}
|
||||
|
||||
// The token balance and scale the Send screen states and hands the
|
||||
// confirmation screen, so the two screens describe the holding the same way.
|
||||
//
|
||||
// The scale is resolved the same way balances.js resolved the scale it
|
||||
// DISPLAYED this token's balance at: bundled list, then the user's tracked
|
||||
// tokens, then the explorer. The stored tokenBalances[].decimals is the
|
||||
// explorer's own answer alone, so reading it raw carries a null forward for a
|
||||
// token the wallet does know the scale of — and displayedDecimals() then throws
|
||||
// inside estimateGas(), which the confirmation screen reports as an unestimable
|
||||
// fee. Unsendable, over a scale that was never in doubt
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/349). Still null when nothing
|
||||
// knows: no fallback.
|
||||
//
|
||||
// Resolved WITH `wallets`, which balances.js does not pass: that adds
|
||||
// explorerDecimals()'s cross-address check, so a contract two addresses report
|
||||
// different scales for answers null rather than picking one. That check has to
|
||||
// apply here, because this scale encodes the transfer — it is carried forward
|
||||
// so the transfer is encoded with the number the user read rather than with
|
||||
// whatever the contract answers at signing time (see
|
||||
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
|
||||
// fetch time and cannot consult a state it is in the middle of replacing.
|
||||
//
|
||||
// The two resolutions can therefore differ, and where they do, the stored
|
||||
// `balance` is a quantity computed at a scale this screen has just declined to
|
||||
// stand behind. Stating it would leave validateTransfer() checking the amount
|
||||
// against a number the wallet does not vouch for, so it is withdrawn: unknown
|
||||
// scale means unknown balance. It is null rather than "0": both screens state
|
||||
// an unknown balance as unknown, and validateTransfer() treats it as no balance
|
||||
// to spend from, which is the fail-closed side of an amount nobody can check.
|
||||
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
|
||||
// all is an absence of holdings, which is true at every scale.
|
||||
function tokenBalanceAndDecimals(addr, token) {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const tokenDecimals = resolveTokenDecimals(token, {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
});
|
||||
if (!tb) return { tokenBalance: "0", tokenDecimals };
|
||||
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
|
||||
return { tokenBalance: tb.balance ?? null, tokenDecimals };
|
||||
}
|
||||
|
||||
function updateSendBalance() {
|
||||
const addr = currentAddress();
|
||||
if (!addr) return;
|
||||
@@ -206,16 +162,18 @@ function updateSendBalance() {
|
||||
truncateAmountNeverZero(addr.balance || "0") +
|
||||
" ETH";
|
||||
} else {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
const symbol = resolveSymbol(
|
||||
token,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
// A null balance is a holding whose scale is unknown. Saying a figure
|
||||
// for it would be a claim about the amount, so it reads as the
|
||||
// confirmation screen's balance line reads it; the send itself is
|
||||
// A null balance is a holding whose scale nothing knows. Saying "0"
|
||||
// for it would be a claim about the amount; the send itself is
|
||||
// refused later by transferAmountUnits() for the same missing scale.
|
||||
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
|
||||
const bal = tb ? tb.balance : "0";
|
||||
$("send-balance").textContent =
|
||||
bal == null
|
||||
? "Current balance: unknown (" + symbol + ")"
|
||||
@@ -282,17 +240,59 @@ function init(_ctx) {
|
||||
|
||||
let tokenSymbol = null;
|
||||
let tokenBalance = null;
|
||||
// The scale the amount and the balance below are rendered at, carried
|
||||
// forward so the transfer is encoded with the number the user read
|
||||
// rather than with whatever the contract answers at signing time. See
|
||||
// src/shared/transferAmount.js.
|
||||
let tokenDecimals = null;
|
||||
if (token !== "ETH") {
|
||||
const tb = (addr.tokenBalances || []).find(
|
||||
(t) => t.address.toLowerCase() === token.toLowerCase(),
|
||||
);
|
||||
tokenSymbol = resolveSymbol(
|
||||
token,
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
|
||||
addr,
|
||||
token,
|
||||
));
|
||||
// null carried through rather than flattened to "0": the confirm
|
||||
// screen states an unknown balance as unknown, and
|
||||
// validateTransfer() treats it as no balance to spend from, which
|
||||
// is the fail-closed side of an amount nobody can check.
|
||||
tokenBalance = tb ? (tb.balance ?? null) : "0";
|
||||
// Resolved the same way balances.js resolved the scale it
|
||||
// DISPLAYED this token's balance at: bundled list, then the user's
|
||||
// tracked tokens, then the explorer. The stored
|
||||
// tokenBalances[].decimals is the explorer's own answer alone, so
|
||||
// reading it raw carries a null forward for a token the wallet
|
||||
// does know the scale of — and displayedDecimals() then throws
|
||||
// inside estimateGas(), which the confirmation screen reports as
|
||||
// an unestimable fee. Unsendable, over a scale that was never in
|
||||
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||
// Still null when nothing knows: no fallback.
|
||||
//
|
||||
// Resolved WITH `wallets`, which balances.js does not pass: that
|
||||
// adds explorerDecimals()'s cross-address check, so a contract two
|
||||
// addresses report different scales for answers null rather than
|
||||
// picking one. That check has to apply here, because this value
|
||||
// encodes a transfer; balances.js is formatting one explorer row
|
||||
// at fetch time and cannot consult a state it is in the middle of
|
||||
// replacing.
|
||||
tokenDecimals = resolveTokenDecimals(token, {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
});
|
||||
// The two resolutions can therefore differ, and where they do, the
|
||||
// stored `balance` is a quantity computed at a scale this screen
|
||||
// has just declined to stand behind. Stating it would leave
|
||||
// validateTransfer() checking the amount against a number the
|
||||
// wallet does not vouch for, and — since the unknown-balance path
|
||||
// is gated on the balance, not on the scale — would leave the
|
||||
// fee-estimate failure as the only thing on the confirmation
|
||||
// screen, which says nothing about decimals. Unknown scale means
|
||||
// unknown balance. Only a stored quantity is withdrawn: the "0"
|
||||
// for a token that has no row at all is an absence of holdings,
|
||||
// which is true at every scale.
|
||||
if (tb && tokenDecimals === null) tokenBalance = null;
|
||||
}
|
||||
|
||||
ctx.showConfirmTx({
|
||||
|
||||
+41
-10
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -382,56 +382,8 @@ describe("a scale the explorer's own rows disagree about", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// https://git.eeqj.de/sneak/AutistMask/issues/377. The Send screen read the
|
||||
// stored balance and said "5.0000 NOVEL", the confirmation screen it leads
|
||||
// to said "unknown (NOVEL)", and the fee message asked the user to go back
|
||||
// and try again, which cannot supply a scale.
|
||||
test("reads the same on the Send screen and the confirmation screen, and the fee message names the scale", async () => {
|
||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
|
||||
state.selectedToken = NOVEL;
|
||||
send.updateSendBalance();
|
||||
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
|
||||
|
||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||
confirmTx.show(txInfo);
|
||||
await settle();
|
||||
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
|
||||
expect(text("confirm-fee-unknown-error")).toBe(
|
||||
"The network fee could not be estimated, because this wallet" +
|
||||
" does not know how many decimal places this token uses, so" +
|
||||
" this transaction cannot be sent.",
|
||||
);
|
||||
expect(el("confirm-fee-unknown-error").style.visibility).toBe(
|
||||
"visible",
|
||||
);
|
||||
});
|
||||
|
||||
test("while a fee that fails for any other reason keeps its retry", async () => {
|
||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||
const getFeeData = mockProvider.getFeeData;
|
||||
mockProvider.getFeeData = async () => {
|
||||
throw new Error("the node did not answer");
|
||||
};
|
||||
try {
|
||||
confirmTx.show(txInfo);
|
||||
await settle();
|
||||
} finally {
|
||||
mockProvider.getFeeData = getFeeData;
|
||||
}
|
||||
expect(text("confirm-fee-amount")).toBe("Unable to estimate");
|
||||
expect(text("confirm-fee-unknown-error")).toBe(
|
||||
"The network fee could not be estimated, so this transaction" +
|
||||
" cannot be checked against your balance. Please go back and" +
|
||||
" try again.",
|
||||
);
|
||||
});
|
||||
|
||||
test("while agreeing rows leave the scale usable", async () => {
|
||||
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
||||
state.selectedToken = NOVEL;
|
||||
send.updateSendBalance();
|
||||
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
|
||||
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||
expect(txInfo.tokenDecimals).toBe(6);
|
||||
expect(txInfo.tokenBalance).toBe("5.0");
|
||||
|
||||
Reference in New Issue
Block a user