Dria

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

Missing secondary oracle fee in minimum fund requirement may lead to oracle agent overspending beyond buyer agent capacity

Summary

Buyers can withdraw funds from their agents by calling BuyerAgent::withdraw during the withdrawal phase. This function ensures buyers leave enough funds to cover future purchases in upcoming rounds:

...
if (treasury() < minFundAmount() + _amount) {
revert MinFundSubceeded(_amount);
}
...

The minimum required funds include the maximum amount the oracle may spend on items from the buyer agent, as well as the oracle fee associated with the purchase request:

function minFundAmount() public view returns (uint256) {
return amountPerRound + swan.getOracleFee();
}

However, this calculation does not account for an additional oracle fee incurred when updating the agent’s state following a fund withdrawal. In the subsequent round, if the oracle selects an item priced precisely at the per-round spending limit, the buyer’s purchase transaction may fail due to insufficient funds. Buyers would need to cover the additional costs by transferring the required tokens to the contract if they wish to acquire the corresponding asset.

Vulnerability Details

Buyer transactions may fail if the buyer agent’s state is updated post-withdrawal, leaving insufficient funds to cover both item costs and the required oracle fees, particularly if these fees exceed the royalties. For sellers, this means that their customized asset, designed for a specific buyer, could become unsellable, with royalty fees effectively lost if the buyer's funds fall below the asset price and they choose not to replenish their agent balance.

Impact

The following PoC in Foundry details the issue:

See PoC
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.20;
import {Vm, Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {Swan} from "../src/swan/Swan.sol";
import {BuyerAgent} from "../src/swan/BuyerAgent.sol";
import {LLMOracleCoordinator} from "../src/llm/LLMOracleCoordinator.sol";
import {LLMOracleRegistry, LLMOracleKind} from "../src/llm/LLMOracleRegistry.sol";
import {MockToken} from "./mock/MockToken.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {LLMOracleTaskParameters, LLMOracleTask} from "../src/llm/LLMOracleTask.sol";
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {SwanMarketParameters} from "../src/swan/SwanManager.sol";
import {SwanAssetFactory, SwanAsset} from "../src/swan/SwanAsset.sol";
contract BaseTest is Test {
LLMOracleRegistry registry;
LLMOracleCoordinator coordinator;
MockToken mocktoken;
Swan swan;
BuyerAgent buyerAgent;
address requester = makeAddr("requester");
// Mocks the oracle LLM
address responder = makeAddr("responder");
// Address whose buyer agent will acquire listed assets
address buyer = makeAddr("buyer");
// Proxies deployment part 1
function setUp() public {
LLMOracleRegistry registryImpl = new LLMOracleRegistry();
mocktoken = new MockToken("Mock Token", "MT");
uint256 generatorStake = 1;
bytes memory _data = abi.encodeWithSignature(
"initialize(uint256,uint256,address)",
generatorStake,
0,
address(mocktoken)
);
ERC1967Proxy registryProxy = new ERC1967Proxy(
address(registryImpl),
_data
);
registry = LLMOracleRegistry(address(registryProxy));
LLMOracleCoordinator coordinatorImpl = new LLMOracleCoordinator();
uint256 _platformFee = 0.1 ether;
uint256 _generationFee = 0.1 ether;
uint256 _validationFee = 0.1 ether;
_data = abi.encodeWithSignature(
"initialize(address,address,uint256,uint256,uint256)",
address(registry),
address(mocktoken),
_platformFee,
_generationFee,
_validationFee
);
ERC1967Proxy coordinatorProxy = new ERC1967Proxy(
address(coordinatorImpl),
_data
);
coordinator = LLMOracleCoordinator(address(coordinatorProxy));
deploySwanAndAgent();
}
// Deployments function part 2 to avoid stack to deep error
function deploySwanAndAgent() public {
SwanAssetFactory assetFactory = new SwanAssetFactory();
Swan swanImpl = new Swan();
SwanMarketParameters memory marketParams = SwanMarketParameters({
withdrawInterval: uint256(1 weeks),
sellInterval: uint256(1 weeks),
buyInterval: uint256(1 weeks),
platformFee: uint256(1),
maxAssetCount: uint256(100),
timestamp: uint256(block.timestamp)
});
LLMOracleTaskParameters memory oracleParams = LLMOracleTaskParameters({
difficulty: uint8(1),
numGenerations: uint40(1),
numValidations: uint40(0)
});
bytes memory _data = abi.encodeWithSelector(
Swan(address(swanImpl)).initialize.selector,
marketParams,
oracleParams,
address(coordinator),
address(mocktoken),
address(1),
assetFactory
);
ERC1967Proxy swanProxy = new ERC1967Proxy(address(swanImpl), _data);
swan = Swan(address(swanProxy));
string memory _name = "buyer agent";
string memory _description = "buyer agent";
uint96 _royaltyFee = 1;
// uint256 _amountPerRound = type(uint256).max;
uint256 _amountPerRound = 1 ether;
address _operator = address(swan);
address _owner = buyer;
buyerAgent = new BuyerAgent(
_name,
_description,
_royaltyFee,
_amountPerRound,
_operator,
_owner
);
}
function testUpdateStateAfterWithdraw() public {
uint256 fundAmount = 10 ether;
// First let's grant some funds to the buyer agent
deal(address(mocktoken), address(buyerAgent), fundAmount);
// Roll to withdraw time
vm.warp(block.timestamp + 2 weeks + 1);
vm.startPrank(buyer);
uint256 minFundAmount = (buyerAgent.minFundAmount());
// We leave the agent with just the minimal amount
buyerAgent.withdraw(uint96(fundAmount - minFundAmount));
// Then update the state
bytes memory input = "input";
bytes memory models = "models";
buyerAgent.oracleStateRequest(input, models);
vm.stopPrank();
// Roll to sell phase
vm.warp(block.timestamp + 1 weeks);
// Another user lists an asset targeting our buyer agent and gets selected as the best response by the oracle
// The price of the asset matches the maximum amount the oracle can spend per round
mockTokenListAndOracleResponse(buyerAgent.amountPerRound());
// There is not enough funds since part of them were spent in the oracle state update request
vm.expectRevert();
// ERC20InsufficientBalance
buyerAgent.purchase();
}
// Mocks listing and oracle response
function mockTokenListAndOracleResponse(uint256 price) public {
string memory _name = "asset";
string memory _symbol = "asset";
bytes memory _desc = "description";
uint256 _price = price;
address _buyer = address(buyerAgent);
mocktoken.approve(address(swan), type(uint256).max);
vm.recordLogs();
// This contract lists any asset targeting buyer
swan.list(_name, _symbol, _desc, _price, _buyer);
Vm.Log[] memory entries = vm.getRecordedLogs();
bytes32 assetAddr = entries[6].topics[2];
// This should be bytes32[](1) but for whatever reason the 1 is not displayed
bytes32[] memory assetAddrArr = new bytes32[]();
assetAddrArr[0] = assetAddr;
// Roll to buy phase
vm.warp(block.timestamp + 1 weeks + 1);
bytes memory input = "input";
bytes memory models = "models";
vm.startPrank(buyer);
// Submit purchase request
buyerAgent.oraclePurchaseRequest(input, models);
vm.stopPrank();
deal(address(mocktoken), responder, 1 ether);
vm.startPrank(responder);
mocktoken.approve(address(registry), type(uint256).max);
LLMOracleKind oracleKind = LLMOracleKind.Generator;
registry.register(oracleKind);
// Task id 2 because 1 is the update state request
uint256 taskId = 2;
uint256 nonce = 123456;
bytes memory output = abi.encode(assetAddrArr);
// Oracle response contains our previously listed asset
coordinator.respond(taskId, nonce, output, models); // Reuse models for metadata to avoid stack too deep error
vm.stopPrank();
}
}

Here you can also find the code for the mock token:

Mock Token
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockToken is ERC20 {
constructor(
string memory name_,
string memory symbol_
) ERC20(name_, symbol_) {
_mint(msg.sender, 100 ether);
}
}

Tools Used

Manual review.

Recommendations

Minimum funds requirement should be equal to amount per round + 2 oracle fees:

function minFundAmount() public view returns (uint256) {
- return amountPerRound + swan.getOracleFee();
+ return amountPerRound + 2*swan.getOracleFee();
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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