Skip to content

feat(gas): reserve gas fee and storage deposit from a GNOT MAX - #998

Merged
jinoosss merged 21 commits into
developfrom
GSW-3000-gnot-max-reserve
Sep 29, 2026
Merged

jinoosss merged 21 commits into
developfrom
GSW-3000-gnot-max-reserve

Conversation

@jinoosss

@jinoosss jinoosss commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Summary

GNOT pays for its own transaction, so the full balance is never spendable. The MAX button did not account for that: on the swap card it filled in the entire balance, which cannot be broadcast, and elsewhere it subtracted a flat 1.000001 GNOT.

The reserve is measured by simulating the transaction the action would actually broadcast, at an amount that could actually be broadcast. Everything below was checked against dev.rpc.gnoswap.io with three funded accounts.

Scope is deliberately narrow: this runs only when MAX is pressed on a GNOT input, and only sets the GNOT amount. Nothing recomputes when tokens or the other side change. It does not reintroduce the per-keystroke or pre-broadcast simulation that #986 removed.

What the chain actually charges

Measured over the same swap at a range of amounts (live gas price 1ugnot/1000gas):

Pair Amount gasUsed Storage deposit Fee the wallet charges
GNOT→GNS 1 GNOT 140,012,248 0 0.154 GNOT
GNOT→GNS 100 140,257,443 0 0.154
GNOT→GNS 10,000 233,303,427 0 0.257
GNOT→GNS 1,000,000 1,233,144,927 0 1.356
GNOT→USDC 1 146,083,015 0 0.161
GNOT→USDC 1,000,000 179,162,563 0 0.197

Two things follow, and both contradicted the first pass at this:

  • Gas dominates a swap and moves 8.8x with the amount, while the storage deposit is zero. A flat reserve is wrong at both ends: 1 GNOT under-reserves every heavy GNOT→GNS swap and over-reserves a light one ~5x. At extreme amounts a swap crosses enough ticks to lock a deposit in r/gnoswap/staker too, so the deposit is not always zero.
  • The fee is not the flat DEFAULT_GAS_FEE the app passes. Real transactions on chain carry gas-wanted = gasUsed x 1.1 with the fee priced at the live gas price — the wallet re-prices rather than taking the offered figure. Worth knowing separately: the app's own pair (gasWanted 2e9, fee 1 GNOT) is a ratio of 0.0005 ugnot/gas against a floor of 0.001, so it would be rejected at CheckTx if a wallet ever did take it literally.

Three bugs the live check caught

  • The simulate query was double-encoded. tm2-js-client's estimateGas puts the base64 text of the transaction in data, the RPC layer base64s it again, and the node answers TxDecodeError. simulateTx had copied that, so every estimate failed and silently fell back to the flat reserve. It passes raw bytes now.
  • The route status was never set. getRoutes returns the router's body and leaves status undefined — useGetRoutes is what derives it, from whether the quotes add up to 100. The max-amount factory tested for "SUCCESS", so a swap MAX never got past it. It applies the same quote rule now.
  • Approvals are MsgRun unless the token carries a GRC20 method spec, and MsgRun makes the chain demand a real signature to simulate (txCarriesCode). Attaching the route metadata — as the submit path already does — keeps every message a MsgCall, which simulates unsigned. Without it a swap cannot be estimated at all without a wallet popup.

Changes

Reading both costs out of a simulation

  • GnoProvider.simulateTx keeps the events estimateGas discards, so the storage deposit is visible next to the gas.
  • storage-deposit-event.ts decodes the amino-encoded /tm.StorageDepositEvent emitted per realm and sums fee_delta. StorageUnlockEvent is deliberately ignored, so a transaction that both grows and releases state over-reserves rather than under-reserves.

One message set per action, owned by the repository

Each repository exposes the messages its send path builds, and the send path calls the same builder, so what gets simulated and what gets broadcast cannot drift apart:

  • swapRouterRepository.makeExactInSwapRouteMessages / makeExactOutSwapRouteMessages
  • positionRepository.makeIncreaseLiquidityMessages
  • poolRepository.makeAddLiquidityMessages
  • walletRepository.makeTransferGNOTTokenMessages

This is the whole transaction, not just the call: token approvals, the wrapping deposit for a native input, and the action itself.

Where the policy lives

native-amount-reserve.ts holds it as pure functions, with the process numbered in its header and the steps named after it. The service keeps only the I/O — resolve the gas price and the account once, measure, narrow, report — and takes its probe amounts from the policy rather than recomputing them.

