Dria

Swan
NFTHardhat
21,000 USDC
View results
Submission Details
Severity: high
Invalid

Exceeding Buy Limit in BuyerAgent Causes Round Wastage

Summary

A vulnerability in the BuyerAgent contract causes the purchase function to revert when the total price of assets returned by the oracle exceeds the amountPerRound limit.
This results in the cancellation of all purchase attempts for that round, leading to financial and opportunity losses for both creators and the BuyerAgent owner.

Vulnerability Details

BuyerAgent.sol#L222-L256

function purchase() external onlyAuthorized {
// check that we are in the Buy phase, and return round
(uint256 round,) = _checkRoundPhase(Phase.Buy);
// check if the task is already processed
uint256 taskId = oraclePurchaseRequests[round];
if (isOracleRequestProcessed[taskId]) {
revert TaskAlreadyProcessed();
}
// read oracle result using the latest task id for this round
bytes memory output = oracleResult(taskId);
address[] memory assets = abi.decode(output, (address[]));
// we purchase each asset returned
for (uint256 i = 0; i < assets.length; i++) {
address asset = assets[i];
// must not exceed the roundly buy-limit
uint256 price = swan.getListingPrice(asset);
spendings[round] += price;
if (spendings[round] > amountPerRound) {
@> revert BuyLimitExceeded(spendings[round], amountPerRound);
}
// add to inventory
inventory[round].push(asset);
// make the actual purchase
swan.purchase(asset);
}
// update taskId as completed
isOracleRequestProcessed[taskId] = true;
}

when buyerAgent make purchase, amountPerRound is used to limit how much spending an agent is allowed per round.
however the code is not considered if the oracle is returning multiple assets which if total of (asset*price) > amountPerRound and this would makes the purchase function revert.
this effectively cancelling all result provided by the oracle and makes the round is wasted because there are no purchase happen even though the creator already paid for fee and the buyerAgent owner already paid the oracle for the generation and validation.

Proof of Code:

first, we add function purchaseMock in BuyerAgent.sol to simulate the purchase where oracle return multiple asset:
BuyerAgent.sol:

function purchaseMock(address[] calldata _assets) external onlyAuthorized {
// check that we are in the Buy phase, and return round
(uint256 round,) = _checkRoundPhase(Phase.Buy);
// check if the task is already processed
// uint256 taskId = oraclePurchaseRequests[round];
// if (isOracleRequestProcessed[taskId]) {
// revert TaskAlreadyProcessed();
// }
// read oracle result using the latest task id for this round
// bytes memory output = oracleResult(taskId);
address[] memory assets = _assets;
// we purchase each asset returned
for (uint256 i = 0; i < assets.length; i++) {
address asset = assets[i];
// must not exceed the roundly buy-limit
uint256 price = swan.getListingPrice(asset);
spendings[round] += price;
if (spendings[round] > amountPerRound) {
revert BuyLimitExceeded(spendings[round], amountPerRound);
// break;
}
// add to inventory
inventory[round].push(asset);
// make the actual purchase
swan.purchase(asset);
}
// update taskId as completed
// isOracleRequestProcessed[taskId] = true;
}

add the following files to test folder:

