Finding description and impact
The marketplace contract allows sellers to create listings where minPurchaseAmt
exceeds the total number of tokens listed if the listing is of the partial-fill type. While the contract enforces this constraint for single-fill listings, it neglects to enforce it for partial-fill listings. As a result, a seller can create a partial listing that no buyer can ever fulfill.
Here, the listVesting() function only checks minPurchaseAmt <= _amount
if listingType == SINGLE
, skipping this validation for partial listings:
require( _listingType != ListingType.SINGLE || (_minPurchaseAmt > 0 && _minPurchaseAmt <= _amount), "SS_Marketplace: Minimum Purchase Amount cannot be more than listing amount" );
Impact
This insufficient validation leads to the creation of listings that are fundamentally unpurchasable. A buyer who attempts to purchase these tokens must either buy fewer tokens than minPurchaseAmt
(which should logically fail) or more tokens than even exist in the listing. The contract currently neither reverts nor prevents creating such listings, allowing such orders to persist.
And it's not merely a user mistake—the contract explicitly advertises conditions like minPurchaseAmt
for buyer protection and logical marketplace flow, yet fails to enforce these conditions for partial listings.
Proof of Concept
Paste the test below in the "Public Vesting Listings" section of the `marketplace.ts test file:
it("should allow creation of a partial listing with minPurchaseAmt > total listed amount", async function () { const { vesting, token, marketplace, user1 } = await loadFixture(deployProxyFixture); const [buyer] = await hre.viem.getWalletClients(); // Listing parameters const listAmount = parseEther("100"); const pricePerUnit = parseEther("1"); const discountPct = BigInt(0); const listingType = 0; // PARTIAL fill const discountType = 0; // No discount const maxWhitelist = BigInt(0); const minPurchaseAmt = parseEther("200"); // More than total listed const isPrivate = false; // Approve and list the vesting await token.write.approve([marketplace.address, parseEther("1000")], { account: user1.account }); await marketplace.write.listVesting( [ vesting.address, listAmount, pricePerUnit, discountPct, listingType, discountType, maxWhitelist, token.address, minPurchaseAmt, isPrivate ], { account: user1.account } ); const listing = await marketplace.read.listings([vesting.address, BigInt(0)]); expect(listing[9]).to.equal(minPurchaseAmt); // Setup buyer balance and approval await token.write.mint([buyer.account.address, parseEther("1000")]); await token.write.approve([marketplace.address, parseEther("1000")], { account: buyer.account }); // a purchase of 100 tokens, which is less than minPurchaseAmt=200 // If minPurchaseAmt was enforced, this should revert, but we know it does not. await marketplace.write.spotPurchase( [vesting.address, BigInt(0), listAmount, "0x0000000000000000000000000000000000000000"], { account: buyer.account } ); // If we reach here, it means it did not revert. // The contract allows a purchase that does not meet the minPurchaseAmt requirement, // proving that the minPurchaseAmt constraint is not properly enforced for partial listings. const updatedListing = await marketplace.read.listings([vesting.address, BigInt(0)]); expect(updatedListing[2]).to.be.lessThan(listAmount, "Tokens were purchased even though minPurchaseAmt was not met"); }); });
Recommended mitigation steps
In listVesting()
, add a requirement that minPurchaseAmt
must always be less than or equal to _amount
, regardless of listing type:
require(_minPurchaseAmt <= _amount, "SS_Marketplace: minPurchaseAmt cannot exceed total listed amount");