Step Function Decides
1 planProbes which amounts to measure at, and the fee each can afford
2 measure (service) the first planned step the balance can actually carry
3 gasFeeFloor + priceReserve the reserve one measurement implies
4 bisectToward where to retreat when an amount turns out unaffordable
5 cappedSpendable the answer, under both the measured and the priced bound
6 describeWithheld the breakdown of what is really held back

Every conditional is a named predicate with its own cases in native-amount-reserve.spec.ts, so the branch table is readable without tracing the flow.

The process — TransactionGasService.estimateMaxNativeAmount

  1. Probe at balance - headroom, where the headroom is a tenth of the balance capped at 5 GNOT. Simulating at the maximum would fail on the very costs being measured, so the probe holds back enough to be an amount that could have been broadcast. Proportional rather than flat because both costs fall with the amount: a fixed cap would probe a small balance at a fraction of its ceiling. If even that cannot be afforded — an extreme amount locking several GNOT of deposit — it retries at the full proportional headroom.

    A balance under the offered fee has no ceiling to probe near: holding back a tenth still leaves nothing to pay the fee with. It is probed at a hundredth of itself instead, leaving almost all of it free to cover the costs being measured. The reading still stands for the whole balance — gas barely moves across small amounts, with a hundredfold rise from 1 to 100 GNOT costing 0.17% more. The flat-fee floor is dropped there too: a wallet charging it could not spend such a balance at all, so holding that much back would only ever return nothing.

    Balance Before Spendable Fee measured Probed at
    0.1 GNOT 0 0 — (a hundredth is not routable) 1,000 ugnot
    0.3 0 0.084 0.206 3,000
    0.5 0 0.282 0.208 5,000
    0.9 0 0.684 0.206 9,000
    1.0 0 0.781 0.209 10,000
    2.0 0.990 0.990 flat floor 1,800,000
  2. Price the reserve: gasUsed x 1.5 x live gas price, floored at the flat fee a send path offers, plus the measured deposit padded 1.5x, plus a small buffer. The margin clears the wallet's 1.1 multiplier and the gas the amount still gains above the probe.

  3. Verify at the resulting amount. A failure bisects between the last amount that worked and the one that did not, rather than trimming a percentage — a 0.5% trim on a 1.3M GNOT balance threw away 6,611 GNOT to grope for costs of three.

  4. Cap the answer by both bounds. The probe proves its own amount affordable, but for a light action the reserve can land above the headroom, and the amount has to respect that too.

  5. Fall back to the offered fee if the node cannot be reached, the action cannot build messages, or no probe succeeds.

MAX buttons

useMaxNativeAmount is the single entry point for all four GNOT MAX buttons, called on click only. Non-GNOT tokens keep returning the full balance.

MAX Reserve source
Swap (SwapCardContent, TokenSwap) Simulated. Routes are looked up for the candidate amount, since how many pools the swap crosses decides the message set, the gas and the storage.
Increase liquidity (IncreaseAmount) Simulated. The paired amount is derived exactly as the inputs derive it, so the simulated deposit is the pair the user would send.
GNOT send (AssetSendModal) Simulated via the wallet repository.
Pool add / reposition / incentivize (TokenAmountInput) Fee only — see below.

Still unsimulated

Pool add, reposition and incentivize assemble their request outside the component that owns the amount input: tick inversion and the create-pool-vs-add branch for pool add, the start/end window for incentivize. A factory at the input would have to duplicate that derivation, and a wrong duplicate is worse than no measurement. makeAddLiquidityMessages is in place; what it needs is the tick derivation lifted out of use-pool-add-liquidity-confirm-modal so both paths share it. Better as its own change. MAX no longer fills an unspendable full balance for these regardless.

Verification

Live, against dev.rpc.gnoswap.io:

Account Balance Pair Amount chosen Withheld Simulates with the reserved fee
g17290… 1,321,925.070595 GNOT→GNS 1,321,921.577236 3.493 yes
g17290… GNOT→USDC 1,321,922.065595 3.005 yes
g1ffzx… 499,587.429215 GNOT→GNS 499,584.162130 3.267 yes
g1ffzx… GNOT→USDC 499,584.424215 3.005 yes
g169d3… 259,201.496687 GNOT→GNS 259,198.370017 3.127 yes
g169d3… GNOT→USDC 259,198.491687 3.005 yes

