Vulnerability Details
The protocol allows sellers & buyers to earn tokens through the following,
Selling NFTs: Sellers tailor NFTs that align with AI agents, sell it to them & earn tokens.
RoyaltyFee: BuyerAgents receive token as a royaltyFee paid by the sellers to them when asset gets created in Swan::list.
The issue lies in the royalty amount that is actually sent. The royaltyFee is specified by BuyerAgent owner and when Swan::list() is called by seller to create an asset, a percentage of the price of this asset is set aside in transferRoyalties. This is known as the buyerFee. The driaFee (fee sent to Swan owner) is taken from the buyerFee
uint256 buyerFee = (asset.price * asset.royaltyFee) / 100;
@> uint256 driaFee = (buyerFee * getCurrentMarketParameters().platformFee) / 100;
and the rest (buyerFee - driaFee) is sent to BuyerAgent,
token.transfer(asset.buyer, buyerFee - driaFee);
This means, the fee to be sent to owner is taken from the royalty that is meant to be sent to Buyer and that is the problem. If
platformFee == 100 (the protocol allows this), then buyerFee == driaFee, and nothing would be sent to buyer! This means 100% of royalty would go to Swan Owner, leaving no royalty for buyer since the amount sent to Swan Owner is directly taken from the ROYALTY AMOUNT and so, if the platformFee is 100%, then
buyerFee - driaFee = 0. It is possible for the owner to set platformFee to 100%. In blockchain, the ultimate truth & the only trustworthy entity is not the owner, but the logic of the protocol and if the logic allows it, it's bound to happen.
Impact
Buyer doesn't necessarily get the royalty when platformFee is 100%. They get a LEFT-OVER AMOUNT which is
(buyerFee - driaFee) making it appear
as if platformFee is actually an EXPLOITATIVE AMOUNT set by protocol, leading to reduced buyer participation and loss of royalty for the Buyer.
Proof-Of-Code
Add the test in Swan.test.ts,
it("buyer gets no royalty", async() => {
const supply = parseEther("1000");
token = await deployTokenFixture(dria, supply);
expect(await token.balanceOf(dria.address)).to.eq(supply);
const MARKET_PARAMETERS2 = {
withdrawInterval: minutes(30),
sellInterval: minutes(60),
buyInterval: minutes(10),
platformFee: 100n,
maxAssetCount: 5n,
timestamp: 0n,
} satisfies SwanMarketParametersStruct;
const currentTime = (await ethers.provider.getBlock("latest").then((block) => block?.timestamp)) as bigint;
MARKET_PARAMETERS2.timestamp = currentTime;
({ swan, registry, coordinator } = await deploySwanFixture(
dria,
token,
STAKES,
FEES,
MARKET_PARAMETERS2,
ORACLE_PARAMETERS
));
const ROYALTY_FEE2 = 50;
const buyerAgentParams = [
{
name: "BuyerAgent#1",
description: "Description of BuyerAgent 1",
royaltyFee: ROYALTY_FEE2,
amountPerRound: AMOUNT_PER_ROUND,
owner: buyer,
},
];
[buyerAgent, buyerAgentToFail] = await createBuyers(swan, buyerAgentParams);
await transferTokens(token, [
[buyer.address, parseEther("120")],
[buyerToFail.address, parseEther("3")],
[seller.address, FEE_AMOUNT1 + FEE_AMOUNT2 + FEE_AMOUNT3 + FEE_AMOUNT1 + FEE_AMOUNT2],
[sellerToRelist.address, FEE_AMOUNT2 + FEE_AMOUNT1],
[generator.address, STAKE_AMOUNT],
[validator.address, STAKE_AMOUNT],
])
const buyerAgentAddress = await buyerAgent.getAddress();
const tokenBalanceBefore = await token.balanceOf(buyerAgentAddress);
expect(tokenBalanceBefore).to.equal(0);
const customPrice = parseEther("120");
await token.connect(buyer).approve(await swan.getAddress(), parseEther("120"));
await swan.connect(buyer).list(NAME, SYMBOL, DESC, customPrice, await buyerAgent.getAddress())
const tokenBalanceAfter = await token.balanceOf(buyerAgentAddress);
expect(tokenBalanceAfter).to.equal(0);
});
Tools Used
Manual Review & Hardhat Testing
Recommendations
Consider one of the following changes,
Add a min threshold for the royalty that should be sent to the Buyer on each listing to ensure that buyers get at least "min" amount of royalty, encouraing users to participate, create agents and so on,
+ uint256 public minRoyalty = x;
function transferRoyalties(AssetListing storage asset) internal {
uint256 buyerFee = (asset.price * asset.royaltyFee) / 100;
uint256 driaFee = (buyerFee * getCurrentMarketParameters().platformFee) / 100;
// first, Swan receives the entire fee from seller
// this allows only one approval from the seller's side
token.transferFrom(asset.seller, address(this), buyerFee);
+ if (buyerFee - driaFee < minRoyalty) revert();
// send the buyer's portion to them
token.transfer(asset.buyer, buyerFee - driaFee);
// then it sends the remaining to Swan owner
token.transfer(owner(), driaFee);
}
2.Make driaFee a small, fixed amount provided by Swan Owner that should get deducted when list() is called.
+ uint256 public protocolFee = x;
function transferRoyalties(AssetListing storage asset) internal {
uint256 buyerFee = (asset.price * asset.royaltyFee) / 100;
- uint256 driaFee = (buyerFee * getCurrentMarketParameters().platformFee) / 100;
// first, Swan receives the entire fee from seller
// this allows only one approval from the seller's side
- token.transferFrom(asset.seller, address(this), buyerFee);
+ token.transferFrom(asset.seller, address(this), buyerFee + protocolFee);
// send the buyer's portion to them
- token.transfer(asset.buyer, buyerFee - driaFee);
+ token.transfer(asset.buyer, buyerFee);
// then it sends the remaining to Swan owner
- token.transfer(owner(), driaFee);
+ token.transfer(owner(), protocolFee);
}
3.Ensure that platformFee is NEVER 100%. An acceptable percentage is 90% so Buyer gets a decent royalty amount.
function initialize(
SwanMarketParameters calldata _marketParameters,
LLMOracleTaskParameters calldata _oracleParameters,
// contracts
address _coordinator,
address _token,
address _buyerAgentFactory,
address _swanAssetFactory
) public initializer {
__Ownable_init(msg.sender);
- require(_marketParameters.platformFee <= 100, "Platform fee cannot exceed 100%");
+ require(_marketParameters.platformFee <= 90, "Platform fee cannot exceed 90%");
...
...
}
function setMarketParameters(SwanMarketParameters memory _marketParameters) external onlyOwner {
- require(_marketParameters.platformFee <= 100, "Platform fee cannot exceed 100%");
+ require(_marketParameters.platformFee <= 90, "Platform fee cannot exceed 90%");
_marketParameters.timestamp = block.timestamp;
marketParameters.push(_marketParameters);
}