Skip to content

fix: support fee-on-transfer ERC20 tokens and enforce auction invariants#62

Open
Rav1Chauhan wants to merge 1 commit intoStabilityNexus:mainfrom
Rav1Chauhan:fix/erc20-fee-on-transfer-support
Open

fix: support fee-on-transfer ERC20 tokens and enforce auction invariants#62
Rav1Chauhan wants to merge 1 commit intoStabilityNexus:mainfrom
Rav1Chauhan:fix/erc20-fee-on-transfer-support

Conversation

@Rav1Chauhan
Copy link

@Rav1Chauhan Rav1Chauhan commented Feb 24, 2026

Addressed Issues:

Fixes #46

Addressed Issues:

Fixes #

Summary

This PR improves auction safety and correctness by:

  1. Supporting fee-on-transfer / deflationary ERC20 tokens
  2. Using balance-difference accounting for token transfers
  3. Enforcing strict bidding invariants
  4. Preventing zero-bid loopholes and dead auctions

All existing tests pass with no regressions.


Problem

ERC20 Accounting Assumption

Auction contracts assumed that transferFrom() transfers the full bid amount.
This is incorrect for:

  • Fee-on-transfer tokens
  • Deflationary tokens
  • Tax tokens

This could result in:

  • Incorrect internal accounting
  • Withdrawal failures
  • Incorrect protocol fee calculation
  • Locked funds

Bidding Invariant Weaknesses

EnglishAuction allowed:

  • minimumBid = 0
  • minBidDelta = 0
  • Equal bids replacing highest bidder
  • Potential griefing via zero-increment bids

Solution

Balance-Difference Accounting

Updated all ERC20 bid transfers to use:

uint256 balanceBefore = IERC20(token).balanceOf(address(this));
safeTransferFrom(...);
uint256 balanceAfter = IERC20(token).balanceOf(address(this));
uint256 actualReceived = balanceAfter - balanceBefore;


Enforced Bidding Invariants (EnglishAuction)
require(minimumBid > 0, "minimumBid must be > 0");
require(minBidDelta > 0, "minBidDelta must be > 0"); 

## Checklist
<!-- Mark items with [x] to indicate completion -->
- [x ] My PR addresses a single issue, fixes a single bug or makes a single improvement.
- [x ] My code follows the project's code style and conventions.
- [ x] If applicable, I have made corresponding changes or additions to the documentation.
- [x ] If applicable, I have made corresponding changes or additions to tests.
- [x ] My changes generate no new warnings or errors.
- [ x] I have joined the Stability Nexus's [Discord server]([https://discord.gg/hjUhu33uAn](https://discord.gg/eqYhuFzuKN)) and I will share a link to this PR with the project maintainers there.
- [x ] I have read the [Contribution Guidelines](../CONTRIBUTING.md).
- [x ] Once I submit my PR, CodeRabbit AI will automatically review it and I will address CodeRabbit's comments.

## AI Usage Disclosure

Check one of the checkboxes below:

- [ x] This PR does not contain AI-generated code at all.
- [ ] This PR contains AI-generated code. I have tested the code locally and I am responsible for it.

I have used the following AI models and tools: TODO


### ⚠️ AI Notice - Important!


We encourage contributors to use AI tools responsibly when creating Pull Requests. While AI can be a valuable aid, it is essential to ensure that your contributions meet the task requirements, build successfully, include relevant tests, and pass all linters. Submissions that do not meet these standards may be closed without warning to maintain the quality and integrity of the project. Please take the time to understand the changes you are proposing and their impact.

@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

Walkthrough

This pull request implements balance-based token reception tracking across multiple auction contracts to handle fee-on-transfer and deflationary ERC20 tokens. The receiveERC20 function now returns the actual amount received, and all auction types update their bid accounting and state to use this actual amount instead of expected amounts.

Changes

Cohort / File(s) Summary
Auction Contracts - Bid Logic Refactoring
contracts/EnglishAuction.sol, contracts/ExponentialReverseDutchAuction.sol, contracts/LinearReverseDutchAuction.sol, contracts/LogarithmicReverseDutchAuction.sol
Updated bid flows to capture actual tokens received via receiveERC20, track actualReceived, and use it for availableFunds and settlePrice instead of expected bid amounts. Added deadline extension in EnglishAuction and getCurrentPrice control flow change in LinearReverseDutchAuction. Minor input validation added to LogarithmicReverseDutchAuction.
Vickrey Auction - Reveal Logic Rewrite
contracts/VickreyAuction.sol
Refactored reveal bid logic to track actual tokens received, update winner state when new highest bid achieved, and conditionally refund previous highest bidder (excluding auctioneer). Moved commit fee refund after state updates for reentrancy safety.
Abstract Base - Token Reception
contracts/abstract/Auction.sol
Modified receiveERC20 signature to return uint256 actualReceived instead of void; now tracks balance before/after transfer to compute actual amount received and validate it exceeds zero.