Across balances from 2 GNOT to 1.3M GNOT the withheld amount stays a few GNOT and amount + reserve equals the balance.

  • jest --ci: 177 suites / 853 tests, including 45 new (8 amino decoder, 26 reserve policy, 15 gas service, 2 swap factory).
  • tsc --noEmit: 28 errors, matching develop (spec fixtures and svg module declarations).
  • next lint --dir src: 3 errors, matching develop.

Chain behaviour cross-checked against gnolang/gno: tm2/pkg/sdk/auth/ante.go (the offered fee is deducted in full; the mempool floor and the simulate gate), gno.land/pkg/sdk/vm/keeper.go (processStorageDeposit, StorageDepositEvent), gno.land/pkg/gnoland/app.go (txCarriesCode), tm2/pkg/sdk/baseapp.go (simulate executes messages, so the deposit is computed).

Test plan

  1. Swap with GNOT in → MAX, on a heavy pair (GNS) and a light one (USDC). Both should broadcast; the heavy one should withhold visibly more.
  2. Increase liquidity with GNOT on one side → MAX on that side, once in a range that initializes new ticks and once in one that does not.
  3. Portfolio → Send → GNOT → MAX, then send.
  4. A GRC20 token → MAX should still fill the full balance.
  5. Stop the RPC node → MAX falls back to the fee-only reserve rather than hanging.
  6. A balance just above 1 GNOT should still measure; at or under the fee it should offer nothing.

Open question — the flat-fee floor

Above 1 GNOT the reserve is floored at the flat DEFAULT_GAS_FEE, in case a wallet takes the offered figure at face value. That floor now shows as a cliff:

Balance Spendable Withheld
1.0 GNOT 0.781 0.219 (measured)
2.0 GNOT 0.990 1.010 (floor)

Doubling the balance from 1 to 2 GNOT only buys 0.209 GNOT of spendable amount, because the floor jumps in. The measured fee for either is ~0.21 GNOT.

The floor also guards a case that cannot happen: a wallet taking the app's figures literally would broadcast gasWanted 2e9 with a 1 GNOT fee, a ratio of 0.0005 ugnot/gas against a floor of 0.001, and the chain would reject it at CheckTx. So that path is already unusable regardless of what MAX offers.

Dropping the floor makes the curve continuous and would withhold ~0.21 GNOT instead of 1.01 on light swaps. Left in for now since it is a policy call about wallet support — one line to remove.

Summary by CodeRabbit

  • New Features
    • MAX amounts account for estimated gas fees and storage deposits when sending native tokens, swapping, or adding liquidity.
    • Transactions can be simulated to estimate gas usage and storage costs before execution.
    • Swap, transfer, and liquidity transactions can be prepared for estimation before submission.
  • Bug Fixes
    • Improved MAX amount handling for low balances, unavailable swap routes, and failed simulations.
  • Style
    • MAX buttons have larger clickable areas, and amount fields visibly indicate when spendable balances are being calculated.

GNOT pays for its own transaction, so the full balance was never
spendable. The MAX button ignored that for a swap, and covered it with a
flat 1 GNOT elsewhere, which is both too much for a plain send and
possibly too little for an action that grows realm storage.

Both costs are now measured by simulating the action itself:

- GnoProvider.simulateTx keeps the events estimateGas discards, so the
  storage deposit is visible next to the gas. The deposit is read out of
  the amino-encoded StorageDepositEvent of each realm the transaction
  grows.
- TransactionGasService.estimateMaxNativeAmount turns that into the
  amount to fill in. Neither cost is constant in the amount — a swap can
  cross more pools, a mint can initialize more ticks — so one simulation
  of an arbitrary amount is not enough. It probes at a conservative
  amount to measure, then simulates the resulting amount to prove it is
  affordable, and shrinks rather than overshooting when that fails.
- The probe runs with no fee, since the ante handler skips the deduction
  for a zero fee and does not enforce the gas price floor while
  simulating. Otherwise a balance almost entirely committed to the
  amount could not be probed at all.
- useMaxNativeAmount is the single entry point for the MAX buttons, and
  falls back to the previous flat reserve when there is nothing to
  simulate or the node cannot be reached.

Wired for the GNOT send; swap and the pool inputs take a messages
factory so they can opt in once their call sites can build one.
@vercel

vercel Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
gnoswap-interface Ready Ready Preview Sep 29, 2026 7:54am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 4b89c0e1-b4d4-4d17-8dc1-044bbcba8fea

📥 Commits

Reviewing files that changed from the base of the PR and between c67505a and 206eb2e.

