NFT Dealers

First Flight #58
Beginner FriendlyFoundry
100 EXP
Submission Details
Impact: medium
Likelihood: high

Fee Threshold Cliff Creates Economic Disincentives - Sellers Net Less By Charging More at Boundaries

Author Revealed upon completion

Root cause is that the fee calculation uses flat percentage thresholds (1%, 3%, 5%) instead of marginal calculation. At threshold boundaries (1000 USDC and 10000 USDC), sellers net LESS money by charging MORE because the fee percentage jumps apply to the entire amount, not just the excess.

Impact: Sellers lose money by pricing above thresholds (19 USDC loss at 1000 USDC boundary, 199 USDC loss at 10000 USDC boundary). Market becomes inefficient as sellers cluster just below thresholds. Protocol revenue becomes unpredictable due to irrational pricing behavior.

Description

  • The NFT Dealers protocol uses a progressive fee structure: 1% for prices ≤1000 USDC, 3% for prices ≤10000 USDC, and 5% for prices >10000 USDC. This is intended to charge higher fees for higher-value sales.

  • However, the fee calculation applies the higher percentage to the ENTIRE sale price, not just the amount above the threshold. This creates a "cliff effect" where sellers net less money by charging more. For example, at 1000 USDC the fee is 10 USDC (seller nets 990), but at 1001 USDC the fee is 30 USDC (seller nets 970) - a 19 USDC loss for charging 1 USDC more.

// src/NFTDealers.sol::_calculateFees()
function _calculateFees(uint256 _price) internal pure returns (uint256) {
@> if (_price <= LOW_FEE_THRESHOLD) { // 1000 USDC
@> return (_price * LOW_FEE_BPS) / MAX_BPS; // ❌ 1% on entire amount
@> } else if (_price <= MID_FEE_THRESHOLD) { // 10000 USDC
@> return (_price * MID_FEE_BPS) / MAX_BPS; // ❌ 3% on entire amount
@> }
@> return (_price * HIGH_FEE_BPS) / MAX_BPS; // ❌ 5% on entire amount
}

Risk

Likelihood:

  • This occurs on EVERY listing priced at or near threshold boundaries (1000 or 10000 USDC)

  • Any seller pricing above thresholds will experience the cliff effect and lose money

Impact:

  • Sellers lose money by pricing above thresholds - creates irrational market behavior

  • Market efficiency reduced as sellers cluster just below fee boundaries

Proof of Concept

The following PoC demonstrates the fee cliff by comparing seller proceeds at threshold boundaries. A seller charging 1001 USDC nets less than a seller charging 1000 USDC.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import { Test } from "forge-std/Test.sol";
import { NFTDealers } from "src/NFTDealers.sol";
import { MockUSDC } from "src/MockUSDC.sol";
contract M02_PoC is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
address owner = makeAddr("owner");
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(owner, address(usdc), "NFT Dealers", "NFTD", "ipfs://image", 20 * 1e6);
}
function test_FeeThresholdCliff() public {
uint256 price1 = 1000 * 1e6;
uint256 price2 = 1001 * 1e6;
uint256 fee1 = nftDealers.calculateFees(price1);
uint256 fee2 = nftDealers.calculateFees(price2);
uint256 net1 = price1 - fee1;
uint256 net2 = price2 - fee2;
console.log("Price 1000 USDC - Fee:", fee1/1e6, "USDC - Net:", net1/1e6, "USDC");
console.log("Price 1001 USDC - Fee:", fee2/1e6, "USDC - Net:", net2/1e6, "USDC");
if (net2 < net1) {
console.log("VULNERABILITY: Seller nets LESS by charging MORE");
console.log("Loss:", (net1 - net2)/1e6, "USDC");
}
}
}

Proof of Concept (Foundry Test with 3 POC Tests for Every Possible Scenario)