Sequence Diagram(s)

sequenceDiagram
    participant Bidder
    participant AuctionContract
    participant ERC20Token
    participant PreviousBidder

    Bidder->>AuctionContract: bid(amount)
    AuctionContract->>ERC20Token: transferFrom(bidder, contract, amount)
    ERC20Token-->>AuctionContract: (may deduct fees)
    AuctionContract->>AuctionContract: balanceAfter - balanceBefore = actualReceived
    AuctionContract->>AuctionContract: validate actualReceived > 0
    AuctionContract->>AuctionContract: store previousHighest & previousWinner
    alt New Highest Bid
        AuctionContract->>AuctionContract: update highestBid = actualReceived
        AuctionContract->>AuctionContract: update winner
        AuctionContract->>PreviousBidder: refund previousHighest (if previousHighest != 0)
        PreviousBidder-->>AuctionContract: ✓
    end
    AuctionContract->>AuctionContract: availableFunds = actualReceived
    AuctionContract->>AuctionContract: emit BidPlaced(actualReceived)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Suggested labels

Solidity Lang

Suggested reviewers

  • ceilican
  • yogesh0509

Poem

🐰 Hops through the ledger with careful measure,
Counting each token—the true treasure!
Fee-on-transfer? No longer a fright,
Balance-based bids now get it right!
Auctions bloom fair under moonlit skies, 🌙

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: support for fee-on-transfer ERC20 tokens and enforcement of auction invariants, which are the primary objectives throughout the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/abstract/Auction.sol`:
- Around line 90-99: receiveERC20 can return less than expected for
fee‑on‑transfer tokens, but callers (receiveFunds / token auction creation
paths) still record the expectedAmount into auctionedTokenIdOrAmount causing
claim to later revert; update the callers to use the actualReceived value
returned by receiveERC20 (or alternatively reject non‑exact receipts).
Specifically, change the receiveFunds / createTokenAuction flow so after calling
receiveERC20(...) you capture its return value and write that actualReceived
into the auction state field auctionedTokenIdOrAmount (or require(actualReceived
== expectedAmount) if you choose to reject shortfalls), and ensure claim uses
that stored amount when transferring tokens. Make sure functions involved
(receiveFunds, any createTokenAuction wrapper, and claim) reference
auctionedTokenIdOrAmount consistently and add a clear revert message when
rejecting non‑exact receipts.

In `@contracts/EnglishAuction.sol`:
- Around line 110-139: Validation currently compares bidAmount but the contract
records actualReceived from receiveERC20 (so fee‑on‑transfer tokens can
underbid). In function bid, move or duplicate the checks to validate using
actualReceived (the value returned by receiveERC20) against auction.minimumBid
and auction.highestBid + auction.minBidDelta (and against auction.highestBid ==
0 case) and revert if actualReceived is too low; keep receiveERC20 call
(function receiveERC20) and then perform these require checks, update
auction.winner/auction.highestBid/auction.availableFunds only after successful
validation, and ensure refund logic (previousHighest / previousWinner) still
uses the stored previousHighest.
- Around line 67-69: Add unit tests for the constructor parameter checks and
fee-on-transfer bidding behavior: write tests that assert the constructor or
initializer reverts when minimumBid == 0 and when minBidDelta == 0 (referencing
the minimumBid and minBidDelta require checks), and add tests for bid() that
simulate a fee-on-transfer/deflationary ERC20 where transferred amount is less
than the specified bid (use a test token that deducts fees and verify
actualReceived < bidAmount), confirming the contract handles actualReceived
correctly (e.g., rejects bids that don't meet minimums or updates highestBid
using actualReceived as implemented in bid()).

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eea3b08 and 93568f2.

📒 Files selected for processing (6)
  • contracts/EnglishAuction.sol
  • contracts/ExponentialReverseDutchAuction.sol
  • contracts/LinearReverseDutchAuction.sol
  • contracts/LogarithmicReverseDutchAuction.sol
  • contracts/VickreyAuction.sol
  • contracts/abstract/Auction.sol

Comment on lines +90 to +99
function receiveERC20(address token, address from, uint256 expectedAmount) internal returns (uint256 actualReceived) {
uint256 balanceBefore = IERC20(token).balanceOf(address(this));

SafeERC20.safeTransferFrom(IERC20(token), from, address(this), expectedAmount);

uint256 balanceAfter = IERC20(token).balanceOf(address(this));

actualReceived = balanceAfter - balanceBefore;

require(actualReceived > 0, 'No tokens received');
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent underfunded token auctions when the auctioned asset is fee-on-transfer.

receiveERC20 now measures actualReceived, but receiveFunds callers still record the expected amount for auctionedTokenIdOrAmount. If the auctioned asset is a fee‑on‑transfer ERC20, the contract can hold fewer tokens than recorded and claim can revert when trying to transfer the full amount. Plumb the actual received amount into token-auction state (or explicitly reject non‑exact receipts for auctioned assets).

🛠️ Example direction (plumb actualReceived to callers)
-function receiveFunds(bool isNFT, address token, address from, uint256 tokenIdOrAmount) internal {
+function receiveFunds(bool isNFT, address token, address from, uint256 tokenIdOrAmount) internal returns (uint256 actualReceived) {
     if (isNFT) {
         receiveNFT(token, from, tokenIdOrAmount);
+        return tokenIdOrAmount;
     } else {
-        receiveERC20(token, from, tokenIdOrAmount);
+        actualReceived = receiveERC20(token, from, tokenIdOrAmount);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/abstract/Auction.sol` around lines 90 - 99, receiveERC20 can return
less than expected for fee‑on‑transfer tokens, but callers (receiveFunds / token
auction creation paths) still record the expectedAmount into
auctionedTokenIdOrAmount causing claim to later revert; update the callers to
use the actualReceived value returned by receiveERC20 (or alternatively reject
non‑exact receipts). Specifically, change the receiveFunds / createTokenAuction
flow so after calling receiveERC20(...) you capture its return value and write
that actualReceived into the auction state field auctionedTokenIdOrAmount (or
require(actualReceived == expectedAmount) if you choose to reject shortfalls),
and ensure claim uses that stored amount when transferring tokens. Make sure
functions involved (receiveFunds, any createTokenAuction wrapper, and claim)
reference auctionedTokenIdOrAmount consistently and add a clear revert message
when rejecting non‑exact receipts.