📒 Files selected for processing (10)
  • packages/web/src/components/common/token-amount-input/TokenAmountInput.styles.ts
  • packages/web/src/components/common/token-amount-input/TokenAmountInput.tsx
  • packages/web/src/hooks/gas/use-max-native-amount.spec.tsx
  • packages/web/src/hooks/gas/use-max-native-amount.ts
  • packages/web/src/layouts/portfolio/components/asset-send-modal/AssetSendModal.styles.ts
  • packages/web/src/layouts/portfolio/components/asset-send-modal/AssetSendModal.tsx
  • packages/web/src/layouts/swap/components/swap-card/swap-card-content/SwapCardContent.styles.ts
  • packages/web/src/layouts/swap/components/swap-card/swap-card-content/SwapCardContent.tsx
  • packages/web/src/layouts/token-detail/components/token-swap/TokenSwap.styles.ts
  • packages/web/src/layouts/token-detail/components/token-swap/TokenSwap.tsx

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The PR adds transaction simulation, storage-deposit decoding, native amount estimation, message-building repository APIs, and asynchronous MAX handling for transfers, swaps, and liquidity changes.

Changes

Native amount estimation

Layer / File(s) Summary
Transaction simulation and storage-deposit decoding
packages/web/src/common/clients/gno-provider/...
GnoProvider simulates transactions and returns gas usage with summed storage deposits. Helpers decode storage-deposit event payloads and ignore unrelated or undecodable events.
Action transaction-message builders
packages/web/src/repositories/{wallet,swap-router,pool,position}/...
Repositories expose message-building methods for transfers, swaps, liquidity additions, and liquidity increases. Existing send methods delegate message construction to these methods.
Native amount reserve estimation
packages/web/src/services/transaction-gas/..., packages/web/src/providers/..., packages/web/src/utils/transaction-utils.ts, packages/web/src/common/values/data-constant.ts
The gas service probes and verifies candidate amounts, calculates gas and storage reserves, and returns fallback values when simulation is unavailable.
MAX hook and action integration
packages/web/src/hooks/gas/..., packages/web/src/components/..., packages/web/src/layouts/..., packages/web/src/hooks/common/..., packages/web/src/hooks/pool/..., packages/web/src/hooks/swap/...
MAX controls request asynchronous spendable amounts and pass action-specific message builders for transfers, swaps, and liquidity changes. MAX buttons are disabled while estimates load. Supporting tests cover decoding, reserve calculations, estimation, and asynchronous UI updates.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MAXControl
  participant UseMaxNativeAmount
  participant TransactionGasServiceImpl
  participant GnoProvider
  MAXControl->>UseMaxNativeAmount: getMaxAmount(balance and messages)
  UseMaxNativeAmount->>TransactionGasServiceImpl: estimateMaxNativeAmount(request)
  TransactionGasServiceImpl->>GnoProvider: simulateTx(candidate transaction)
  GnoProvider-->>TransactionGasServiceImpl: gasUsed and storageDeposit
  TransactionGasServiceImpl-->>UseMaxNativeAmount: estimated amount and reserve
  UseMaxNativeAmount-->>MAXControl: spendable amount
Loading

Merge Risk: 🔵 Low · up to 206eb

GNOT MAX can calculate a slightly inaccurate reserve for exceptionally large storage deposits. Deployed limits and practical reachability remain unconfirmed; this is a bounded risk to accept or address before merging.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 206eb

The new estimate can improve spendable MAX amounts, but a pending swap estimate may apply after the user changes the output amount, replacing that newer intent. MAX does not submit a transaction; swap confirmation remains separate.

Retained concerns

  • Medium · reliability · inferred: A pending GNOT MAX estimate can be accepted after an output-amount edit and reset the swap from EXACT_OUT to EXACT_IN. This is an inferred timing-dependent loss of newer draft intent, not an observed bypass of confirmation.
Security review details

Security Blast Radius

  • inferred — The immediate influence of a simulation answer is the connected wallet's draft MAX amount in affected controls, not independent authority over another account or direct broadcast.

Security Findings and Attack Paths

  • inferred — No direct unauthorized-signing path is established by the inspected MAX flow. RPC-influenced estimates can affect the proposed draft amount, but the MAX handler does not submit it.

Trust Boundaries and Controls

  • observed — The hook binds simulated results to the latest request, connected signer, token path, input amount, and declared dependencies; swap messages are built by the application's repository-owned factory.

Resilience and Maintainability Implications

  • observed — Successful estimates retain a priced reserve and buffer; failed estimation falls back to a fixed reserve. Storage events are converted to JavaScript numbers before reserve pricing, so exact large-value behavior and deployed deposit limits remain unverified by this review.