Base.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "../lib/forge-std/src/Test.sol";
import {console2} from "../lib/forge-std/src/console2.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.sol";
import {Swan} from "../contracts/swan/Swan.sol";
import {SwanAssetFactory, SwanAsset} from "../contracts/swan/SwanAsset.sol";
import {SwanManager, SwanMarketParameters} from "../contracts/swan/SwanManager.sol";
import {BuyerAgentFactory, BuyerAgent} from "../contracts/swan/BuyerAgent.sol";
import {LLMOracleTaskParameters} from "../contracts/llm/LLMOracleTask.sol";
import {LLMOracleCoordinator} from "../contracts/llm/LLMOracleCoordinator.sol";
import {LLMOracleRegistry, LLMOracleKind} from "../contracts/llm/LLMOracleRegistry.sol";
import {ERC20Mock} from "./ERC20Mock.sol";
contract Base is Test {
// address
address deployer = makeAddr("deployer");
address buyerAgentOwner = makeAddr("buyerAgentOwner");
address creator = makeAddr("creator");
address generator1 = makeAddr("generator1");
address generator2 = makeAddr("generator2");
address[] generators;
address validator1 = makeAddr("validator1");
address validator2 = makeAddr("validator2");
address validator3 = makeAddr("validator3");
address[] validators;
// contracts
Swan swan;
SwanAssetFactory swanAssetFactory;
LLMOracleCoordinator coordinator;
LLMOracleRegistry registry;
BuyerAgentFactory buyerAgentFactory;
ERC20Mock token;
// market parameters
uint256 withdrawInterval = 30 minutes;
uint256 sellInterval = 60 minutes;
uint256 buyInterval = 10 minutes;
uint256 platformFee = 1;
uint256 maxAssetCount = 5;
uint256 timestamp = 0;
SwanMarketParameters marketParameters = SwanMarketParameters({
withdrawInterval: withdrawInterval,
sellInterval: sellInterval,
buyInterval: buyInterval,
platformFee: platformFee,
maxAssetCount: maxAssetCount,
timestamp: timestamp
});
// oracle parameters
LLMOracleTaskParameters oracleParameters =
LLMOracleTaskParameters({difficulty: 1, numGenerations: 1, numValidations: 1});
// stake parameters
uint256 generatorStakeAmount = 0.1e18;
uint256 validatorStakeAmount = 0.1e18;
uint256 stakePlatformFee = 0.001e18;
uint256 generationFee = 0.002e18;
uint256 validationFee = 0.003e18;
function setUp() public virtual {
vm.startPrank(deployer);
token = new ERC20Mock("Mock", "MOCK");
buyerAgentFactory = new BuyerAgentFactory();
swanAssetFactory = new SwanAssetFactory();
LLMOracleRegistry registryImpl = new LLMOracleRegistry();
ERC1967Proxy registryProxy = new ERC1967Proxy(
address(registryImpl),
abi.encodeWithSelector(
LLMOracleRegistry.initialize.selector, generatorStakeAmount, validatorStakeAmount, address(token)
)
);
registry = LLMOracleRegistry(address(registryProxy));
LLMOracleCoordinator coordinatorImpl = new LLMOracleCoordinator();
ERC1967Proxy coordinatorProxy = new ERC1967Proxy(
address(coordinatorImpl),
abi.encodeWithSelector(
LLMOracleCoordinator.initialize.selector,
address(registry),
address(token),
stakePlatformFee,
generationFee,
validationFee
)
);
coordinator = LLMOracleCoordinator(address(coordinatorProxy));
Swan swanImpl = new Swan();
ERC1967Proxy swanProxy = new ERC1967Proxy(
address(swanImpl),
abi.encodeWithSelector(
Swan.initialize.selector,
marketParameters,
oracleParameters,
address(coordinator),
address(token),
address(buyerAgentFactory),
address(swanAssetFactory)
)
);
swan = Swan(address(swanProxy));
assertEq(swan.getMarketParameters().length, 1);
vm.stopPrank();
generators = new address[](2);
generators[0] = generator1;
generators[1] = generator2;
validators = new address[](3);
validators[0] = validator1;
validators[1] = validator2;
validators[2] = validator3;
vm.stopPrank();
token.mint(buyerAgentOwner, 10e18);
token.mint(creator, 10e18);
// give token to generators, validators
token.mint(generator1, generatorStakeAmount);
token.mint(generator2, generatorStakeAmount);
token.mint(validator1, validatorStakeAmount);
token.mint(validator2, validatorStakeAmount);
token.mint(validator3, validatorStakeAmount);
}
function createBuyerAgent(uint96 _royaltyFee, uint256 _amountPerRound, uint256 _createTimeStamp)
public
returns (BuyerAgent buyerAgent)
{
// create buyer agent
vm.warp(_createTimeStamp);
vm.prank(buyerAgentOwner);
buyerAgent = swan.createBuyer("BuyerAgent", "Buyooor", _royaltyFee, _amountPerRound);
}
function generatorAndValidatorStake() public {
// generator stake
for (uint256 i = 0; i < generators.length; i++) {
vm.startPrank(generators[i]);
token.approve(address(registry), generatorStakeAmount);
registry.register(LLMOracleKind.Generator);
}
//validator stake
for (uint256 i = 0; i < validators.length; i++) {
vm.startPrank(validators[i]);
token.approve(address(registry), validatorStakeAmount);
registry.register(LLMOracleKind.Validator);
}
}
function createListing_Amount_For_WithPrice(address _creator, uint256 _amount, address _buyerAgent, uint256 _price)
public
returns (address[] memory assets)
{
vm.startPrank(_creator);
swan.token().approve(address(swan), _price * _amount);
bytes memory desc = "description";
for (uint256 i = 0; i < _amount; i++) {
swan.list(string(abi.encodePacked("test ", uint2str(i))), "TEST", desc, _price, _buyerAgent);
}
(uint256 round,,) = BuyerAgent(_buyerAgent).getRoundPhase();
assets = swan.getListedAssets(_buyerAgent, round);
vm.stopPrank();
}
function uint2str(uint256 _i) internal pure returns (string memory) {
if (_i == 0) {
return "0";
}
uint256 j = _i;
uint256 length;
while (j != 0) {
length++;
j /= 10;
}
bytes memory bstr = new bytes(length);
uint256 k = length;
while (_i != 0) {
k = k - 1;
uint8 temp = (48 + uint8(_i - _i / 10 * 10));
bytes1 b1 = bytes1(temp);
bstr[k] = b1;
_i /= 10;
}
return string(bstr);
}
function test_createListing() public {
uint256 amount = 5;
BuyerAgent buyerAgent = createBuyerAgent(10, 1e18, 1000);
address[] memory assets = createListing_Amount_For_WithPrice(creator, amount, address(buyerAgent), 100);
assertEq(swan.getListedAssets(address(buyerAgent), 0).length, amount);
for (uint256 i = 0; i < assets.length; i++) {
console2.log("asset: ", assets[i]);
}
}
function test_stake() public {
generatorAndValidatorStake();
}
}