Comment on lines 67 to +69
require(duration > 0, 'Duration must be greater than zero seconds');
require(minimumBid > 0, 'minimumBid must be > 0');
require(minBidDelta > 0, 'minBidDelta must be > 0');
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, find the EnglishAuction.sol contract
find . -name "EnglishAuction.sol" -type f

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Search for test files related to EnglishAuction
find . -type f \( -name "*test*" -o -name "*spec*" \) | head -20

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 307


🏁 Script executed:

#!/bin/bash
# Search for EnglishAuction references in test files
rg -l "EnglishAuction" --type sol --type ts --type js

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Read the EnglishAuction.sol contract, focusing on the area around lines 67-69
cat -n ./contracts/EnglishAuction.sol | sed -n '50,100p'

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 2314


🏁 Script executed:

#!/bin/bash
# Check the full test file for EnglishAuction
wc -l ./test/EnglishAuction.test.ts

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 119


🏁 Script executed:

#!/bin/bash
# Search for test cases related to minimumBid, minBidDelta, and fee-on-transfer
rg -n "minimumBid|minBidDelta|fee.*transfer|deflationary|actualReceived" ./test/EnglishAuction.test.ts

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 145


🏁 Script executed:

#!/bin/bash
# Read the EnglishAuction test file to see what tests exist
cat -n ./test/EnglishAuction.test.ts

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 14981


🏁 Script executed:

#!/bin/bash
# Read more of the EnglishAuction.sol contract to see the bid function
wc -l ./contracts/EnglishAuction.sol

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 120


🏁 Script executed:

#!/bin/bash
# Look at the bid function in EnglishAuction
cat -n ./contracts/EnglishAuction.sol | sed -n '110,200p'

Repository: StabilityNexus/HammerAuctionHouse-Solidity

Length of output: 2863


Add tests for minimumBid/minBidDelta constraints and fee-on-transfer token handling.