Hardening Proposals

  • proposed — Bind pending swap MAX results to output-amount and direction changes, or invalidate the request whenever the transaction-message inputs change, before applying an estimate to shared swap state.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.52% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 53 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: reserving gas fees and storage deposits when calculating the GNOT MAX amount.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 99741960-4e33-4a16-9b1a-06d453a448ad

📥 Commits

Reviewing files that changed from the base of the PR and between 2ae8659 and 55d9c3c.

📒 Files selected for processing (20)
  • packages/web/src/common/clients/gno-provider/gno-provider.ts
  • packages/web/src/common/clients/gno-provider/methods/index.ts
  • packages/web/src/common/clients/gno-provider/methods/storage-deposit-event.spec.ts
  • packages/web/src/common/clients/gno-provider/methods/storage-deposit-event.ts
  • packages/web/src/common/values/data-constant.ts
  • packages/web/src/components/common/token-amount-input/TokenAmountInput.spec.tsx
  • packages/web/src/components/common/token-amount-input/TokenAmountInput.tsx
  • packages/web/src/hooks/common/use-gnoswap-context.tsx
  • packages/web/src/hooks/gas/index.ts
  • packages/web/src/hooks/gas/use-max-native-amount.ts
  • packages/web/src/layouts/portfolio/components/asset-send-modal/AssetSendModal.spec.tsx
  • packages/web/src/layouts/portfolio/components/asset-send-modal/AssetSendModal.tsx
  • packages/web/src/layouts/swap/components/swap-card/swap-card-content/SwapCardContent.spec.tsx
  • packages/web/src/layouts/swap/components/swap-card/swap-card-content/SwapCardContent.tsx
  • packages/web/src/layouts/token-detail/components/token-swap/TokenSwap.tsx
  • packages/web/src/providers/gnoswap-service-provider/GnoswapServiceProvider.tsx
  • packages/web/src/services/transaction-gas/transaction-gas-service-impl.spec.ts
  • packages/web/src/services/transaction-gas/transaction-gas-service-impl.ts
  • packages/web/src/services/transaction-gas/transaction-gas-service.ts
  • packages/web/src/utils/transaction-utils.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

The reserve is only meaningful if it is measured against the messages the
action would actually broadcast. Swap and the pool inputs were falling
back to a flat estimate, so nothing was being measured for them.

Each repository now owns the message set for its action and exposes it,
with the send path calling the same builder. Simulation and broadcast
cannot drift apart:

- swapRouterRepository.makeExactInSwapRouteMessages /
  makeExactOutSwapRouteMessages
- positionRepository.makeIncreaseLiquidityMessages
- poolRepository.makeAddLiquidityMessages
- walletRepository.makeTransferGNOTTokenMessages

The message set is the whole transaction, not just the call: token
approvals, the wrapping deposit for a native input, and the action
itself.

Wired to those builders:

- Swap. Routes are looked up for the candidate amount instead of reusing
  the ones on screen, since how many pools the swap crosses decides both
  the message set and the storage it touches.
- Increase liquidity. The paired amount is derived exactly as the inputs
  derive it, so the simulated deposit is the pair the user would send —
  which decides how many ticks get initialized.
- GNOT send, now through the repository rather than the message module.

Pool add, reposition and incentivize still use the flat reserve. Their
requests are assembled outside the component that owns the amount input
— tick inversion and the create-pool branch for pool add, the start/end
window for incentivize — so a factory there would duplicate that
derivation and could simulate a transaction the user is not sending.
Two mistakes in the reserve.

The gas fee was priced from the simulation, as gasUsed x margin x gas
price. That is not what the wallet pays. Every send path offers a flat
DEFAULT_GAS_FEE of 1 GNOT and the chain deducts the offered fee in full,
so a 1M-gas call was reserving ~1,200 ugnot against a 1,000,000 ugnot
charge — off by a factor of ~833, leaving MAX short by almost the whole
fee. The fee is a known constant, not something to measure: it is now
reserved exactly as offered, derived the same way
generateSendTransactionParams derives it. Callers that offer a different
fee can pass it.

The probe leaned on a zero fee to survive being near the maximum, which
made it a transaction nobody would broadcast. It now holds back the fee
plus room for a realistic deposit (1 GNOT, 10 KB of state at the default
storage price) and simulates at the fee and ceiling the real transaction
would carry, so the measurement comes from something executable.

