Compare commits
1 Commits
041f5dc39f
...
fb260ddf20
| Author | SHA1 | Date | |
|---|---|---|---|
| fb260ddf20 |
24
TODO.md
24
TODO.md
@@ -145,14 +145,22 @@ but the review is broader than any of them.
|
|||||||
[#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
|
[#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
|
||||||
hold `18`s that cannot be told apart retroactively; they display exactly as
|
hold `18`s that cannot be told apart retroactively; they display exactly as
|
||||||
they do today until the next balance refresh, which rewrites `tokenBalances`
|
they do today until the next balance refresh, which rewrites `tokenBalances`
|
||||||
wholesale and needs no user action. The only `18`s left in `src/` are native
|
wholesale and needs no user action. No `|| 18` or `?? 18` fallback remains
|
||||||
ETH's real scale in `src/shared/uniswap.js` and the fixed-point comparison
|
anywhere in `src/`; the literal `18`s that do remain are real data, not
|
||||||
scale in `src/shared/txValidation.js`. `tokenBalances[].decimals` is the
|
defaults — 432 per-token `decimals: 18` entries in the bundled
|
||||||
explorer's answer alone and not the scale a screen renders at, so the Send
|
`src/shared/tokenList.js`, and, outside that file, only native ETH's
|
||||||
screen resolves through `resolveTokenDecimals()` like every other consumer:
|
protocol-defined scale in `src/shared/uniswap.js` and the fixed-point
|
||||||
reading the stored field raw carried a `null` into `estimateGas()` for a
|
comparison scale in `src/shared/txValidation.js`. `tokenBalances[].decimals`
|
||||||
bundled token such as WETH, which reported an unestimable network fee and left
|
is the explorer's answer alone and not the scale a screen renders at, so the
|
||||||
Send disabled behind a message no retry could clear.
|
Send screen resolves through `resolveTokenDecimals()` like every other
|
||||||
|
consumer: reading the stored field raw carried a `null` into `estimateGas()`
|
||||||
|
for a bundled token such as WETH, which reported an unestimable network fee
|
||||||
|
and left Send disabled behind a message no retry could clear. Send resolves
|
||||||
|
with `wallets`, which adds the cross-address disagreement check the balance
|
||||||
|
list does not make, so the two can differ; where they do, the stored quantity
|
||||||
|
was computed at a scale Send has refused, and it is withdrawn with it. An
|
||||||
|
unknown scale is an unknown balance, and the user is told that rather than
|
||||||
|
that the fee could not be estimated.
|
||||||
|
|
||||||
- 2026-08-23: The background no longer reads or writes the shared `state`
|
- 2026-08-23: The background no longer reads or writes the shared `state`
|
||||||
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ function showFlash(msg, duration = 2000) {
|
|||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stored token balance as a number, or null when there is no number in it.
|
||||||
|
// balances.js writes null for a holding whose scale nothing knows, and this
|
||||||
|
// keeps that null from becoming a zero one dereference later.
|
||||||
|
function unknownableAmount(balance) {
|
||||||
|
if (balance == null) return null;
|
||||||
|
const n = parseFloat(balance);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
// One row of the balance list: symbol, quantity, fiat value.
|
// One row of the balance list: symbol, quantity, fiat value.
|
||||||
//
|
//
|
||||||
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
|
||||||
@@ -210,15 +219,6 @@ function showFlash(msg, duration = 2000) {
|
|||||||
// print for it and no fiat value to derive from one, and printing 0.0000 for
|
// print for it and no fiat value to derive from one, and printing 0.0000 for
|
||||||
// a real holding is the failure this whole rule exists to prevent, so the row
|
// a real holding is the failure this whole rule exists to prevent, so the row
|
||||||
// says so instead.
|
// says so instead.
|
||||||
// A stored token balance as a number, or null when there is no number in it.
|
|
||||||
// balances.js writes null for a holding whose scale nothing knows, and this
|
|
||||||
// keeps that null from becoming a zero one dereference later.
|
|
||||||
function unknownableAmount(balance) {
|
|
||||||
if (balance == null) return null;
|
|
||||||
const n = parseFloat(balance);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function balanceLine(symbol, amount, price, tokenId) {
|
function balanceLine(symbol, amount, price, tokenId) {
|
||||||
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
|
||||||
const usd =
|
const usd =
|
||||||
|
|||||||
@@ -255,12 +255,31 @@ function init(_ctx) {
|
|||||||
// inside estimateGas(), which the confirmation screen reports as
|
// inside estimateGas(), which the confirmation screen reports as
|
||||||
// an unestimable fee. Unsendable, over a scale that was never in
|
// an unestimable fee. Unsendable, over a scale that was never in
|
||||||
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
|
||||||
// Still null when nothing knows: no fallback, and the unknown
|
// Still null when nothing knows: no fallback.
|
||||||
// path below is then the real one.
|
//
|
||||||
|
// 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, {
|
tokenDecimals = resolveTokenDecimals(token, {
|
||||||
trackedTokens: state.trackedTokens,
|
trackedTokens: state.trackedTokens,
|
||||||
wallets: state.wallets,
|
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({
|
ctx.showConfirmTx({
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ const confirmTx = require("../src/popup/views/confirmTx");
|
|||||||
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
|
||||||
|
|
||||||
const HOLDER = "0x" + "a".repeat(40);
|
const HOLDER = "0x" + "a".repeat(40);
|
||||||
|
const SECOND_HOLDER = "0x" + "b".repeat(40);
|
||||||
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
|
||||||
const BLOCKSCOUT = "https://blockscout.example/api/v2";
|
const BLOCKSCOUT = "https://blockscout.example/api/v2";
|
||||||
// Bundled, 18 decimals. The wallet knows this token's scale without asking
|
// Bundled, 18 decimals. The wallet knows this token's scale without asking
|
||||||
@@ -176,6 +177,38 @@ async function fetchOnto(items) {
|
|||||||
return balances;
|
return balances;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same, for two addresses of one wallet holding the same contract. Sending
|
||||||
|
// is from the first. Two addresses is what it takes to reach
|
||||||
|
// explorerDecimals()'s disagreement check, which is only reachable across rows.
|
||||||
|
async function fetchOntoBoth(itemsA, itemsB) {
|
||||||
|
debugFetch.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
json: async () => itemsA,
|
||||||
|
}));
|
||||||
|
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||||
|
debugFetch.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
json: async () => itemsB,
|
||||||
|
}));
|
||||||
|
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, []);
|
||||||
|
state.wallets = [
|
||||||
|
{
|
||||||
|
name: "Wallet 1",
|
||||||
|
addresses: [
|
||||||
|
{ address: HOLDER, balance: "1.0", tokenBalances: a },
|
||||||
|
{ address: SECOND_HOLDER, balance: "1.0", tokenBalances: b },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
state.selectedWallet = 0;
|
||||||
|
state.selectedAddress = 0;
|
||||||
|
return { a, b };
|
||||||
|
}
|
||||||
|
|
||||||
function el(id) {
|
function el(id) {
|
||||||
return global.document.getElementById(id);
|
return global.document.getElementById(id);
|
||||||
}
|
}
|
||||||
@@ -283,6 +316,76 @@ describe("the Send screen resolves the scale rather than reading the stored one"
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// balances.js resolves the display scale WITHOUT `wallets`, so its explorer leg
|
||||||
|
// is the row it is formatting. send.js resolves WITH `wallets`, so its explorer
|
||||||
|
// leg is explorerDecimals(), which answers null when two addresses report
|
||||||
|
// different scales for one contract — the check that must apply before a scale
|
||||||
|
// encodes a transfer. The two therefore disagree exactly here, and a stored
|
||||||
|
// balance formatted at a scale the Send screen just refused is not a balance it
|
||||||
|
// may state: it would leave validateTransfer() satisfied, the unknown-balance
|
||||||
|
// sentence unfired, and the fee-estimate failure as the only thing on screen.
|
||||||
|
describe("a scale the explorer's own rows disagree about", () => {
|
||||||
|
// 5000000 units at the "6" address A reports, 5e18 at the "18" address B
|
||||||
|
// reports: both format to "5.0", so the disagreement is in the scale alone
|
||||||
|
// and not in the quantity.
|
||||||
|
function novel(decimals, value) {
|
||||||
|
return row(
|
||||||
|
{
|
||||||
|
address_hash: NOVEL,
|
||||||
|
symbol: "NOVEL",
|
||||||
|
name: "Novel Token",
|
||||||
|
decimals,
|
||||||
|
},
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("is stored per row, because storage holds the explorer's own answer", async () => {
|
||||||
|
const { a, b } = await fetchOntoBoth(
|
||||||
|
[novel("6", 5000000n)],
|
||||||
|
[novel("18", FIVE_WETH)],
|
||||||
|
);
|
||||||
|
expect(a[0].decimals).toBe(6);
|
||||||
|
expect(a[0].balance).toBe("5.0");
|
||||||
|
expect(b[0].decimals).toBe(18);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves to null on the Send screen, and takes the balance with it", async () => {
|
||||||
|
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
|
||||||
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
|
expect(txInfo.tokenDecimals).toBeNull();
|
||||||
|
// The regression this closes: null scale alongside a non-null balance.
|
||||||
|
expect(txInfo.tokenBalance).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("so the user is told the balance is unknown, not that the fee failed", async () => {
|
||||||
|
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
|
||||||
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
|
confirmTx.show(txInfo);
|
||||||
|
await settle();
|
||||||
|
// Before the fix: "5.0 NOVEL", an empty confirm-errors, and
|
||||||
|
// confirm-fee-unknown-error — "the network fee could not be
|
||||||
|
// estimated... please go back and try again" — as the only explanation
|
||||||
|
// for a screen that can never proceed.
|
||||||
|
expect(errors()).not.toBe("");
|
||||||
|
expect(errors()).toContain("This token's balance is unknown");
|
||||||
|
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
|
||||||
|
expect(sendDisabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("while agreeing rows leave the scale usable", async () => {
|
||||||
|
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
|
||||||
|
const txInfo = await reviewSend(NOVEL, "1.5");
|
||||||
|
expect(txInfo.tokenDecimals).toBe(6);
|
||||||
|
expect(txInfo.tokenBalance).toBe("5.0");
|
||||||
|
confirmTx.show(txInfo);
|
||||||
|
await settle();
|
||||||
|
expect(text("confirm-balance")).toBe("5.0 NOVEL");
|
||||||
|
expect(errors()).toBe("");
|
||||||
|
expect(sendDisabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the confirmation screen tells an unknown balance from a zero one", () => {
|
describe("the confirmation screen tells an unknown balance from a zero one", () => {
|
||||||
function txInfo(tokenBalance) {
|
function txInfo(tokenBalance) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user