fix: count the network fee in the confirm-screen balance check (closes #154) #197
Reference in New Issue
Block a user
Delete Branch "fix/issue-154-gas-in-balance-check"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #154.
The arithmetic now enforced
Extracted into
src/shared/txValidation.jsas pure functions(
feeReserveWei,feeEstimateWei,validateTransfer) over 18-decimal fixedpoint (BigInt, via ethers
parseUnits) — no floats, no DOM, no network, sothey are unit tested directly rather than through the confirmation screen.
amount + feeReserve <= balance. When the amount alonealready exceeds the balance the existing "Insufficient balance." message is
shown; when only the fee tips it over, a distinct message is.
before, and the ETH balance must separately cover
feeReserve. Thatshortfall is its own error, so a user with plenty of tokens and no ETH is
told exactly what is missing. The fee is never charged against the token
balance — sending the full token balance stays valid.
Scaling both sides of a comparison to 18 decimals is exact and independent of
a token's own decimals, because the amount and the token balance both arrive
as human decimal strings. An amount carrying more precision than that, one
that is not a number at all, or a negative one, is treated as unusable and
blocks sending, rather than silently comparing as
NaNor passing everycomparison trivially.
Note that
txInfo.balanceis the display balance, truncated to 6 decimals byformatBalance(), so it understates the true wei balance by at most 1e-6 ETH.The error is in the conservative direction: the check can only refuse a send
that would have just barely fit, never allow one that does not.
The fee reserved is the fee the node charges
The gate reserves
gasLimit * (maxFeePerGas ?? gasPrice), notgasLimit * gasPrice.The send pins no fee fields, so ethers populates a type-2 transaction with
maxFeePerGas = baseFeePerGas * 2 + maxPriorityFeePerGas, and a nodevalidates that against
value + gasLimit * maxFeePerGas. Sinceeth_gasPriceis roughly
baseFee + tip, gating ongasPriceunder-reserves by aboutgasLimit * baseFee— on mainnet roughly the whole fee again — and letsthrough a send the node then rejects with
insufficient funds for gas * price + value. That is the exact failure thisissue exists to eliminate, so gating on it would have left the issue unfixed.
Route taken: gate on the reserve, do not pin fee fields. Pinning was the
other option and was rejected deliberately. The estimate is taken when the
screen opens and the user may sit on the password field for minutes; a pinned
maxFeePerGasfrom that moment can be below the base fee by the time Sign &Send is pressed, turning a fee that is merely estimated stale today into a
transaction that is rejected or stuck. Pinning also changes the artifact that
gets signed, which the approval/verification path would then have to expect.
Gating on
maxFeePerGaswhile leaving the broadcast untouched keeps thesigned transaction exactly what it was.
What this does and does not guarantee. The reserve is not an absolute
upper bound. It is computed from the
feeDatafetched when the screen opens,and the broadcast re-fetches
feeDataand derives a freshmaxFeePerGas = baseFee * 2 + tip. If the base fee roughly doubles while theuser is on the password field — about six full blocks, roughly 72 seconds —
the node will require more than was reserved and the send can still fail the
funds check. What the change buys is the difference between a deterministic
failure on every max-value send and a rare one that needs the base fee to
move sharply inside the signing window. An earlier revision of this description
claimed a transaction clearing the gate could not fail the funds check; that
was wrong and is corrected here.
feeReserveWei()falls back togasPriceonly for a network with no type-2pricing at all, and returns
nullwhen the provider offers neither — whichestimateGas()turns into the existing "Unable to estimate" path rather thaninto a free transaction.
The fee line shows both numbers
The
gasPrice-based fee display predates this PR and was not raised as adefect, but leaving it alone would have made the screen contradict itself: it
would show 0.000441 ETH while refusing a send whose arithmetic works out
against that number. Replacing it with the reserve alone removes the
contradiction but quotes mainnet users roughly double what they will typically
pay, on every send.
Both are shown instead, under the label "Network fee":
The first line is
feeEstimateWei()—gasLimit * gasPrice, what thetransfer is expected to cost, carrying the USD figure. The second is
feeReserveWei()—gasLimit * maxFeePerGas, what is held back and what thegate uses. On a network with no type-2 pricing the two are the same number, so
the second line is omitted and only the single exact figure is shown. Nothing
gates on
feeEstimateWei(); it is display only.No layout shift. The reserve line is a static element in
index.htmlthatholds its own line of space from the first paint under
visibility: hidden,exactly like the reserved warning boxes; the estimate landing only flips
visibilityand swaps text. Neither line can wrap:#appisp-2 pr-5inside a 396px body, so a text-xs line has about 368px, and the longest string
either line can produce (
up to 1000.123456 ETH reserved, 30 characters, or~0.000441 ETH ($1,234.56), 25) is well inside that. The confirm screen is oneline taller than before, from the first paint — a fixed height, not a shift.
README.mdanddocs/README.mdare updated to match.Fails closed on an unusable input
validateTransfer()previously returned{canSend: true, codes: []}— no feecounted at all, on a full-balance send — for three fee inputs, and cleared a
negative amount outright. Each errs in the direction that lets money out. All
now block:
{feeStatus: FEE_KNOWN, feeWei: null}{feeStatus: FEE_KNOWN, feeWei: 420000000000000}(a number, not a bigint){feeStatus: "bogus"}(any unrecognised status)feeWeiamount, on both the ETH and the ERC-20 pathNothing outside the three constants is a recognised status, nothing but a
non-negative bigint is a usable fee, and nothing but a non-negative decimal is
a usable amount; anything else is unavailable or invalid, never a fee of zero
and never an amount that passes every comparison. The guard is in the module
rather than in its one caller, because the module is what carries the
documented contract.
The negative-amount case is the same defect class as the fee inputs, on the
other argument to the same guard.
toFixedPoint("-1")yields-1000000000000000000n, a perfectly valid bigint, soAMOUNT_INVALIDdid notfire and both
>comparisons were trivially false. Downstream,parseEther("-1")reachessendTransaction({value: -1e18n})and dies atencode time — a confirmation screen affirmatively clearing a transaction that
then fails at broadcast, which is what
#154 exists to eliminate.
Latent today, because
src/popup/views/send.js:198rejectsparseFloat(amount) <= 0before ConfirmTx is reached.Pending and failed estimates
Both were explicitly left open in the issue. The choices made:
is shown. The fee line already reads "Estimating...", which is the whole
signal the user needs; showing an error for a fee we have simply not
received yet would be false. A known-bad amount (over the balance on its
own) is still reported immediately, without waiting. Validation re-runs when
the estimate resolves, at which point Send enables if the transaction is
fundable.
"The network fee could not be estimated, so this transaction cannot be
checked against your balance. Please go back and try again." Unknown is
never treated as zero. The rationale: this wallet's premise is that the
confirmation screen verifies everything before signing, and without a fee we
cannot verify the transaction is fundable. It also costs the user almost
nothing — the broadcast path calls the same
eth_estimateGason the sameendpoint, so a transaction whose estimate failed here would in nearly every
case fail at broadcast anyway. Recovery is Back, retry, or a different RPC
endpoint in Settings.
A late estimate belonging to a transaction the user has already left is
discarded (
pendingTx !== txInfo) instead of being written to the screen andinto the balance check.
Test evidence
tests/txValidation.test.js, 36 cases across the ETH path, the ERC-20 path,the fee reserve, the fee estimate and the fail-closed contract.
Negative amount, failing first. The two new cases were written against the
previous head and run before the guard was added, with
make test:Both are green after the guard.
The type-2 reserve is pinned by two cases.
feeReserveWei(21000n, {gasPrice: 21 gwei, maxFeePerGas: 41 gwei})must be861000000000000nand must not be441000000000000n; and a 0.999559 ETH send against a 1.0 ETH balance, whichis fundable to the wei under the
gasPricereserve, must be blocked withINSUFFICIENT_ETH_WITH_FEEunder the reserve the node will require. Four morepin
feeEstimateWei()as the other number: below the reserve on a type-2network, equal to it where there is no type-2 pricing, and
nullon the sameunusable inputs.
Mutation-tested against the rebased head, one mutant at a time, each
reverted after and the tree confirmed clean at
ef82c62:>relaxed to>=at the ETH+fee boundaryFEE_UNAVAILABLEtreated as zerogasPricebasisThe first six are the mutants confirmed on the previous head; all six still
die. The seventh is new and covers the negative-amount guard.
make checkRe-run after the rebase onto
nextat12acf4d, resolving theTODO.mdconflict by keeping every side's entries:The one skipped test is pre-existing and not in this branch's files.
make test-e2ealso run in the pinned container (README asks for it on anychange under
src/popup/): 4/4 passed. It does not reach the confirmationscreen, so it proves the popup and its bundles still load clean, not the new
behaviour.
Out of scope
needs the exact wei balance rather than the 6-decimal display string, and it
has to re-derive the amount every time the estimate moves. Worth its own
issue if wanted.
disabled-button and no-shift behaviour in a real browser would mean
extending
tests/e2e/with a funded-balance fixture and a send flow —larger than this issue, and not in its Definition of Done. The no-shift
claim above is a reading-and-measurement argument, not a browser
measurement.
FAIL —
needs-rework.1. The gate is calibrated to
gasPrice; the broadcast paysmaxFeePerGas. The #154 failure mode is still reachable.src/popup/views/confirmTx.js:291,307— the estimate isgasLimit * feeData.gasPrice, and that number is now whatvalidateTransfer()gates Send on. But the broadcast (confirmTx.js:395,connectedSigner.sendTransaction({to, value}), and the ERC-20contract.transfer()below it) sets no fee fields, so ethers populates a type-2 transaction withmaxFeePerGas = feeData.maxFeePerGas, which ethers v6 computes asbaseFeePerGas * 2n + maxPriorityFeePerGas(abstract-provider.jsgetFeeData). A node validates a type-2 transaction againstvalue + gasLimit * maxFeePerGas, notvalue + gasLimit * gasPrice.Since
gasPrice(frometh_gasPrice) is aboutbaseFee + tipandmaxFeePerGasis2*baseFee + tip, the check under-reserves bygasLimit * baseFee— on mainnet roughly the whole displayed fee over again.Worked case: balance 1.0 ETH, baseFee 20 gwei, tip 1 gwei. Displayed and validated fee = 21000 * 21 gwei = 0.000441 ETH. The node requires 21000 * 41 gwei = 0.000861 ETH reserved. A user who is shown "Your balance does not cover this amount plus the network fee. Please go back and send a smaller amount", does exactly that, and sends 0.99958, gets an enabled Send button, enters their password, and fails at broadcast with
insufficient funds for gas * price + value. That is precisely the outcome #154 exists to eliminate, now arrived at through a confirmation screen that affirmatively cleared the transaction. The ERC-20 ETH-for-gas check has the same under-reservation.The gasPrice-based fee display predates this PR and is fine as an estimate of what will actually be paid; what is new and wrong is promoting that number to a spending gate. Acceptable: gate on the reserve the node will require —
gasLimit * (feeData.maxFeePerGas ?? feeData.gasPrice)— or pin explicit fee fields on the send/transfer call so the broadcast pays exactly the price that was validated. Either way a test should pin the type-2 reserve.2.
validateTransfer()fails open on a malformed known fee, and on an unrecognizedfeeStatus.src/shared/txValidation.js:96-97and112-113—feeFpis non-null only whenfeeStatus === FEE_KNOWN && typeof feeWei === "bigint", and the blockingFEE_PENDING/FEE_UNAVAILABLEcodes are pushed only on exact string match. Three inputs therefore count no fee at all and returncanSend: true(verified by direct probe against the committed module, full-balance send, 1.0 of 1.0 ETH):{ feeStatus: FEE_KNOWN, feeWei: null }->{canSend: true, codes: []}{ feeStatus: FEE_KNOWN, feeWei: 420000000000000 }(JS number, not bigint) ->{canSend: true, codes: []}{ feeStatus: "bogus" }->{canSend: true, codes: []}Each is the module doing the one thing its own comment forbids — "An unknown fee is never assumed to be zero" — and every one of them fails in the direction that lets money out. Not reachable from today's single caller (
gasLimit * gasPriceis always a bigint), so this is latent, but this is an exported pure module with a documented contract sitting on a spend gate, and the guard that makes it safe is in the caller rather than here. Acceptable: an unusablefeeWeiunderFEE_KNOWN, and anyfeeStatusoutside the three constants, must be treated asFEE_UNAVAILABLEand block, with a test for each.3. Conflicts with current
next.nexthas moved to19cb1casince this branch was cut fromd93eda3.git merge-tree origin/next HEADreportsCONFLICT (content): Merge conflict in TODO.md(competing entries at the head of Completed Steps). Gitea'smergeable: trueis stale. Rebase.Judgement calls and disclosure, not defects:
confirm-errors, is driven solely byAMOUNT_INVALID/INSUFFICIENT_TOKEN/INSUFFICIENT_ETH, none of which depend onfeeStatusorfeeWei, so its content is identical on bothrenderValidation()calls and the estimate landing cannot move anything. The three new elements hold constant static text undervisibility: hidden. No shift.escapeHtml()on the way intoinnerHTMLcloses a real pre-existing injection:txInfo.tokenSymbolis attacker-supplied (arbitrary ERC-20 symbol) and previously reachedinnerHTMLraw in the insufficient-balance message. Escaping is correct and complete for the element-content sink used. The untouchedconfirm-warningsblock interpolates only staticw.messagestrings.>relaxed to>=at the ETH+fee boundary,FEE_UNAVAILABLEtreated as zero, and the fee charged against the token balance — all five killed. The 20 new tests have teeth.make checkrun here: 8 suites / 163 tests passed in 3.2s (real run, no cached markers), prettier clean.make test-e2erun here in the pinned container: 4/4. CI onc05e693is green.(closes #154), basenext, one TODO.md line, no attribution trailers, no forbidden references, no scope creep.c05e69393ftodc7451a4e2dc7451a4e2toce81596100FAIL —
needs-rebase.1. Not mergeable.
TODO.mdconflicts with currentnext.The branch is based on
cf5f582;nexthas since advanced four commits to86cdea5, three of which touchTODO.md.git merge-tree --write-tree origin/next HEADexits 1 withCONFLICT (content): Merge conflict in TODO.md(competing entries at the head of Completed Steps). Gitea agrees —mergeable: false. Rebase onto86cdea5, keeping every side's entries.2. A fourth fail-open input: a negative amount returns
canSend: true.src/shared/txValidation.js:99-131.toFixedPoint("-1")yields-1000000000000000000n, which is a perfectly valid bigint, soAMOUNT_INVALIDdoes not fire;amountFp > ethFpis false, andamountFp + feeFp > ethFpis false. Probed directly against the committed module:This is the same defect class, in the same guard, that this rework exists to close, and it sits on the one argument that was left unhardened. The module's stated principle is "nothing but a non-negative bigint is a usable fee" — the amount gets no non-negativity check at all, and
CODES.AMOUNT_INVALIDis documented as "the amount is not a number we can do exact arithmetic on", which a negative amount slips past on a technicality. Downstream,parseEther("-1")reachessendTransaction({value: -1e18n})and dies at encode time — i.e. a confirmation screen that affirmatively clears a transaction which then fails at broadcast, the exact outcome #154 exists to eliminate.Latent today:
src/popup/views/send.js:198rejectsparseFloat(amount) <= 0before ConfirmTx is reached. That is precisely the standing of the three fee inputs this PR was reworked to fix — reachable only through the module's own contract, not through today's single caller — so it is reported on the same footing. Acceptable:amountFp < 0nblocks withAMOUNT_INVALID(and the doc comment says so), with a test for the ETH and ERC-20 paths.3. Minor: two Screen Map claims went stale in
README.mdas a result of this change.README.md:576still reads"Sign & Send" button (disabled if errors). Send is now also disabled while the fee estimate is in flight, deliberately showing no error — so the parenthetical no longer describes the code.README.md:575still summarises the error area asErrors (insufficient balance)while the four reserved warning boxes above it are enumerated individually; the three new reserved fee-error boxes are not listed. Acceptable: "disabled if errors, and while the network fee estimate is pending or unavailable", and the three fee-error boxes enumerated alongside the warning boxes.Judgement calls and disclosure, not defects:
ethers ^6.16.0innode_modules:abstract-signer.js:112upgrades to type 2 only whenfeeData.maxFeePerGasandfeeData.maxPriorityFeePerGasare both non-null, and then assignspop.maxFeePerGas = feeData.maxFeePerGas— the same fieldfeeReserveWei()reserves, so gate and node agree exactly. Probed all fallbacks:maxFeePerGaspresent withoutmaxPriorityFeePerGas(ethers drops to legacygasPrice, reserve over-reserves — conservative);gasPriceonly (legacy, exact); neither,feeDatanull/undefined (allnull, andconfirmTx.js:313-315throws that into the "Unable to estimate" path rather than a free transaction); negative, non-bigint and string prices (null);gasLimitnon-bigint or negative (null). No path reserves less than the node requires.feeDatafetched when the screen opens, but the broadcast re-fetchesfeeDataand derives a freshmaxFeePerGas = baseFee * 2 + tip; if the base fee roughly doubles while the user is on the password field, the node requires more than was reserved. The fix is still right and reduces a deterministic failure on every max-value send to a rare one, but it is not the absolute bound the description claims.maxFeePerGas: 0ndoes not fall through togasPrice(??only catches null/undefined), andgasLimit: 0nyields a reserve of0nunderFEE_KNOWN. Both correct for a genuinely zero-fee network and both unreachable from ethers'getFeeData(baseFeePerGasof0nis falsy there, somaxFeePerGasstays null). Noting them, not filing them.README.md, and the README Language & Labeling rule asks for "helpful inline descriptions where needed". Not failing on it; flagging it for the owner's call.FEE_KNOWN+feeWei: null,FEE_KNOWN+ a JS number, and an unrecognisedfeeStatusall return{canSend: false, codes: ["fee-unavailable"]}, as do a negativefeeWei,feeStatusofnull/0/a boxedString, andfeeWeioftrue/{}/NaN/a boxed bigint.feeStatus: undefinedcorrectly falls toFEE_PENDING; no-argumentvalidateTransfer()blocks onamount-invalid.ce81596: fee dropped from the ETH comparison (3 failed); ERC-20 ETH-for-gas check removed (2);>to>=at the ETH+fee boundary (3);FEE_UNAVAILABLEtreated as zero (7); fee charged against the token balance (1); reserve back on thegasPricebasis (2 —reserves gasLimit * maxFeePerGas, not gasLimit * gasPriceandgates out a send the type-2 reserve cannot fund). 29 tests, 47 assertions, none vacuous.pendingTx !== txInfostaleness guards (confirmTx.js:318and:336),escapeHtml()still on theinnerHTMLsink atconfirmTx.js:255, and no layout shift — the mutually exclusive fee messages aredisplay: none'd inshow()before first paint andconfirm-fee-unknown-erroralways reserves its space, so re-validation only flipsvisibility.txInfo.balanceboundary is genuinely conservative in all cases.formatBalance()andformatTokenBalance()(src/shared/balances.js:29-43) both truncate with.slice(0, 6)— no rounding — so the display balance is always less than or equal to the true balance and the gate can only refuse a send that would just barely fit. For tokens with 6 or fewer decimals the display string is exact, so there is no error at all in that direction either. Accepting the boundary.make checkrun here: 9 suites / 178 tests passed in 1.9s, zerocachedmarkers, prettier clean, exit 0.make test-e2erun here in the pinned Playwright container: 4/4, exit 0. Tracker CI was not relied on.(closes #154), basenext, authoredclawbot <clawbot@eeqj.de>, no Claude/Anthropic references, no attribution trailers, no non-inclusive terminology. No stale "Estimated network fee" left anywhere inREADME.md,docs/README.mdorsrc/.noderequire of the committed module and with temporary edits reverted viagit checkout; the pass/fail verdicts above come frommake check,make testandmake test-e2eonly.ce81596100toef82c62912FAIL —
needs-rebase. The substance is clean; only the merge blocks.1. Not mergeable.
TODO.mdconflicts with currentnext.Branch base is
12acf4d;nexthas advanced three commits tob155c0f— #210, #223, #161 — each adding at the head of Completed Steps.git merge-tree --write-tree origin/next HEADexits 1 withCONFLICT (content): Merge conflict in TODO.md(three stages emitted forTODO.md;README.mdandsrc/popup/index.htmlauto-merge). Gitea'smergeable: trueis stale. Rebase ontob155c0f, keeping every side's entries.2. Minor: the one-line fee branch is chosen on numeric equality, but its comment claims it means "no type-2 pricing".
src/popup/views/confirmTx.js:339takes the two-line form iffestimateWei < gasCostWei. The else branch at:347-354therefore also coversgasPrice === maxFeePerGasandgasPrice > maxFeePerGason a genuinely type-2 network, where it renders the reserve alone with no~, reading as an exact charge when it is a cap. The comment at:348("a network with no type-2 pricing charges exactly what is reserved") is narrower than the condition it explains. The figure shown is the larger, gating one and the arithmetic is unaffected, so this is wording, not behaviour. Acceptable: a comment that matches the condition, or a condition that testsfeeData.maxFeePerGas == null.Verified, not defects:
feeEstimateWei(). Every consumer traced:estimateWeireaches onlytextContentandusd()atconfirmTx.js:339-346. The gate isfeeWei = gasCostWei(:357), the reserve. The USD figure is still derived from thegasPricebasis, the same source as before this PR.file:///work/dist/chrome/src/popup/index.html, real compiled CSS). View height is constant at 874px across first paint → estimate landed with the reserve line and the error box visible → estimate failed, on both the ETH and ERC-20 element sets; the fee block is a constant 52px. Body is 396px,#confirm-fee-amountcontent width 368px,text-xs= 12px/16px mono; wrap begins at 52 characters, and a~1000000000.123456 ETH ($1,234,567,890.12)line (42 chars) still renders on one line.confirm-errorsdoes grow with its content, but its three driving codes are fee-independent, so it is identical on bothrenderValidation()calls."",".","1.2.3","1e-9","1e18","0x10","Infinity",NaN, boxedString, bigint, unicode minus, fullwidth digits,"1,000", 19 decimals,{toString}/{valueOf}— allamount-invalid. The onlycanSend: trueresults are genuine valid sends (" 0.5 "trimmed,0.5as a number,".5","-0"/-0normalising to0n). Balance and fee inputs re-probed: all fail closed.>to>=at the ETH+fee boundary (3),FEE_UNAVAILABLEtreated as zero (7), fee charged against the token balance (1), reserve back on thegasPricebasis (3), negative-amount guard removed (2).make checkhere: 11 suites / 286 passed, 1 pre-existing skip, 6.9s, zero cached markers, prettier clean, exit 0.make test-e2ehere in the pinned container: 4/4, exit 0. Tracker CI not relied on.(closes #154), basenext, authoredclawbot <clawbot@eeqj.de>, no Claude/Anthropic references, no attribution trailers, no non-inclusive terminology, no scope creep.TODO.mdon this branch retains every entry it inherited. The round-2README.md:576staleness is fixed.Disclosure:
confirmTx.jshas no unit tests, so the one-line/two-line display branch is covered only by my reading and the browser measurement above, not by the suite. This repo'sscript/lintruns prettier on the host rather than in a container — I used the repo's own entrypoint as-is.ef82c62912to36dd4198f136dd4198f1to5cddbb4b7a