Swan.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {console2} from "../lib/forge-std/src/console2.sol";
import {Base} from "./Base.t.sol";
import {BuyerAgent} from "../contracts/swan/BuyerAgent.sol";
import {SwanMarketParameters} from "../contracts/swan/SwanManager.sol";
import {LLMOracleTaskParameters} from "../contracts/llm/LLMOracleTask.sol";
contract SwanTest is Base {
BuyerAgent buyerAgent;
uint96 royaltyFee = 10; // 10%
uint256 amountPerRound = 1e18;
// for the sake of testing, we set the create time stamp to 1000
uint256 createTimeStamp = 1000;
function setUp() public override {
super.setUp();
buyerAgent = createBuyerAgent(royaltyFee, amountPerRound, createTimeStamp);
}
function test_oracleResultMultipleAssetTotalExceedMaxSpendingRounds() public {
// sell phase
(uint256 round,,) = buyerAgent.getRoundPhase();
uint256 assetAmount = 5;
uint256 assetPrice = 0.4e18;
// buyer agent can only spend 1e18 per round
// so this would exceed the max spending rounds
uint256 balanceOfCreatorBefore = token.balanceOf(creator);
address[] memory listedAssets =
createListing_Amount_For_WithPrice(creator, assetAmount, address(buyerAgent), assetPrice);
uint256 balanceOfCreatorAfter = token.balanceOf(creator);
uint256 feePaidForListing = balanceOfCreatorBefore - balanceOfCreatorAfter;
console2.log("Creator paid ", feePaidForListing, " for listing assets at round", round);
// buy phase
vm.warp(createTimeStamp + sellInterval + 10);
// simulate oracle result returning multiple asset
// so we use purchaseMock here
vm.startPrank(buyerAgentOwner);
token.transfer(address(buyerAgent), 1e18);
// the buyer agent should only be able to buy 2 assets
// but instead buyer agent cant buy any asset
// wasting fee paid by creator for listing and fee paid by agent owner for calling oracle
vm.expectRevert();
buyerAgent.purchaseMock(listedAssets);
}
}

then run forge test --mt test_oracleResultMultipleAssetTotalExceedMaxSpendingRounds
the test would pass because instead agent can buy 2 assets, the call revert

Impact

no purchase can happen on the round, financial and opportunity loss for both creator and agent owner

Tools Used

foundry

Recommendations

it is recommended to let the agent to buy until next purchase is exceed the amountPerRound value by using break instead revert:

diff --git a/contracts/swan/BuyerAgent.sol b/contracts/swan/BuyerAgent.sol
index 6660a67..62ce6b7 100644
--- a/contracts/swan/BuyerAgent.sol
+++ b/contracts/swan/BuyerAgent.sol
@@ -241,7 +283,9 @@ contract BuyerAgent is Ownable {
uint256 price = swan.getListingPrice(asset);
spendings[round] += price;
if (spendings[round] > amountPerRound) {
- revert BuyLimitExceeded(spendings[round], amountPerRound);
+ break;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

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