The contract properly implements fee-on-transfer support (via actualReceived tracking in bid()), but tests are missing for:

  1. Boundary conditions rejecting zero values for minimumBid and minBidDelta
  2. Fee-on-transfer/deflationary token scenarios where actualReceived < bidAmount
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/EnglishAuction.sol` around lines 67 - 69, Add unit tests for the
constructor parameter checks and fee-on-transfer bidding behavior: write tests
that assert the constructor or initializer reverts when minimumBid == 0 and when
minBidDelta == 0 (referencing the minimumBid and minBidDelta require checks),
and add tests for bid() that simulate a fee-on-transfer/deflationary ERC20 where
transferred amount is less than the specified bid (use a test token that deducts
fees and verify actualReceived < bidAmount), confirming the contract handles
actualReceived correctly (e.g., rejects bids that don't meet minimums or updates
highestBid using actualReceived as implemented in bid()).

Comment on lines 110 to +139
function bid(uint256 auctionId, uint256 bidAmount) external exists(auctionId) beforeDeadline(auctions[auctionId].deadline) {
AuctionData storage auction = auctions[auctionId];

// First bid validation
require(auction.highestBid != 0 || bidAmount >= auction.minimumBid, 'First bid should be greater than starting bid');

// Increment validation
require(auction.highestBid == 0 || bidAmount >= auction.highestBid + auction.minBidDelta, 'Bid amount should exceed current bid by atleast minBidDelta');
receiveERC20(auction.biddingToken, msg.sender, bidAmount);
uint256 refund = auction.highestBid;

// Capture actual tokens received (supports fee-on-transfer tokens)
uint256 actualReceived = receiveERC20(auction.biddingToken, msg.sender, bidAmount);

// Refund previous highest bidder
uint256 previousHighest = auction.highestBid;
address previousWinner = auction.winner;

auction.winner = msg.sender;
auction.highestBid = bidAmount;
if (refund != 0) {
sendERC20(auction.biddingToken, previousWinner, refund);
auction.highestBid = actualReceived;

if (previousHighest != 0) {
sendERC20(auction.biddingToken, previousWinner, previousHighest);
}
auction.availableFunds = bidAmount;

// Track only what contract actually received
auction.availableFunds = actualReceived;

// Extend deadline
auction.deadline += auction.deadlineExtension;
emit bidPlaced(auctionId, msg.sender, bidAmount);

emit bidPlaced(auctionId, msg.sender, actualReceived);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate bids using actualReceived to preserve auction invariants.

The checks use bidAmount (requested transfer) while state uses actualReceived. With fee‑on‑transfer tokens, a bidder can pass the checks but still deposit less than minimumBid or less than previousHighest + minBidDelta, effectively underbidding and displacing the current winner. Gate the invariants on actualReceived.

✅ Suggested fix (use actualReceived for validation)
-        // First bid validation
-        require(auction.highestBid != 0 || bidAmount >= auction.minimumBid, 'First bid should be greater than starting bid');
-
-        // Increment validation
-        require(auction.highestBid == 0 || bidAmount >= auction.highestBid + auction.minBidDelta, 'Bid amount should exceed current bid by atleast minBidDelta');
-
-        //  Capture actual tokens received (supports fee-on-transfer tokens)
-        uint256 actualReceived = receiveERC20(auction.biddingToken, msg.sender, bidAmount);
+        uint256 previousHighest = auction.highestBid;
+
+        // Capture actual tokens received (supports fee-on-transfer tokens)
+        uint256 actualReceived = receiveERC20(auction.biddingToken, msg.sender, bidAmount);
+
+        // Validate against actual received amount
+        if (previousHighest == 0) {
+            require(actualReceived >= auction.minimumBid, 'First bid should be greater than starting bid');
+        } else {
+            require(actualReceived >= previousHighest + auction.minBidDelta, 'Bid amount should exceed current bid by atleast minBidDelta');
+        }
 
-        // Refund previous highest bidder
-        uint256 previousHighest = auction.highestBid;
         address previousWinner = auction.winner;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/EnglishAuction.sol` around lines 110 - 139, Validation currently
compares bidAmount but the contract records actualReceived from receiveERC20 (so
fee‑on‑transfer tokens can underbid). In function bid, move or duplicate the
checks to validate using actualReceived (the value returned by receiveERC20)
against auction.minimumBid and auction.highestBid + auction.minBidDelta (and
against auction.highestBid == 0 case) and revert if actualReceived is too low;
keep receiveERC20 call (function receiveERC20) and then perform these require
checks, update auction.winner/auction.highestBid/auction.availableFunds only
after successful validation, and ensure refund logic (previousHighest /
previousWinner) still uses the stored previousHighest.

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.

[Medium] Fee-on-transfer / deflationary ERC20 bidding tokens can break auction accounting

1 participant