Compare commits
3
Commits
057d49d5b2
...
d732f1aafe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d732f1aafe | ||
|
|
6127fd9432 | ||
|
|
f4a51e1679 |
@@ -887,7 +887,9 @@ Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
|
|||||||
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
|
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
|
||||||
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
|
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
|
||||||
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
|
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
|
||||||
shows is a V4 exact-in `amountIn` of zero.
|
shows is a V4 exact-in `amountIn` of zero and
|
||||||
|
`Whatever an earlier step sent to the pair (V2 already paid)` when it is a V2
|
||||||
|
exact-in `amountIn` of zero.
|
||||||
|
|
||||||
The rule and its exception live in `src/shared/amountDisplay.js` as
|
The rule and its exception live in `src/shared/amountDisplay.js` as
|
||||||
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
||||||
@@ -980,7 +982,8 @@ read:
|
|||||||
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
|
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,
|
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
|
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
|
the same words. `Unlimited`, `All available (V4 open delta)` and
|
||||||
|
`Whatever an earlier step sent to the pair (V2 already paid)` keep their
|
||||||
wording. When a V2 exact-out step sets `Min. received`, that line shows its
|
wording. When a V2 exact-out step sets `Min. received`, that line shows its
|
||||||
`amountOut`, the exact amount it buys.
|
`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
|
||||||
@@ -991,11 +994,22 @@ read:
|
|||||||
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
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
|
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
||||||
exact-in action.
|
exact-in action.
|
||||||
|
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
|
||||||
|
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
|
||||||
|
The router reads that zero as "the pair already holds the input tokens": the
|
||||||
|
step pays nothing itself and swaps whatever an earlier step sent to the pair,
|
||||||
|
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
|
||||||
|
such meaning and is shown as a zero.
|
||||||
- `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
|
||||||
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
||||||
read `0.0000` when the token's scale was known.
|
read `0.0000` when the token's scale was known. The router passes a balance
|
||||||
|
check whenever the balance is at least `minBalance`, so a zero `minBalance`
|
||||||
|
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
||||||
|
output side holds no minimum, not even a zero one, at the point the check is
|
||||||
|
reached, and otherwise leaves the current token and figure in place. A nonzero
|
||||||
|
`minBalance` sets both lines, as a swap step does.
|
||||||
|
|
||||||
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
|
||||||
@@ -1008,6 +1022,12 @@ 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
|
the signature screen has its own amount wording, including `Unknown`; the
|
||||||
SignApproval section below describes it.
|
SignApproval section below describes it.
|
||||||
|
|
||||||
|
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
|
||||||
|
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
|
||||||
|
UTC, so a later deadline, such as the `uint256` maximum, reads
|
||||||
|
`After 275760-09-13 00:00:00 (no deadline in practice)` rather than leaving the
|
||||||
|
whole swap undecoded.
|
||||||
|
|
||||||
#### Partial USD totals
|
#### Partial USD totals
|
||||||
|
|
||||||
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
Prices are fetched for the top 25 tokens only, so an address can hold assets the
|
||||||
@@ -1114,16 +1134,18 @@ because bumping for one would send every older install to StateRecovery for
|
|||||||
nothing.
|
nothing.
|
||||||
|
|
||||||
Every read of the record goes through `assertStateUsable()` first, on the raw
|
Every read of the record goes through `assertStateUsable()` first, on the raw
|
||||||
bytes, before normalization: `loadState()` for the popup and `getState()` for
|
bytes, before normalization: `loadState()` and every `saveState()` for the
|
||||||
the background. It refuses a record that is not an object, a `schemaVersion`
|
popup, and `getState()` for the background. It refuses a record that is not an
|
||||||
this build does not understand (a newer one included), a `wallets` that is not a
|
object, a `schemaVersion` this build does not understand (a newer one included),
|
||||||
list of wallet records with address records in them, and a `networkId` that is
|
a `wallets` that is not a list of wallet records with address records in them,
|
||||||
not a network in `src/shared/networks.js`. Refusing is the whole point — a
|
and a `networkId` that is not a network in `src/shared/networks.js`. Refusing is
|
||||||
record the wallet cannot vouch for is never normalized, never written back, and
|
the whole point — a record the wallet cannot vouch for is never normalized,
|
||||||
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
|
never written back, and never half-loaded. The popup shows StateRecovery,
|
||||||
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
|
whether it finds the record unreadable when it opens or at a save while it is
|
||||||
saved data cannot be read and that nothing was signed or sent, rather than the
|
open; a dApp gets a specific error (`-32007`, an EIP-1474 server-error code the
|
||||||
generic `-32603` every request used to answer.
|
spec leaves unassigned) saying the saved data cannot be read and that nothing
|
||||||
|
was signed or sent, rather than the generic `-32603` every request used to
|
||||||
|
answer.
|
||||||
|
|
||||||
Every other field of the record is floored in `normalizePersisted()` rather than
|
Every other field of the record is floored in `normalizePersisted()` rather than
|
||||||
gated, and the floor is not the same for every field. Some are type-checked as a
|
gated, and the floor is not the same for every field. Some are type-checked as a
|
||||||
@@ -1167,8 +1189,9 @@ now also reported rather than swallowed: `onSaveFailure()` in
|
|||||||
`src/shared/state.js` is called for every failed save, awaited or not, and the
|
`src/shared/state.js` is called for every failed save, awaited or not, and the
|
||||||
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
|
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
|
||||||
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
|
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
|
||||||
covers — a quota, a revoked permission, a record a newer build wrote — and the
|
covers — a quota, a revoked permission — and the wallet must never look healthy
|
||||||
wallet must never look healthy while that is true.
|
while that is true. A save that fails because the stored record fails the gate,
|
||||||
|
such as one a newer build wrote, gets StateRecovery instead of the banner.
|
||||||
|
|
||||||
The `networkId` check is not cosmetic: that value is an object KEY into
|
The `networkId` check is not cosmetic: that value is an object KEY into
|
||||||
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
|
||||||
@@ -1978,9 +2001,19 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
#### StateRecovery (`state-recovery`)
|
#### StateRecovery (`state-recovery`)
|
||||||
|
|
||||||
- **When**: `loadState()` refused the stored profile, so the popup has no
|
- **When**: the stored profile fails `assertStateUsable()`. At open, that is
|
||||||
profile at all. It is the only screen reached without one, and the only one
|
`loadState()` refusing it, so the popup has no profile at all. While the popup
|
||||||
that never appears during ordinary use.
|
is open, on any screen, it is a save refusing it: every `saveState()` reads
|
||||||
|
the stored record and runs the same check before writing, so the popup finds
|
||||||
|
it at the next navigation or ten-second refresh, whether or not the network
|
||||||
|
answers ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). A save that
|
||||||
|
fails for any other reason, such as a storage read or write that errors, gets
|
||||||
|
the "NOT SAVED" banner instead and leaves the screen as it is. The screen it
|
||||||
|
replaces is left as any navigation leaves it, so a revealed phrase or key, or
|
||||||
|
a typed password, is wiped. Once up, the screen stays until the popup closes
|
||||||
|
or reloads: work still running in the popup, such as a transaction wait,
|
||||||
|
cannot replace it, even after the record is erased in another window. It is
|
||||||
|
the only screen that never appears during ordinary use.
|
||||||
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
- **Why it exists**: a record the wallet cannot read used to render nothing — no
|
||||||
view, no message, no control — while every dApp call answered a generic
|
view, no message, no control — while every dApp call answered a generic
|
||||||
internal error, and no reset or wipe control existed anywhere in the product.
|
internal error, and no reset or wipe control existed anywhere in the product.
|
||||||
@@ -2007,16 +2040,20 @@ view would leave a wallet one click from deletion.
|
|||||||
Nothing was erased." on the error line
|
Nothing was erased." on the error line
|
||||||
- **No other control is reachable.** The Settings gear is hidden while this
|
- **No other control is reachable.** The Settings gear is hidden while this
|
||||||
screen is up, because every screen behind it renders from the profile that
|
screen is up, because every screen behind it renders from the profile that
|
||||||
could not be read, and `showView()` is not used to raise it for the same
|
could not be read. `showView()` is not used to raise it, for the same reason:
|
||||||
reason — it reads and writes the state singleton.
|
it reads and writes the state singleton. Under an open popup the screen is
|
||||||
|
passed to `showView()` only to run the replaced screen's cleanup; from then on
|
||||||
|
`showView()` shows nothing else in that popup.
|
||||||
- **Both controls are required.** An export with no reset leaves the user
|
- **Both controls are required.** An export with no reset leaves the user
|
||||||
looking at a broken profile with no way to use the wallet again; a reset with
|
looking at a broken profile with no way to use the wallet again; a reset with
|
||||||
no export destroys the only copy of a record that may hold recoverable key
|
no export destroys the only copy of a record that may hold recoverable key
|
||||||
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
|
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
|
||||||
and for the same reason: there is no password to gate this with, since there
|
and for the same reason: there is no password to gate this with, since there
|
||||||
is no profile to check one against.
|
is no profile to check one against.
|
||||||
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
|
- Not in `RESTORABLE_VIEWS`, and never recorded as the current view: the record
|
||||||
nothing on this path writes state at all.
|
can become readable again under an open popup, erased in another window, and
|
||||||
|
the next save from that popup then succeeds. A popup opened after that opens
|
||||||
|
normally.
|
||||||
|
|
||||||
### External Services
|
### External Services
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,36 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-04: A popup that is already open when the stored profile becomes
|
||||||
|
unreadable moves to the recovery screen
|
||||||
|
([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on
|
||||||
|
the last good profile, with the "NOT SAVED" banner at most, until reopened.
|
||||||
|
Every save already ran the check the popup runs at open, so the popup finds
|
||||||
|
the record at the next navigation or ten-second refresh, whether or not the
|
||||||
|
network answers; a save that fails that check now raises the recovery screen
|
||||||
|
and stops the refresh. The screen it replaces is left as any navigation leaves
|
||||||
|
it, so a revealed phrase or key or a typed password is wiped. Once up, nothing
|
||||||
|
else in that popup can replace it, and a later save or a transaction wait that
|
||||||
|
ends does not clear an export or a typed confirmation. It is never saved as
|
||||||
|
the current view, so a popup opened after the record is erased in another
|
||||||
|
window opens normally. Any other failed save still gets the banner and leaves
|
||||||
|
the screen alone.
|
||||||
|
- 2026-10-04: A swap whose deadline is later than a JavaScript date can hold is
|
||||||
|
decoded ([#437](https://git.eeqj.de/sneak/AutistMask/issues/437)). A date
|
||||||
|
reaches only to 275760-09-13, so a later deadline, such as the `uint256`
|
||||||
|
maximum, made the `Deadline` line throw, and the approval screen showed the
|
||||||
|
swap as an undecoded contract call with nothing saying why. That line now
|
||||||
|
reads `After 275760-09-13 00:00:00 (no deadline in practice)`.
|
||||||
|
- 2026-10-04: The swap decoder reads two router zeros the way the router does
|
||||||
|
([#415](https://git.eeqj.de/sneak/AutistMask/issues/415)). A V2 exact-in
|
||||||
|
`amountIn` of zero means an earlier step already sent the tokens to the pair;
|
||||||
|
`Amount` showed `0.0000` for it and now reads
|
||||||
|
`Whatever an earlier step sent to the pair (V2 already paid)`. A
|
||||||
|
`BALANCE_CHECK_ERC20` with a zero `minBalance` guarantees nothing, yet it
|
||||||
|
replaced the minimum an earlier swap step stated, so `Min. received` read
|
||||||
|
`None (no minimum guaranteed)`; it now sets the output side only when that
|
||||||
|
side holds no minimum at the point the check is reached. A nonzero
|
||||||
|
`minBalance` still sets the output side.
|
||||||
- 2026-10-04: The signature screen shows a personal message as the bytes that
|
- 2026-10-04: The signature screen shows a personal message as the bytes that
|
||||||
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
|
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
|
||||||
showed only the decoded text, with bidirectional and zero-width characters
|
showed only the decoded text, with bidirectional and zero-width characters
|
||||||
|
|||||||
+27
-2
@@ -50,6 +50,10 @@ function renderWalletList() {
|
|||||||
|
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
|
||||||
|
// The ten-second refresh init() starts, stopped when the popup moves to the
|
||||||
|
// recovery screen: there is no profile left to refresh.
|
||||||
|
let refreshTimer = null;
|
||||||
|
|
||||||
async function doRefreshAndRender() {
|
async function doRefreshAndRender() {
|
||||||
if (refreshInFlight) return;
|
if (refreshInFlight) return;
|
||||||
refreshInFlight = true;
|
refreshInFlight = true;
|
||||||
@@ -155,7 +159,28 @@ async function init() {
|
|||||||
// reported rather than being swallowed by the save queue
|
// reported rather than being swallowed by the save queue
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
|
||||||
// the approval-window branch below too, since that window saves as well.
|
// the approval-window branch below too, since that window saves as well.
|
||||||
onSaveFailure(showSaveFailureBanner);
|
//
|
||||||
|
// Every save first reads the stored record and refuses it with the same
|
||||||
|
// check loadState() runs below. So a record that becomes unreadable while
|
||||||
|
// the popup is open is found by the next save, a navigation or the
|
||||||
|
// ten-second refresh, and gets the screen it would get at open
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/373). Passing the recovery
|
||||||
|
// screen to showView() first leaves the current screen as any navigation
|
||||||
|
// does, so a phrase, key or password on it is wiped, and from then on
|
||||||
|
// showView() shows nothing else. A later save that fails the same way,
|
||||||
|
// such as a refresh already in flight, comes back here, where both calls
|
||||||
|
// see the screen already up and do nothing. Any other failed save is a
|
||||||
|
// read or write that failed, and gets the banner without changing the
|
||||||
|
// screen.
|
||||||
|
onSaveFailure((e) => {
|
||||||
|
if (e instanceof StateUnusableError) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
showView("state-recovery");
|
||||||
|
stateRecovery.show(e);
|
||||||
|
} else {
|
||||||
|
showSaveFailureBanner(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await loadState();
|
await loadState();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -244,7 +269,7 @@ async function init() {
|
|||||||
renderWalletList();
|
renderWalletList();
|
||||||
restoreView();
|
restoreView();
|
||||||
doRefreshAndRender();
|
doRefreshAndRender();
|
||||||
setInterval(doRefreshAndRender, 10000);
|
refreshTimer = setInterval(doRefreshAndRender, 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,8 @@ const VIEWS = [
|
|||||||
"export-privkey",
|
"export-privkey",
|
||||||
"show-phrase",
|
"show-phrase",
|
||||||
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
// Shown by src/popup/views/stateRecovery.js when the stored profile
|
||||||
// cannot be read. It is never reached through showView() — by then the
|
// cannot be read, never by showView() (see there), but listed so that
|
||||||
// state singleton this file writes on every navigation refuses to be read
|
// every view-hiding loop covers it.
|
||||||
// — but it is listed so that every view-hiding loop covers it.
|
|
||||||
"state-recovery",
|
"state-recovery",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -85,12 +84,28 @@ function hideError(id) {
|
|||||||
el.style.visibility = "hidden";
|
el.style.visibility = "hidden";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set when src/popup/index.js passes the recovery screen to showView(), and
|
||||||
|
// never cleared. Kept in memory for this popup's life, never in
|
||||||
|
// state.currentView, which is saved: a popup opened later must not inherit it.
|
||||||
|
let stateRecoveryShown = false;
|
||||||
|
|
||||||
function showView(name) {
|
function showView(name) {
|
||||||
|
// The recovery screen, once up, is never replaced: work still running
|
||||||
|
// when it went up, such as a transaction wait, must not take the user off
|
||||||
|
// it or clear what they exported or typed there
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
||||||
|
if (stateRecoveryShown) return;
|
||||||
const leaving = state.currentView;
|
const leaving = state.currentView;
|
||||||
if (leaving && leaving !== name) {
|
if (leaving && leaving !== name) {
|
||||||
const onLeave = viewLeaveHandlers.get(leaving);
|
const onLeave = viewLeaveHandlers.get(leaving);
|
||||||
if (onLeave) onLeave();
|
if (onLeave) onLeave();
|
||||||
}
|
}
|
||||||
|
// Passed here only so the screen it replaces is left like any other;
|
||||||
|
// stateRecovery.show() raises it, and it is never the current view.
|
||||||
|
if (name === "state-recovery") {
|
||||||
|
stateRecoveryShown = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const v of VIEWS) {
|
for (const v of VIEWS) {
|
||||||
const el = document.getElementById(`view-${v}`);
|
const el = document.getElementById(`view-${v}`);
|
||||||
if (el) {
|
if (el) {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
// Everything else in the popup assumes a loaded profile: showView() reads and
|
// Everything else in the popup assumes a loaded profile: showView() reads and
|
||||||
// writes the state singleton, every view renders from it, and the Settings
|
// writes the state singleton, every view renders from it, and the Settings
|
||||||
// gear leads to a screen that does both. None of that is available here — by
|
// gear leads to a screen that does both. None of that is available here — by
|
||||||
// the time this runs, loadState() has REFUSED, deliberately, and reading the
|
// the time this runs, the stored record has been REFUSED, deliberately: at
|
||||||
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
// open loadState() refused it and reading the singleton throws
|
||||||
|
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
|
||||||
|
// a save refused it (https://git.eeqj.de/sneak/AutistMask/issues/373).
|
||||||
//
|
//
|
||||||
// So this module talks to the DOM directly and touches no state at all. It is
|
// So this module talks to the DOM directly and touches no state at all. It is
|
||||||
// the one screen that must work when nothing else can, which is also why it
|
// the one screen that must work when nothing else can, which is also why it
|
||||||
@@ -170,6 +172,11 @@ function wire() {
|
|||||||
* refused, or its sentence.
|
* refused, or its sentence.
|
||||||
*/
|
*/
|
||||||
function show(problem) {
|
function show(problem) {
|
||||||
|
// Already up: a later save that trips over the same record, such as a
|
||||||
|
// refresh that was in flight when the screen went up, must not clear what
|
||||||
|
// the user has exported or typed here.
|
||||||
|
if (!$("view-state-recovery").classList.contains("hidden")) return;
|
||||||
|
|
||||||
const sentence =
|
const sentence =
|
||||||
(problem && (problem.problem || problem.message)) || String(problem);
|
(problem && (problem.problem || problem.message)) || String(problem);
|
||||||
|
|
||||||
|
|||||||
+47
-15
@@ -84,17 +84,31 @@ function present(value) {
|
|||||||
//
|
//
|
||||||
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
||||||
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
||||||
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
|
// a zero minimum is a literal zero slippage floor and is stated as one. Nor
|
||||||
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
||||||
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
||||||
// so a zero `amountIn` there is a literal zero and is displayed as one.
|
// so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2
|
||||||
|
// exact-in path gives zero a meaning of its own: see ALREADY_PAID.
|
||||||
const OPEN_DELTA = Symbol("v4-open-delta");
|
const OPEN_DELTA = Symbol("v4-open-delta");
|
||||||
|
|
||||||
// The two amount lines that state a fact instead of a quantity. Same register
|
// The Universal Router's V2 exact-in spells "the pair already holds the input
|
||||||
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
|
// tokens" as an amount of zero: universal-router
|
||||||
// as a number — and deliberately not a third phrasing of "not named": these
|
// `contracts/libraries/Constants.sol` declares
|
||||||
// say different things.
|
// `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases
|
||||||
|
// when a v2 pair has already received input tokens"), and
|
||||||
|
// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn`
|
||||||
|
// equals it. The swap then spends whatever an earlier step sent to the pair.
|
||||||
|
// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say
|
||||||
|
// that nothing is swapped.
|
||||||
|
const ALREADY_PAID = Symbol("v2-already-paid");
|
||||||
|
|
||||||
|
// The amount lines that state a fact instead of a quantity. Same register as
|
||||||
|
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
|
||||||
|
// number — and deliberately not another phrasing of "not named": these say
|
||||||
|
// different things.
|
||||||
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
||||||
|
const ALREADY_PAID_AMOUNT =
|
||||||
|
"Whatever an earlier step sent to the pair (V2 already paid)";
|
||||||
const NO_MINIMUM = "None (no minimum guaranteed)";
|
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".
|
||||||
@@ -185,6 +199,7 @@ function decodeBalanceCheck(input) {
|
|||||||
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
||||||
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
||||||
// address[] path, bool payerIsUser)
|
// address[] path, bool payerIsUser)
|
||||||
|
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
|
||||||
function decodeV2SwapExactIn(input) {
|
function decodeV2SwapExactIn(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(
|
const d = coder.decode(
|
||||||
@@ -192,7 +207,7 @@ function decodeV2SwapExactIn(input) {
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
amountIn: d[1],
|
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
|
||||||
amountOutMin: d[2],
|
amountOutMin: d[2],
|
||||||
tokenIn: d[3][0],
|
tokenIn: d[3][0],
|
||||||
tokenOut: d[3][d[3].length - 1],
|
tokenOut: d[3][d[3].length - 1],
|
||||||
@@ -502,7 +517,13 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
if (cmdId === 0x0e) {
|
if (cmdId === 0x0e) {
|
||||||
const b = decodeBalanceCheck(inputs[i]);
|
const b = decodeBalanceCheck(inputs[i]);
|
||||||
if (b) setOutput(b.token, b.minBalance);
|
// The router passes this check whenever the owner holds at
|
||||||
|
// least minBalance, so a zero one guarantees nothing and
|
||||||
|
// does not replace a minimum an earlier step stated. Any
|
||||||
|
// other minBalance sets the output side as a swap does.
|
||||||
|
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
||||||
|
setOutput(b.token, b.minBalance);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x00) {
|
if (cmdId === 0x00) {
|
||||||
@@ -623,14 +644,20 @@ function decode(data, toAddress, sources) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (present(inputAmount)) {
|
if (present(inputAmount)) {
|
||||||
// Two amounts need no scale to describe and are named rather than
|
// Three amounts need no scale to describe and are named rather
|
||||||
// formatted: V4's open delta, which is not a quantity at all (see
|
// than formatted: V4's open delta and V2's already-paid zero,
|
||||||
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
|
// neither of which is a quantity at all (see OPEN_DELTA and
|
||||||
// first — the sentinel is not a bigint and cannot be compared with
|
// ALREADY_PAID), and an unbounded permit. Those two tests come
|
||||||
// one.
|
// first — the sentinels are not bigints and cannot be compared
|
||||||
|
// with one.
|
||||||
let amount;
|
let amount;
|
||||||
if (inputAmount === OPEN_DELTA) {
|
if (inputAmount === OPEN_DELTA) {
|
||||||
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||||
|
} else if (inputAmount === ALREADY_PAID) {
|
||||||
|
amount = {
|
||||||
|
raw: ALREADY_PAID_AMOUNT,
|
||||||
|
display: ALREADY_PAID_AMOUNT,
|
||||||
|
};
|
||||||
} else if (inputAmount >= MAX_UINT160) {
|
} else if (inputAmount >= MAX_UINT160) {
|
||||||
amount = { raw: "Unlimited", display: "Unlimited" };
|
amount = { raw: "Unlimited", display: "Unlimited" };
|
||||||
} else if (hasV2ExactOut) {
|
} else if (hasV2ExactOut) {
|
||||||
@@ -697,10 +724,15 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
|
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
|
||||||
|
|
||||||
|
// A JavaScript date reaches only to 275760-09-13 00:00:00 UTC. A
|
||||||
|
// later deadline, such as the uint256 maximum, makes an invalid date,
|
||||||
|
// and toISOString() throws on one, so that deadline is said in words.
|
||||||
const deadlineDate = new Date(Number(deadline) * 1000);
|
const deadlineDate = new Date(Number(deadline) * 1000);
|
||||||
details.push({
|
details.push({
|
||||||
label: "Deadline",
|
label: "Deadline",
|
||||||
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
value: isNaN(deadlineDate.getTime())
|
||||||
|
? "After 275760-09-13 00:00:00 (no deadline in practice)"
|
||||||
|
: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -148,6 +148,173 @@ describe("the destructive reset on the recovery screen", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a popup already open when the stored profile becomes unreadable", () => {
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
|
||||||
|
// on the wallet list with the last good balances, and only a reopen
|
||||||
|
// reached the recovery screen.
|
||||||
|
test("moves to the recovery screen at its next refresh", async () => {
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
expect(env.visibleViews()).toEqual(["main"]);
|
||||||
|
|
||||||
|
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||||
|
await env.tick();
|
||||||
|
|
||||||
|
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||||
|
expect(env.text("state-recovery-problem").length).toBeGreaterThan(10);
|
||||||
|
expect(env.hidden("btn-settings")).toBe(true);
|
||||||
|
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops the ten-second refresh", async () => {
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||||
|
await env.tick();
|
||||||
|
const { refreshBalances } = require("../src/shared/balances");
|
||||||
|
const calls = refreshBalances.mock.calls.length;
|
||||||
|
|
||||||
|
await env.tick();
|
||||||
|
|
||||||
|
expect(refreshBalances).toHaveBeenCalledTimes(calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a later save does not clear what the user exported or typed", async () => {
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
|
||||||
|
await env.tick();
|
||||||
|
|
||||||
|
await env.click("btn-state-recovery-export");
|
||||||
|
env.node("state-recovery-reset-input").value = "erase my";
|
||||||
|
// Such as the save of a refresh already in flight when the screen
|
||||||
|
// went up.
|
||||||
|
const { saveState } = require("../src/shared/state");
|
||||||
|
await expect(saveState()).rejects.toThrow();
|
||||||
|
await env.settle();
|
||||||
|
|
||||||
|
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||||
|
expect(env.hidden("state-recovery-blob")).toBe(false);
|
||||||
|
expect(env.value("state-recovery-reset-input")).toBe("erase my");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The record can become readable again under this popup, erased from the
|
||||||
|
// recovery screen of another window, so a save from this one can succeed.
|
||||||
|
test("a popup opened after the record is erased elsewhere shows a screen", async () => {
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||||
|
await env.tick();
|
||||||
|
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||||
|
|
||||||
|
await env.storage.remove("autistmask");
|
||||||
|
const { saveState } = require("../src/shared/state");
|
||||||
|
await saveState();
|
||||||
|
|
||||||
|
const reopened = await bootPopup(env.storage.read("autistmask"));
|
||||||
|
expect(reopened.visibleViews()).toEqual(["welcome"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a stored current view of the recovery screen does not blank the popup", async () => {
|
||||||
|
const env = await bootPopup(
|
||||||
|
unversionedValidProfile({ currentView: "state-recovery" }),
|
||||||
|
);
|
||||||
|
expect(env.visibleViews()).toEqual(["main"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a transaction wait that ends under it does not replace it", async () => {
|
||||||
|
const env = await bootPopup(
|
||||||
|
unversionedValidProfile({
|
||||||
|
currentView: "wait-tx",
|
||||||
|
viewData: {
|
||||||
|
pendingWait: {
|
||||||
|
hash: "0x1",
|
||||||
|
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(env.visibleViews()).toEqual(["wait-tx"]);
|
||||||
|
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
|
||||||
|
await env.tick();
|
||||||
|
await env.click("btn-state-recovery-export");
|
||||||
|
env.node("state-recovery-reset-input").value = "erase my";
|
||||||
|
|
||||||
|
// The test provider answers no receipt lookup, and six that fail in
|
||||||
|
// a row end the wait with an error.
|
||||||
|
for (let i = 0; i < 6; i++) await env.tick();
|
||||||
|
|
||||||
|
expect(env.text("error-tx-message")).toMatch(/could not be reached/);
|
||||||
|
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||||
|
expect(env.hidden("state-recovery-blob")).toBe(false);
|
||||||
|
expect(env.value("state-recovery-reset-input")).toBe("erase my");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a storage read that fails once leaves the wallet list up", async () => {
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
|
||||||
|
env.storage.local.get.mockRejectedValueOnce(
|
||||||
|
new Error("IO error: storage busy"),
|
||||||
|
);
|
||||||
|
await env.tick();
|
||||||
|
|
||||||
|
// Reported as a failed save, not mistaken for an unreadable profile.
|
||||||
|
expect(env.visibleViews()).toEqual(["main"]);
|
||||||
|
expect(env.node("save-failure-banner")).not.toBeNull();
|
||||||
|
|
||||||
|
await env.tick();
|
||||||
|
expect(env.visibleViews()).toEqual(["main"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The screen it replaces is left as any navigation leaves it: the rules
|
||||||
|
// at the top of src/popup/views/showPhrase.js and exportPrivkey.js hold
|
||||||
|
// for this way off them too.
|
||||||
|
describe("from a screen holding a secret", () => {
|
||||||
|
const PHRASE =
|
||||||
|
"abandon abandon abandon abandon abandon abandon abandon" +
|
||||||
|
" abandon abandon abandon abandon about";
|
||||||
|
|
||||||
|
afterEach(() => jest.dontMock("../src/shared/vault"));
|
||||||
|
|
||||||
|
test("a recovery phrase on screen is wiped", async () => {
|
||||||
|
jest.doMock("../src/shared/vault", () => ({
|
||||||
|
decryptWithPassword: async () => PHRASE,
|
||||||
|
}));
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
require("../src/popup/views/showPhrase").show(0);
|
||||||
|
env.node("show-phrase-password").value = "password";
|
||||||
|
await env.click("btn-show-phrase-reveal");
|
||||||
|
expect(env.text("show-phrase-value")).toBe(PHRASE);
|
||||||
|
|
||||||
|
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||||
|
await env.tick();
|
||||||
|
|
||||||
|
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||||
|
expect(env.text("show-phrase-value")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a private key still being decrypted is never written", async () => {
|
||||||
|
let answer;
|
||||||
|
jest.doMock("../src/shared/vault", () => ({
|
||||||
|
decryptWithPassword: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
answer = resolve;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const env = await bootPopup(unversionedValidProfile());
|
||||||
|
require("../src/popup/views/exportPrivkey").show(0, 0);
|
||||||
|
env.node("export-privkey-password").value = "password";
|
||||||
|
const revealing = env.click("btn-export-privkey-confirm");
|
||||||
|
|
||||||
|
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
|
||||||
|
await env.tick();
|
||||||
|
expect(env.visibleViews()).toEqual(["state-recovery"]);
|
||||||
|
expect(env.value("export-privkey-password")).toBe("");
|
||||||
|
|
||||||
|
answer(PHRASE);
|
||||||
|
await revealing;
|
||||||
|
expect(env.text("export-privkey-value")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("an unversioned profile that is perfectly valid", () => {
|
describe("an unversioned profile that is perfectly valid", () => {
|
||||||
// The upgrade case. Every install in the field is in this state, and the
|
// The upgrade case. Every install in the field is in this state, and the
|
||||||
// popup must load it, not offer to wipe it.
|
// popup must load it, not offer to wipe it.
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ jest.doMock("libsodium-wrappers-sumo", () => sodium);
|
|||||||
jest.doMock("qrcode", () => QRCode);
|
jest.doMock("qrcode", () => QRCode);
|
||||||
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
|
||||||
|
|
||||||
|
// Taken before any boot replaces them; see bootPopup().
|
||||||
|
const realSetInterval = globalThis.setInterval;
|
||||||
|
const realClearInterval = globalThis.clearInterval;
|
||||||
|
|
||||||
const POPUP_HTML = fs.readFileSync(
|
const POPUP_HTML = fs.readFileSync(
|
||||||
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -292,9 +296,20 @@ async function bootPopup(stored, options) {
|
|||||||
}),
|
}),
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
};
|
};
|
||||||
// The 10s refresh loop init() starts would outlive the test.
|
// The ten-second refresh init() starts, and a transaction wait's timers,
|
||||||
const realSetInterval = globalThis.setInterval;
|
// would outlive the test. So every interval is recorded rather than
|
||||||
globalThis.setInterval = () => 0;
|
// started, clearInterval() removes it as a browser would, and tick() below
|
||||||
|
// runs the ones still set. Put back by cleanupPopup().
|
||||||
|
const intervals = new Map();
|
||||||
|
let lastId = 0;
|
||||||
|
globalThis.setInterval = (fn) => {
|
||||||
|
lastId += 1;
|
||||||
|
intervals.set(lastId, fn);
|
||||||
|
return lastId;
|
||||||
|
};
|
||||||
|
globalThis.clearInterval = (id) => {
|
||||||
|
intervals.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
require("../../src/popup/index");
|
require("../../src/popup/index");
|
||||||
|
|
||||||
@@ -316,8 +331,6 @@ async function bootPopup(stored, options) {
|
|||||||
}
|
}
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
globalThis.setInterval = realSetInterval;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storage,
|
storage,
|
||||||
document,
|
document,
|
||||||
@@ -337,6 +350,12 @@ async function bootPopup(stored, options) {
|
|||||||
for (const fn of fns) await fn();
|
for (const fn of fns) await fn();
|
||||||
await settle();
|
await settle();
|
||||||
},
|
},
|
||||||
|
// Every interval still set runs once: the ten-second refresh, and a
|
||||||
|
// transaction wait's receipt poll and elapsed counter while one runs.
|
||||||
|
tick: async () => {
|
||||||
|
for (const fn of intervals.values()) await fn();
|
||||||
|
await settle();
|
||||||
|
},
|
||||||
settle,
|
settle,
|
||||||
// The view ids whose section is not hidden, as the audit measured them.
|
// The view ids whose section is not hidden, as the audit measured them.
|
||||||
visibleViews: () => {
|
visibleViews: () => {
|
||||||
@@ -354,6 +373,8 @@ function cleanupPopup() {
|
|||||||
delete globalThis.chrome;
|
delete globalThis.chrome;
|
||||||
delete globalThis.document;
|
delete globalThis.document;
|
||||||
delete globalThis.window;
|
delete globalThis.window;
|
||||||
|
globalThis.setInterval = realSetInterval;
|
||||||
|
globalThis.clearInterval = realClearInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -554,6 +554,33 @@ describe("uniswap decoder", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows the deadline as a UTC date and time", () => {
|
||||||
|
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
|
||||||
|
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A JavaScript date cannot hold this deadline. It used to make the whole
|
||||||
|
// swap undecoded.
|
||||||
|
test("a deadline of the uint256 maximum is stated in words", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
"0x08", // V2_SWAP_EXACT_IN
|
||||||
|
[
|
||||||
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
||||||
|
USDT_ADDR,
|
||||||
|
WETH_ADDR,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
2n ** 256n - 1n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result.name).toBe("Swap USDT \u2192 WETH");
|
||||||
|
expect(detail(result, "Deadline").value).toBe(
|
||||||
|
"After 275760-09-13 00:00:00 (no deadline in practice)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("formats permit amount when not unlimited", () => {
|
test("formats permit amount when not unlimited", () => {
|
||||||
const data = buildExecute(
|
const data = buildExecute(
|
||||||
"0x0a",
|
"0x0a",
|
||||||
@@ -831,6 +858,104 @@ describe("uniswap decoder", () => {
|
|||||||
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
|
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
|
||||||
|
// exact-in reads an amountIn of zero as universal-router
|
||||||
|
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and
|
||||||
|
// the swap uses all of them. Against 375998b this read "0.0000 USDT".
|
||||||
|
test("a V2 exact-in already-paid amountIn is named, not printed as zero", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
"0x08",
|
||||||
|
[
|
||||||
|
encodeV2SwapExactIn(
|
||||||
|
USER_ADDR,
|
||||||
|
0n, // Constants.ALREADY_PAID
|
||||||
|
500000000000000n,
|
||||||
|
[USDT_ADDR, WETH_ADDR],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
|
expect(detail(result, "Token In").value).toContain("USDT");
|
||||||
|
expect(detail(result, "Amount").value).toBe(
|
||||||
|
"Whatever an earlier step sent to the pair (V2 already paid)",
|
||||||
|
);
|
||||||
|
expect(detail(result, "Amount").rawValue).toBe(
|
||||||
|
"Whatever an earlier step sent to the pair (V2 already paid)",
|
||||||
|
);
|
||||||
|
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router passes a
|
||||||
|
// BALANCE_CHECK_ERC20 whenever the balance is at least minBalance, so a
|
||||||
|
// zero one guarantees nothing. Against 375998b it replaced the swap's
|
||||||
|
// output side: Token Out = USDC, Min. received = "None (no minimum
|
||||||
|
// guaranteed)".
|
||||||
|
test("a zero balance check keeps the minimum a swap step stated", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
|
||||||
|
[
|
||||||
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
||||||
|
USDT_ADDR,
|
||||||
|
WETH_ADDR,
|
||||||
|
]),
|
||||||
|
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 0n),
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
|
expect(detail(result, "Token Out").value).toContain("WETH");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A nonzero balance check still replaces the output side, as before.
|
||||||
|
test("a nonzero balance check replaces the minimum a swap step stated", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
|
||||||
|
[
|
||||||
|
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
||||||
|
USDT_ADDR,
|
||||||
|
WETH_ADDR,
|
||||||
|
]),
|
||||||
|
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 2000000n),
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
|
expect(detail(result, "Token Out").value).toContain("USDC");
|
||||||
|
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
||||||
|
});
|
||||||
|
|
||||||
|
// With no minimum stated before it, a zero balance check is what sets the
|
||||||
|
// output side, and it guarantees nothing.
|
||||||
|
test("a zero balance check with no earlier minimum states no minimum", () => {
|
||||||
|
const data = buildExecute(
|
||||||
|
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
||||||
|
[
|
||||||
|
encodeWrapEth(ROUTER_ADDR, 1000000000000000000n),
|
||||||
|
encodeBalanceCheck(USER_ADDR, USDT_ADDR, 0n),
|
||||||
|
],
|
||||||
|
9999999999n,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = uniswap.decode(data, ROUTER_ADDR);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
|
||||||
|
expect(detail(result, "Token Out").value).toContain("USDT");
|
||||||
|
expect(detail(result, "Min. received").value).toBe(
|
||||||
|
"None (no minimum guaranteed)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
|
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
|
||||||
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
|
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
|
||||||
// output currency, so it says so and titles itself "Uniswap Swap" rather
|
// output currency, so it says so and titles itself "Uniswap Swap" rather
|
||||||
|
|||||||
Reference in New Issue
Block a user