The comprehensive test suite below validates the vulnerability across three scenarios: (1) Low threshold cliff - seller loses 19 USDC by charging 1 USDC more, (2) Mid threshold cliff - seller loses 199 USDC by charging 1 USDC more, (3) Optimal pricing analysis shows market will cluster at thresholds. All tests pass and confirm the vulnerability.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
/**
* ============================================================
* POC-M02: Fee Threshold Cliff Creates Economic Disincentives
* Sellers net LESS by charging MORE at boundaries
* Severity : MEDIUM
* Contract : NFTDealers.sol
* Function : _calculateFees()
* Author: Sudan249 AKA 0xAljzoli
* ============================================================
*/
import { Test } from "forge-std/Test.sol";
import { console } from "forge-std/console.sol";
import "./AuditBase.sol";
contract POC_M02_FeeThresholdCliff is AuditBase {
uint256 constant LOW_THRESHOLD = 1000 * 1e6;
uint256 constant MID_THRESHOLD = 10_000 * 1e6;
// ------------------------------------------------------------------
// POC A: Low Threshold Cliff - Seller Loses Money Charging More
// ------------------------------------------------------------------
function test_M02_A_lowThresholdCliff_sellerLosesMoney() public {
uint256 priceAt = LOW_THRESHOLD;
uint256 priceAbove = LOW_THRESHOLD + 1e6;
uint256 feeAt = nftDealers.calculateFees(priceAt);
uint256 feeAbove = nftDealers.calculateFees(priceAbove);
uint256 netAt = priceAt - feeAt;
uint256 netAbove = priceAbove - feeAbove;
console.log("=== LOW THRESHOLD CLIFF ===");
console.log("Price at threshold:", priceAt / 1e6, "USDC");
console.log("Fee at threshold:", feeAt / 1e6, "USDC");
console.log("Net at threshold:", netAt / 1e6, "USDC");
console.log("Price above threshold:", priceAbove / 1e6, "USDC");
console.log("Fee above threshold:", feeAbove / 1e6, "USDC");
console.log("Net above threshold:", netAbove / 1e6, "USDC");
if (netAbove < netAt) {
console.log("VULNERABILITY CONFIRMED: Seller nets LESS by charging MORE");
console.log("Price increase:", (priceAbove - priceAt) / 1e6, "USDC");
console.log("Net decrease:", (netAt - netAbove) / 1e6, "USDC");
}
assertGt(netAt, netAbove, "Seller should not net less when charging more");
}
// ------------------------------------------------------------------
// POC B: Mid Threshold Cliff - Even Worse Loss
// ------------------------------------------------------------------
function test_M02_B_midThresholdCliff_evenWorse() public {
uint256 priceAt = MID_THRESHOLD;
uint256 priceAbove = MID_THRESHOLD + 1e6;
uint256 feeAt = nftDealers.calculateFees(priceAt);
uint256 feeAbove = nftDealers.calculateFees(priceAbove);
uint256 netAt = priceAt - feeAt;
uint256 netAbove = priceAbove - feeAbove;
console.log("=== MID THRESHOLD CLIFF ===");
console.log("Price at threshold:", priceAt / 1e6, "USDC");
console.log("Fee at threshold:", feeAt / 1e6, "USDC");
console.log("Net at threshold:", netAt / 1e6, "USDC");
console.log("Price above threshold:", priceAbove / 1e6, "USDC");
console.log("Fee above threshold:", feeAbove / 1e6, "USDC");
console.log("Net above threshold:", netAbove / 1e6, "USDC");
uint256 loss = netAt - netAbove;
console.log("Seller LOSS by charging 1 USDC more:", loss / 1e6, "USDC");
if (loss > 0) {
console.log("VULNERABILITY CONFIRMED: Massive disincentive at mid threshold");
}
assertGt(netAt, netAbove, "Seller should not net less when charging more");
}
// ------------------------------------------------------------------
// POC C: Optimal Pricing Analysis - Market Distortion
// ------------------------------------------------------------------
function test_M02_C_optimalPricing_marketDistortion() public {
console.log("=== OPTIMAL PRICING ANALYSIS ===");
uint256[] memory testPrices = new uint256[](6);
testPrices[0] = 999 * 1e6;
testPrices[1] = 1000 * 1e6;
testPrices[2] = 1001 * 1e6;
testPrices[3] = 9999 * 1e6;
testPrices[4] = 10_000 * 1e6;
testPrices[5] = 10_001 * 1e6;
uint256 maxNet = 0;
uint256 optimalPrice = 0;
for (uint256 i = 0; i < testPrices.length; i++) {
uint256 price = testPrices[i];
uint256 fee = nftDealers.calculateFees(price);
uint256 net = price - fee;
// ✅ FIXED: Use separate log calls
console.log("Test", i);
console.log(" Price:", price / 1e6, "USDC");
console.log(" Fee:", fee / 1e6, "USDC");
console.log(" Net:", net / 1e6, "USDC");
if (net > maxNet) {
maxNet = net;
optimalPrice = price;
}
}
console.log("OPTIMAL PRICE:", optimalPrice / 1e6, "USDC");
console.log("MAX NET:", maxNet / 1e6, "USDC");
console.log("VULNERABILITY: Market will cluster at threshold boundaries");
}
// ------------------------------------------------------------------
// POC D: Marginal Fee Calculation (Proposed Fix)
// ------------------------------------------------------------------
function test_M02_D_marginalFeeCalculation_fix() public pure {
console.log("=== PROPOSED FIX: MARGINAL FEE CALCULATION ===");
uint256 price = 1001 * 1e6;
uint256 lowThreshold = 1000 * 1e6;
uint256 currentFee = (price * 300) / 10000;
uint256 feeOnThreshold = (lowThreshold * 100) / 10000;
uint256 excess = price - lowThreshold;
uint256 feeOnExcess = (excess * 300) / 10000;
uint256 marginalFee = feeOnThreshold + feeOnExcess;
console.log("Price:", price / 1e6, "USDC");
console.log("Current Fee:", currentFee / 1e6, "USDC");
console.log("Marginal Fee:", marginalFee / 1e6, "USDC");
console.log("FIX: Use marginal calculation to eliminate cliff effect");
}
}

