Skip to content

fix(uniswap-v3): stop aToken sells failing on the pool's input check - #1543

Merged
enthusiastmartin merged 3 commits into
masterfrom
fix/atoken-uniswapv3-rounding
Oct 28, 2026
Merged

enthusiastmartin merged 3 commits into
masterfrom
fix/atoken-uniswapv3-rounding

Conversation

@iamyxsh

@iamyxsh iamyxsh commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Description

A router sell whose UniswapV3 hop takes an Aave aToken as input (e.g. aDOT → HOLLAR)
failed for some amounts with a bare DispatchError::Other, while amounts 1 wei higher
or lower went through.

An aToken stores scaled balances: balanceOf is scaled.rayMul(index) and a transfer
moves amount.rayDiv(index), both rounding half up. The two roundings can leave the
pool's balanceOf 1 wei short of amount_in, and UniswapV3Pool.swap reverts on its
input check (IIA). The executor then replaced the revert with "uniswapv3: swap failed",
a DispatchError::Other whose text is dropped when the error is encoded, so on chain
only Other was visible.

execute_sell now reads the input token's aToken state before swapping —
UNDERLYING_ASSET_ADDRESS(), POOL(), getReserveNormalizedIncome(underlying) and
scaledBalanceOf(pool) — and predicts the pool's exact balanceOf increase with Aave's
rounding. While that increase would fall short of the amount, the amount is stepped
down 1 wei at a time, up to 1,000 steps. The amount actually sold is what gets reported
to the oracle and in the trade event.

A failed swap now returns pallet_dispatcher::Error::UniswapV3SwapFailed instead of
Other, on both the sell and the buy path.

Note for reviewers

Only amounts that would revert are changed. Anything that goes through today is sold
unchanged. This matters for ICE: the UniswapV3 simulator quotes the exact pool output at
its sample points, so shrinking a trade that would have succeeded could break
conservation for a whole batch. A simpler rule that only looks at the transfer's own
rounding (no pool read) changes about half of all amounts instead of only the failing
ones.

Everything falls back to today's behaviour: a non-aToken input, any failed read, or no
passing amount within the cap sells the full amount unchanged. The cap only matters for
an index very close to 1.0, i.e. a freshly listed reserve.

The sell path makes four extra view calls, so worst_case_gas now returns the larger of
the buy and sell paths instead of assuming the buy path bounds both. Every UniswapV3
trade reserves ~300k more gas worth of weight.

Two things left out on purpose:

  • A stepped-down sell leaves a few wei of the aToken in the router account (at most
    ~17 wei of aDOT at an index ≥ 1.03). The next trade through the router sells it.
    Returning it to the seller would mean extra EVM reads and transfers in the router on
    every trade, and the router's weights are benchmarked.
  • Buys with an aToken input can still hit IIA, because in exactOutput the pool
    decides the input amount. They now fail with UniswapV3SwapFailed rather than Other.

UniswapV3SwapFailed is appended at the end of the enum, so no existing variant index
shifts.

Related Issue

Fixes #1538

Motivation and Context

The outcome depended only on the last wei of the amount and on the current Aave index,
so neither a quote nor a dry run at the best block could predict it. Users paid the fee
and saw nothing but Other.

How Has This Been Tested?

  • cargo test -p hydradx-runtime --lib uniswap_v3_trade_executor::tests — 19 passed, 8 new
  • cargo test -p pallet-dispatcher — 14 passed
  • cargo test -p runtime-integration-tests uniswap_v3_router — 33 passed, 1 ignored
  • cargo clippy --all-targets -- -D warnings on the three changed crates and
    cargo fmt --check clean

New test router_sell_should_succeed_at_every_amount_when_the_input_is_an_atoken sells
aDOT → HOLLAR through the router at 32 consecutive amounts on the mainnet snapshot.
Without the fix it fails at 1_000_000_007 with Other("uniswapv3: swap failed"); with
it all 32 go through.

execute_sell_should_fail_with_a_named_error_when_the_swap_reverts and
trade_weight_should_cover_the_atoken_sell_path cover the new error and the weight.

The unit tests pin the rounding prediction and the step-down: a 1 wei shortfall stepped
down, an amount kept when it already passes, index 1.0, zero amount, zero index, the cap,
a realistic index of 1.07, and overflow.

Versions: hydradx-runtime 448.0.0 / spec_version 448, pallet-dispatcher 1.8.0,
runtime-integration-tests 1.119.0.

Checklist:

  • I have updated the documentation if necessary.
  • I have added tests to cover my changes, regression test if fixing an issue.
  • This is a breaking change.

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Crate versions that have been updated:

  • runtime-integration-tests: v1.126.3 -> v1.127.0
  • pallet-dispatcher: v1.8.0 -> v1.9.0
  • hydradx-runtime: v457.0.0 -> v458.0.0

Runtime version has been increased.

iamyxsh and others added 2 commits October 26, 2026 14:26
- sell gas counts 5 ERC20_VIEW reads, 4 aToken + 1 allowance from #1545
- bumped runtime to 456, dispatcher to 1.9.0, integration tests to 1.127.0
@enthusiastmartin
enthusiastmartin merged commit 74ae9eb into master Oct 28, 2026
10 of 11 checks passed
@enthusiastmartin
enthusiastmartin deleted the fix/atoken-uniswapv3-rounding branch October 28, 2026 08:36
@github-actions

Copy link
Copy Markdown

Quick benchmark at commit 442efd2 has been executed successfully.
View results

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.

Bug: router sell of an aToken into UniswapV3 fails with Other depending on the amount's last wei

2 participants