What is left to simulate is the storage deposit, which is the part that
genuinely varies with the amount. It is read from the per-realm
StorageDepositEvent and padded, and the result is still verified once the
final amount is known.
Both costs fall with the amount, so a flat 1 GNOT of probe headroom only
suited a large balance. Below ~2 GNOT the probe amount came out negative
and the deposit was never measured at all, and just above that the probe
ran at a third of what was spendable — measuring a deposit nothing like
the one the final amount incurs.

The headroom is now a share of what is spendable, capped at the flat
figure, so the probe sits within a tenth of the ceiling across the range:
a 1.5 GNOT balance probes at 0.441 rather than being skipped, a 2.5 GNOT
balance at 1.341 rather than 0.49, and a large balance still probes just
under its ceiling where the cap applies.

Only a balance the fee alone exhausts now goes unprobed.
…hain

Checked against dev.rpc.gnoswap.io with three funded accounts, and three
things were wrong.

The simulate query was double-encoded. tm2-js-client's estimateGas puts
the base64 text of the transaction in `data`, the RPC layer base64s it
again, and the node answers TxDecodeError. simulateTx copied that, so
every estimate failed and silently fell back. It now passes raw bytes.

The route status was never set. getRoutes returns the router's body and
leaves `status` undefined — useGetRoutes is what derives it, from whether
the quotes add up to 100. The max-amount factory tested for "SUCCESS", so
a swap MAX never got past it. It now applies the same quote rule.

The fee is not the flat DEFAULT_GAS_FEE. Real transactions on chain carry
gas-wanted = gasUsed x 1.1 and a fee priced at the live gas price, so the
wallet re-prices rather than taking the offered figure. Measured over the
same swap at a range of amounts:

  GNOT->GNS    1 GNOT ->  140,012,248 gas -> 0.154 GNOT fee
            1,000,000 -> 1,233,144,927 gas -> 1.356 GNOT fee
  GNOT->USDC   1 GNOT ->  146,083,015 gas -> 0.161 GNOT fee
            1,000,000 ->   179,162,563 gas -> 0.197 GNOT fee

Gas dominates a swap and moves 8.8x with the amount, while the storage
deposit is zero — the opposite of what the flat-fee reserve assumed, and
it under-reserved every heavy swap. The fee is now priced from the
measured gas with a margin over the wallet's multiplier, floored at the
offered fee so a wallet that does take it at face value is still covered.

Two further fixes the measurements forced:

- The retreat on a failed verification was a 0.5% trim, which on a
  1.3M GNOT balance threw away 6,611 GNOT to grope for costs of three.
  It now bisects between the last amount that worked and the one that
  did not, so the slack is a couple of GNOT at any balance.
- The probe amount is no longer trusted on its own. It can sit above
  what the reserve allows for a light action, and the answer is capped by
  both bounds.

Verified end to end: for all three accounts and both pairs the chosen
amount simulates with the reserved fee, and amount + reserve equals the
balance from 2 GNOT up to 1.3M GNOT.
A balance under the offered fee had no ceiling to probe near: holding
back a tenth still left nothing to pay the fee with, so the probe either
failed or its measurement was discarded, and MAX offered nothing at all.

Such a balance is now probed at a hundredth of itself, which leaves
almost all of it free to cover the costs being measured. The reading
still stands for the whole balance: gas barely moves across small
amounts — measured on the dev chain, a hundredfold rise from 1 to 100
GNOT cost 0.17% more gas.

The flat-fee floor is dropped there too. A wallet charging the flat fee
could not spend such a balance at all, so holding that much back would
only ever return nothing; what the simulation measured is the honest
figure. The floor returns as soon as the balance can bear it.

Measured on the dev chain, GNOT->GNS, previously all zero:

  balance 0.3 GNOT -> 0.084 spendable, 0.206 fee measured at 3,000 ugnot
  balance 0.5      -> 0.282 spendable, 0.208 fee measured at 5,000
  balance 0.9      -> 0.684 spendable, 0.206 fee measured at 9,000
  balance 1.0      -> 0.781 spendable, 0.209 fee measured at 10,000

A balance so small that a hundredth of it is not routable still falls
back, which for 0.1 GNOT is the right answer anyway.
The conditions and the arithmetic were buried in one long method with
inline commentary, so the process was only legible by reading it end to
end. They are now separate.

`native-amount-reserve.ts` holds the policy as pure functions, with a
header stating why each cost is known the way it is and a numbered
process the steps are named after: planProbes, priceReserve,
bisectToward, cappedSpendable, describeWithheld. The branch that decides
where to measure — below the ceiling for a balance that can bear the
fee, at a hundredth of itself for one that cannot — is a table in the
doc comment and a table of cases in its spec.