Recommended Mitigation

The fix implements marginal fee calculation (like income tax) where the lower rate applies to the threshold amount and the higher rate applies only to the excess. This ensures sellers always net more by charging more.

- function _calculateFees(uint256 _price) internal pure returns (uint256) {
- if (_price <= LOW_FEE_THRESHOLD) {
- return (_price * LOW_FEE_BPS) / MAX_BPS;
- } else if (_price <= MID_FEE_THRESHOLD) {
- return (_price * MID_FEE_BPS) / MAX_BPS;
- }
- return (_price * HIGH_FEE_BPS) / MAX_BPS;
- }
+ function _calculateFees(uint256 _price) internal pure returns (uint256) {
+ uint256 fee = 0;
+
+ if (_price <= LOW_FEE_THRESHOLD) {
+ fee = (_price * LOW_FEE_BPS) / MAX_BPS;
+ } else if (_price <= MID_FEE_THRESHOLD) {
+ // ✅ Marginal: 1% on first 1000, 3% on excess
+ fee = (LOW_FEE_THRESHOLD * LOW_FEE_BPS) / MAX_BPS;
+ fee += ((_price - LOW_FEE_THRESHOLD) * MID_FEE_BPS) / MAX_BPS;
+ } else {
+ // ✅ Marginal: 1% on first 1000, 3% on next 9000, 5% on excess
+ fee = (LOW_FEE_THRESHOLD * LOW_FEE_BPS) / MAX_BPS;
+ fee += ((MID_FEE_THRESHOLD - LOW_FEE_THRESHOLD) * MID_FEE_BPS) / MAX_BPS;
+ fee += ((_price - MID_FEE_THRESHOLD) * HIGH_FEE_BPS) / MAX_BPS;
+ }
+
+ return fee;
+ }

Mitigation Explanation: The fix addresses the root cause by: (1) Implementing marginal fee calculation where each fee tier applies only to the amount within that tier, similar to how income tax works, (2) For prices above 1000 USDC, the first 1000 is taxed at 1% and only the excess is taxed at 3%, (3) For prices above 10000 USDC, the first 1000 is at 1%, next 9000 at 3%, and only the excess above 10000 at 5%, (4) This ensures sellers always net more money by charging more, eliminating the irrational pricing incentive, (5) Market efficiency is restored as sellers can price at any point without fearing the cliff effect.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!