Dria

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

Platform fees withdrawal will sweep oracle agents earned fees

Summary

Oracle agents earn a portion of user-paid fees if their responses fall within established accuracy boundaries, defined by a specific range of standard deviations. Rather than directly transferring these fees to the agents, LLMOracleCoordinator.sol grants them an approval to spend the fees. Additionally, there is a protocol admin function to withdraw the platform fees along with any residual fee tokens remaining in the contract. However, because oracle agents are only granted approval (not ownership) of the fees, any fees they have not yet withdrawn may be inadvertently collected by the protocol when the admin executes a platform fee withdrawal.

Vulnerability Details

Oracle agents must quickly transfer their earned fees to another address before the protocol sweeps the contract's funds, potentially leaving it empty. If they fail to do so, they must wait until the contract’s balance increases to an amount they can claim before others do.

Impact

The issue can be tested using the following PoC in Foundry:

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.001 ether;
uint256 _generationFee = 0.001 ether;
uint256 _validationFee = 0.001 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;
address _operator = address(swan);
address _owner = buyer;
buyerAgent = new BuyerAgent(
_name,
_description,
_royaltyFee,
_amountPerRound,
_operator,
_owner
);
}
function testSweepFunds() public {
bytes32 protocol = "protocol";
bytes memory input = "input";
bytes memory models = "models";
LLMOracleTaskParameters memory oracleParams = LLMOracleTaskParameters({
difficulty: 1,
numGenerations: 1,
numValidations: 0
});
deal(address(mocktoken), requester, 1 ether);
vm.startPrank(requester);
mocktoken.approve(address(coordinator), type(uint256).max);
// request to the coordinator paying the corresponding protocol and generation fees
coordinator.request(protocol, input, models, oracleParams);
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);
uint256 taskId = 1;
uint256 nonce = 123;
bytes memory output = "output";
bytes memory metadata = "metadata";
// response from the LLM, since there is no validation the fee is granted right away
coordinator.respond(taskId, nonce, output, metadata);
uint256 generatorAllowance = mocktoken.allowance(
address(coordinator),
responder
);
assertEq(generatorAllowance, 0.004 ether); // diff*fee
vm.stopPrank();
// owner sweeps funds from the contract
coordinator.withdrawPlatformFees();
vm.prank(responder);
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector,
address(coordinator),
0,
0.004 ether
)
);
// LLM oracle cannot access the granted funds
mocktoken.transferFrom(address(coordinator), responder, 0.004 ether);
}
}

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

Maintain a separate record of funds allocated to oracle agents, subtracting these from the total contract balance during platform fee withdrawals. This will ensure that agents’ earned fees are preserved when the protocol withdraws platform fees and other residual tokens.

Updates

Lead Judging Commences

inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Validated
Assigned finding tags:

`withdrawPlatformFees` withdraws the entire balance

Appeal created

0xhals Auditor
12 months ago
inallhonesty Lead Judge
12 months ago
inallhonesty Lead Judge 12 months ago
Submission Judgement Published
Validated
Assigned finding tags:

`withdrawPlatformFees` withdraws the entire balance

Support

FAQs

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