The service keeps only the I/O: resolve the gas price and the account
once, measure the first planned step that simulates, narrow, report.
Its two loops are named for the steps they carry out, and it takes its
probe amounts from the policy rather than recomputing them.

The service spec loses the six cases that were pure arithmetic — those
now sit next to the functions they test — and derives its expected probe
amounts from planProbes instead of restating the formula.

@junghoon-vans junghoon-vans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found four MAX-path issues in the deposit decoder, first-transaction simulation, async input updates, and GNOT wrapping. Details and requested corrections are inline.

Four findings from review, all reproduced against the dev chain.

The deposit decoder read the wrong wire shape. `std.Coin` carries a
`MarshalAmino` returning `coin.String()`, so `fee_delta` is the text
`600ugnot`, not a nested message. Parsing it as one threw on an
unsupported wire type, `sumStorageDeposit` swallowed that, and every
estimate reported no deposit at all — which is why the live figures were
uniformly zero while the chain was failing probes on locked deposits. It
now parses the coin string, and the spec is pinned to the bytes of an
event the chain emitted, with its own encoder asserted byte-identical.

Simulation named no signer key. The ante handler resolves the key from
the account when the signature carries none, and an account that has
never signed has none stored: a funded address with no public key
returns `InvalidPubKeyError` before the simulate bypass is reached, so
MAX fell back for every first-time user. The wallet's key is now carried
on the simulation signature, wrapped as amino wraps a byte-array type.

A pending MAX could land on the wrong field. The result was applied
unconditionally after route lookups and simulations, so switching token
or typing meanwhile was overwritten by a stale, differently-denominated
figure. `getMaxAmount` now answers `null` once the token changed, the
user typed, or a later press superseded it, and the four call sites
write back only a non-null answer.

GNOT to wrapped GNOT was weighed as a swap. The submit path treats that
pair as a wrap and broadcasts a `Deposit`; the factory asked the router
for routes and built an `ExactInSwapRoute`. It now builds the wrap.

Measured deposits after the decoder fix, previously all zero:
GNOT->GNS 324,600 and 4,650 ugnot, GNOT->USDC 1,470,300 and 1,151,250.
Every case still simulates with the reserved fee.
The first guard only compared the input token and the amount in the
field. Those are not the whole of what an estimate answers.

Changing the output token leaves both untouched — changeTokenB rewrites
only tokenB — while the route, and so the gas, changes completely. Press
MAX on GNOT->USDC, switch the output to GNS before it returns, and a
figure priced for 0.197 GNOT of fee lands in a form whose swap needs
1.356. The same holds for a price range on an increase, which the amount
inputs never show, and for the recipient of a send: measured on the dev
chain, a bank send costs 623,278 gas back to the sender, 934,170 to an
existing account and 1,237,649 to an address the chain has not seen.

The subject a pending answer belongs to now carries a `dependsOn` list
for whatever else defined it, compared element by element so call sites
can build it inline. Wired to the output token and balance on both swap
inputs, the recipient and balance on the send, and the position, pool
price and range on an increase.

The recipient case changes no reserve today, since all three figures
price under the flat fee the reserve is floored at. It would once that
floor goes.
Two more from review.

The signer was left to each call site to declare, and the send and the
increase both build their messages from the connected account without
listing it. A wallet switch mid-flight would then write an answer priced
for the previous account — usually caught by the balance changing with
it, but only by luck. The hook tracks the connected address itself now,
so no caller has to remember and none can get it wrong.

`loading` was cleared in a `finally` that did not ask whether it was
still the latest press, so of two overlapping estimates the first to
settle re-enabled the button while the second was still out. It is now
cleared only by the press that is still current.

The test for that one interleaves properly — one gate per estimate, so
the earlier call settles while the later is still pending — and fails
without the guard.
junghoon-vans
junghoon-vans previously approved these changes Sep 28, 2026
Three things about how MAX reads while the reserve is being worked out.

The field went on showing whatever was in it, then jumped to the answer
with nothing said in between. It now empties and shows the whole balance
as a dimmed placeholder for the duration, which is what the answer is
about to be, less what the transaction costs. The hook hands that
balance out, so no caller has to derive it.

The button was two pixels of padding around eleven-pixel text. Its hit
area now reaches past the pill without changing how the pill looks.

Pressing the button lit up the whole input section, because the section
highlighted on `:focus-within` and the button sits inside it. The
highlight now follows the field holding an amount, which is what it was
meant to indicate — and it stays off through the measurement, since the
field is empty while the placeholder shows.
… the output too

Two corrections to the MAX presentation.

The output kept showing a figure priced for an input that was about to
change. It now stands in the same way the input does while the reserve
is measured: the old number shown dimmed as a placeholder, replaced once
the new amount arrives.

The highlight was rebuilt on the wrong rule. It originally followed
focus, and the fault was only that `:focus-within` also caught the MAX
button sitting inside the section; tying it to the field holding a
number instead changed when it appears. It now follows focus on the
section's own field, so it behaves as before everywhere except the case
that prompted this — pressing MAX leaves it dark, since focus goes to
the button.

Pinned by a spec asserting the section lights on input focus, goes dark
on blur, and stays dark through a MAX press.
The overlay was doing its job but barely — 8px above and below, 6px to
the sides, on a pill 16px tall. It now reaches 6px up, 14px down and
14px to each side, roughly 36px of height to aim at.

The top is deliberately the small one: 8px is all that separates this
row from the one holding the token selector, and reaching past that
would take clicks meant for it. Down and sideways the section's own
padding is free space, so there was nothing to be careful of.

`inset` is replaced by the four offsets it stands for. It resolves
everywhere the app runs, but a shorthand that silently drops the whole
declaration where it does not is a poor thing to hang a hit area on.
The invisible overlay was the wrong shape of fix. It left the drawn
badge and the area that responds as two different boxes — clicks landing
14px outside the pill still counted, which is what it looked like from
the outside.

There is one box now. The badge carries its own padding and stands 24px
instead of 16px, so what is drawn is what responds, and it is worth
aiming at without anything invisible around it.

The row grows a few pixels with it.
Growing the badge to carry its own hit area made it too big. The two
jobs are now split: the button is a wrapper that does the responding,
and the badge inside is a span that only gets drawn.

The badge goes back to the 16px it was. The wrapper pads 6px above and
below and 10px to each side, so there is 28px of height to aim at, and
negative margins hand that space back to the layout — the row keeps the
height and spacing it had before any of this.

6px is the ceiling above: that is the gap to the row holding the token
selector, and reaching past it would take clicks meant for that.
The wrapper's negative margins put its edges over the balance text
beside it and a little into the row above, but nothing said it should
paint over them. Whichever sibling came later won the click there, so
the part hanging past the badge was dead.

It sits at z-index 1 now, on position: relative so the value applies at
all. The local scale is deliberate — Z_INDEX starts at 1040 for modals
and has nothing to say about ordering within a row.
Importing @states/index pulls every state module in with it, and one of
them reaches back into the hooks tree. The gas hook wants one atom, so
it now names the module that holds it.

No behaviour change; it keeps the module graph under this hook narrow.
…reserve

Three things needed deciding.

The only textual conflict was the import line in gno-provider, where
both sides had reformatted it. Took develop's.

`estimateSocialWalletFee`, new on develop, calls `modifyDocument` — which
this branch had renamed to `withGasFee`. Git merged the text without
noticing the callee no longer existed, so the two call sites now use the
new name.

Both sides had independently found the same bug: tm2-js-client base64s
the transaction on top of the RPC client's own encoding, and the node
answers TxDecodeError. develop fixed it by overriding `estimateGas`,
this branch by writing `simulateTx`. Rather than keep two copies of the
query, `estimateGas` now takes the gas off `simulateTx` and drops the
rest.
The placeholder and the disabled button were tied to the request, not to
the field. Switch the input token mid-estimate and the new token's field
sat blank behind the old token's balance with its button locked, until
an estimate finished that `stillWanted()` then threw away. On a slow RPC
that is several route lookups and up to five simulations of waiting for
nothing.

`pendingBalance` is worked out as the field renders now: it stands in
only while the subject still matches the one the estimate was asked
about. A field that moves on gets its placeholder and its button back on
the next render, and the request still settles quietly into a result
nobody uses.

The comparison the answer is gated on and the one the placeholder is
gated on are the same function now, so the two cannot drift.
@sonarqubecloud

Copy link
Copy Markdown

@jinoosss
jinoosss merged commit 80d9498 into develop Sep 29, 2026
7 checks passed
@jinoosss
jinoosss deleted the GSW-3000-gnot-max-reserve branch September 29, 2026 08:16

This branch was successfully deployed

1 active deployment
Preview — 8dd7f717 Deployed Sep 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants