From b4c24e4b2f0edd9cf4a6d7921cf1dcf404c3a876 Mon Sep 17 00:00:00 2001 From: vivinvinh212 Date: Thu, 30 Jan 2025 02:10:22 +0700 Subject: [PATCH 1/5] Swan: Preliminary fixes --- src/Swan.sol | 7 +++++++ src/SwanAgent.sol | 20 +++++++++++++++++++- src/SwanArtifact.sol | 27 +++++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/Swan.sol b/src/Swan.sol index 16e61a9..9f29ba8 100644 --- a/src/Swan.sol +++ b/src/Swan.sol @@ -42,6 +42,7 @@ contract Swan is SwanManager, UUPSUpgradeable { /// @notice Invalid price for the artifact. error InvalidPrice(uint256 price); + error InsufficientBudget(uint256 required, uint256 available); /*////////////////////////////////////////////////////////////// EVENTS @@ -343,6 +344,12 @@ contract Swan is SwanManager, UUPSUpgradeable { revert Unauthorized(msg.sender); } + // check budget before proceeding + uint256 agentBalance = token.balanceOf(msg.sender); + if (agentBalance < listing.price) { + revert InsufficientBudget(listing.price, agentBalance); + } + // update artifact status to be sold listing.status = ArtifactStatus.Sold; diff --git a/src/SwanAgent.sol b/src/SwanAgent.sol index 8ba2027..db035e8 100644 --- a/src/SwanAgent.sol +++ b/src/SwanAgent.sol @@ -168,7 +168,7 @@ contract SwanAgent is Ownable { /// @notice The minimum amount of money that the agent must leave within the contract. /// @dev minFundAmount should be `amountPerRound + oracleFee` to be able to make requests. function minFundAmount() public view returns (uint256) { - return amountPerRound + swan.getOracleFee(); + return amountPerRound + 2 * swan.getOracleFee(); } /// @notice Reads the best performing result for a given task id, and parses it as an array of addresses. @@ -420,4 +420,22 @@ contract SwanAgent is Ownable { amountPerRound = _amountPerRound; } + + /// @notice Withdraws all available funds within allowable limits + /// @dev Withdraws maximum possible amount while respecting minFundAmount requirements + function withdrawAll() external onlyAuthorized { + (, Phase phase,) = getRoundPhase(); + uint256 balance = treasury(); + + if (phase != Phase.Withdraw) { + // Must leave minFundAmount in non-withdraw phase + if (balance > minFundAmount()) { + uint256 withdrawable = balance - minFundAmount(); + swan.token().transfer(owner(), withdrawable); + } + } else { + // Can withdraw everything in withdraw phase + swan.token().transfer(owner(), balance); + } + } } diff --git a/src/SwanArtifact.sol b/src/SwanArtifact.sol index d09b92c..5b3af72 100644 --- a/src/SwanArtifact.sol +++ b/src/SwanArtifact.sol @@ -20,9 +20,16 @@ contract SwanArtifactFactory { contract SwanArtifact is ERC721, Ownable { /// @notice Creation time of the token uint256 public createdAt; + /// @notice Description of the token bytes public description; + /// @notice Swan operator address that cannot have its approval revoked + address public immutable swanOperator; + + /// @notice Error thrown when attempting to revoke Swan's approval + error CannotRevokeSwan(); + /// @notice Constructor sets properties of the token. constructor( string memory _name, @@ -33,11 +40,27 @@ contract SwanArtifact is ERC721, Ownable { ) ERC721(_name, _symbol) Ownable(_owner) { description = _description; createdAt = block.timestamp; + swanOperator = _operator; // owner is minted the token immediately - ERC721._safeMint(_owner, 1); + _safeMint(_owner, 1); // Swan (operator) is approved to by the owner immediately. - ERC721._setApprovalForAll(_owner, _operator, true); + _setApprovalForAll(_owner, _operator, true); + } + + function setApprovalForAll(address operator, bool approved) public override { + if (operator == swanOperator && !approved) { + revert CannotRevokeSwan(); + } + super.setApprovalForAll(operator, approved); + } + + function approve(address to, uint256 tokenId) public override { + address owner = ownerOf(tokenId); + if (isApprovedForAll(owner, swanOperator) && to != swanOperator) { + revert CannotRevokeSwan(); + } + super.approve(to, tokenId); } } From e55a734082a29f3da74b03f5e2e67dbeb13c4aee Mon Sep 17 00:00:00 2001 From: vivinvinh212 Date: Fri, 31 Jan 2025 11:56:48 +0700 Subject: [PATCH 2/5] SwanArtifact: Undo changes --- src/SwanArtifact.sol | 38 ++++++-------------------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/src/SwanArtifact.sol b/src/SwanArtifact.sol index 5b3af72..33a9bea 100644 --- a/src/SwanArtifact.sol +++ b/src/SwanArtifact.sol @@ -20,47 +20,21 @@ contract SwanArtifactFactory { contract SwanArtifact is ERC721, Ownable { /// @notice Creation time of the token uint256 public createdAt; - /// @notice Description of the token bytes public description; - /// @notice Swan operator address that cannot have its approval revoked - address public immutable swanOperator; - - /// @notice Error thrown when attempting to revoke Swan's approval - error CannotRevokeSwan(); - /// @notice Constructor sets properties of the token. - constructor( - string memory _name, - string memory _symbol, - bytes memory _description, - address _owner, - address _operator - ) ERC721(_name, _symbol) Ownable(_owner) { + constructor(string memory _name, string memory _symbol, bytes memory _description, address _owner) + ERC721(_name, _symbol) + Ownable(_owner) + { description = _description; createdAt = block.timestamp; - swanOperator = _operator; // owner is minted the token immediately - _safeMint(_owner, 1); + ERC721._safeMint(_owner, 1); // Swan (operator) is approved to by the owner immediately. - _setApprovalForAll(_owner, _operator, true); - } - - function setApprovalForAll(address operator, bool approved) public override { - if (operator == swanOperator && !approved) { - revert CannotRevokeSwan(); - } - super.setApprovalForAll(operator, approved); - } - - function approve(address to, uint256 tokenId) public override { - address owner = ownerOf(tokenId); - if (isApprovedForAll(owner, swanOperator) && to != swanOperator) { - revert CannotRevokeSwan(); - } - super.approve(to, tokenId); + ERC721._setApprovalForAll(_owner, _operator, true); } } From 2c3d9a64352c0e40e62b7f1574a54c84eb812101 Mon Sep 17 00:00:00 2001 From: vivinvinh212 Date: Sat, 1 Feb 2025 02:07:13 +0700 Subject: [PATCH 3/5] Swan contract fixes for maximum purchase --- src/Swan.sol | 7 ------- src/SwanAgent.sol | 41 ++++++++++++++++++++++++++++++++--------- src/SwanArtifact.sol | 11 +++++++---- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/Swan.sol b/src/Swan.sol index 9f29ba8..16e61a9 100644 --- a/src/Swan.sol +++ b/src/Swan.sol @@ -42,7 +42,6 @@ contract Swan is SwanManager, UUPSUpgradeable { /// @notice Invalid price for the artifact. error InvalidPrice(uint256 price); - error InsufficientBudget(uint256 required, uint256 available); /*////////////////////////////////////////////////////////////// EVENTS @@ -344,12 +343,6 @@ contract Swan is SwanManager, UUPSUpgradeable { revert Unauthorized(msg.sender); } - // check budget before proceeding - uint256 agentBalance = token.balanceOf(msg.sender); - if (agentBalance < listing.price) { - revert InsufficientBudget(listing.price, agentBalance); - } - // update artifact status to be sold listing.status = ArtifactStatus.Sold; diff --git a/src/SwanAgent.sol b/src/SwanAgent.sol index db035e8..a6d8dee 100644 --- a/src/SwanAgent.sol +++ b/src/SwanAgent.sol @@ -5,6 +5,7 @@ import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {LLMOracleTaskParameters} from "@firstbatch/dria-oracle-contracts/LLMOracleTask.sol"; import {Swan, SwanAgentPurchaseOracleProtocol, SwanAgentStateOracleProtocol} from "./Swan.sol"; import {SwanMarketParameters} from "./SwanManager.sol"; +import {SwanArtifact} from "./SwanArtifact.sol"; /// @notice Factory contract to deploy Agent contracts. /// @dev This saves from contract space for Swan. @@ -51,6 +52,9 @@ contract SwanAgent is Ownable { EVENTS //////////////////////////////////////////////////////////////*/ + /// @notice Emitted when an artifact is skipped. + event ItemSkipped(address indexed agent, address indexed artifact); + /// @notice Emitted when a state update request is made. event StateRequest(uint256 indexed taskId, uint256 indexed round); @@ -255,24 +259,37 @@ contract SwanAgent is Ownable { // read oracle result using the latest task id for this round bytes memory output = oracleResult(taskId); + // TODO: add try-catch (When solidity supports) to handle more data when revert address[] memory artifacts = abi.decode(output, (address[])); // we purchase each artifact returned for (uint256 i = 0; i < artifacts.length; i++) { address artifact = artifacts[i]; - - // must not exceed the roundly buy-limit uint256 price = swan.getListingPrice(artifact); - spendings[round] += price; - if (spendings[round] > amountPerRound) { - revert BuyLimitExceeded(spendings[round], amountPerRound); + + // skip artifacts that exceed budget instead of reverting + if (spendings[round] + price > amountPerRound) { + emit ItemSkipped(address(this), artifact); + continue; } - // add to inventory - inventory[round].push(artifact); + // check approval + SwanArtifact artifactContract = SwanArtifact(artifact); + address seller = swan.getListing(artifact).seller; + + if (!artifactContract.isApprovedForAll(seller, address(swan))) { + emit ItemSkipped(address(this), artifact); + continue; + } - // make the actual purchase - swan.purchase(artifact); + // try purchase for other potential failures + try swan.purchase(artifact) { + spendings[round] += price; + inventory[round].push(artifact); + } catch { + emit ItemSkipped(address(this), artifact); + continue; + } } // update taskId as completed @@ -438,4 +455,10 @@ contract SwanAgent is Ownable { swan.token().transfer(owner(), balance); } } + + /// @notice Get the inventory for a specific round + /// @param round The queried round + function getInventory(uint256 round) public view returns (address[] memory) { + return inventory[round]; + } } diff --git a/src/SwanArtifact.sol b/src/SwanArtifact.sol index 33a9bea..d09b92c 100644 --- a/src/SwanArtifact.sol +++ b/src/SwanArtifact.sol @@ -24,10 +24,13 @@ contract SwanArtifact is ERC721, Ownable { bytes public description; /// @notice Constructor sets properties of the token. - constructor(string memory _name, string memory _symbol, bytes memory _description, address _owner) - ERC721(_name, _symbol) - Ownable(_owner) - { + constructor( + string memory _name, + string memory _symbol, + bytes memory _description, + address _owner, + address _operator + ) ERC721(_name, _symbol) Ownable(_owner) { description = _description; createdAt = block.timestamp; From ce5983b923ef50a2e87cf672904add23a058b65d Mon Sep 17 00:00:00 2001 From: vivinvinh212 Date: Sat, 1 Feb 2025 02:08:21 +0700 Subject: [PATCH 4/5] SwanTest: Add skipped item purchase test --- test/SwanTest.t.sol | 89 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/test/SwanTest.t.sol b/test/SwanTest.t.sol index e600748..60d6e34 100644 --- a/test/SwanTest.t.sol +++ b/test/SwanTest.t.sol @@ -142,7 +142,7 @@ contract SwanTest is Helper { } /// @notice Agent cannot spend more than amountPerRound per round - function test_RevertWhen_PurchaseMoreThanAmountPerRound() + function test_PurchaseOnlyWithinAmountPerRound() external fund createAgents @@ -172,8 +172,93 @@ contract SwanTest is Helper { safeValidate(validators[0], 1); vm.prank(_agentOwnerToFail); - vm.expectRevert(abi.encodeWithSelector(SwanAgent.BuyLimitExceeded.selector, artifactPrice * 2, amountPerRound)); _agentToFail.purchase(); + + // Verify spending didn't exceed amountPerRound + assertLe(_agentToFail.spendings(currRound), _agentToFail.amountPerRound()); + + // Get purchased artifacts + address[] memory purchasedArtifacts = _agentToFail.getInventory(currRound); + assertLt(purchasedArtifacts.length, output.length, "Should not purchase all artifacts if over budget"); + } + + function test_PurchaseWithSkippedItems() + external + fund + createAgents + sellersApproveToSwan + addValidatorsToWhitelist + registerOracles + listArtifacts(sellers[0], marketParameters.maxArtifactCount, address(agents[0])) + { + address _agentOwnerToFail = agentOwners[0]; + SwanAgent _agentToFail = agents[0]; + + // Get artifacts and encode output early + address[] memory output = swan.getListedArtifacts(address(_agentToFail), currRound); + bytes memory encodedOutput = abi.encode(output); + address artifact1Addr = output[0]; + address artifact3Addr = output[2]; + + // Fund agent with WETH for purchases + deal(address(token), address(_agentToFail), _agentToFail.amountPerRound() * 3); + + // Approve WETH transfers + vm.startPrank(address(_agentToFail)); + token.approve(address(swan), type(uint256).max); + vm.stopPrank(); + + vm.recordLogs(); + + // Revoke approval for artifact 1 + vm.prank(sellers[0]); + SwanArtifact(artifact1Addr).setApprovalForAll(address(swan), false); + + // Move to next round + uint256 nextRoundTime = _agentToFail.createdAt() + marketParameters.listingInterval + + marketParameters.buyInterval + marketParameters.withdrawInterval; + vm.warp(nextRoundTime); + + // Relist with higher price + uint256 overPrice = _agentToFail.amountPerRound() - 1; + vm.prank(sellers[0]); + swan.relist(output[1], address(_agentToFail), overPrice); + + // Move to buy phase + vm.warp(nextRoundTime + marketParameters.listingInterval + 1); + + // Make purchase request + vm.prank(_agentOwnerToFail); + _agentToFail.oraclePurchaseRequest(input, models); + + safeRespond(generators[0], encodedOutput, 1); + safeRespond(generators[1], encodedOutput, 1); + safeValidate(validators[0], 1); + + vm.prank(_agentOwnerToFail); + _agentToFail.purchase(); + + // Record logs and execute purchase + Vm.Log[] memory logs = vm.getRecordedLogs(); + bool foundArtifact1Skip = false; + bool foundArtifact3Skip = false; + + bytes32 skipEventSig = 0x3c44a811ea05c98efb27db6d3cbc9d4e7b0eb204b81047d92adfa387d3b0e818; + + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == skipEventSig) { + address artifact = address(uint160(uint256(logs[i].topics[2]))); + + if (artifact == artifact1Addr) { + foundArtifact1Skip = true; + } else if (artifact == artifact3Addr) { + foundArtifact3Skip = true; + } + } + } + + assertTrue(foundArtifact1Skip, "artifact 1 should be skipped"); + assertTrue(foundArtifact3Skip, "artifact 3 should be skipped"); } /// @notice Agent can purchase From 73f0007b35dad57c69ed2267d30cac0b3a269733 Mon Sep 17 00:00:00 2001 From: vivinvinh212 Date: Sat, 1 Feb 2025 02:11:14 +0700 Subject: [PATCH 5/5] SwanTest: Add purchased artifact test --- test/SwanTest.t.sol | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/SwanTest.t.sol b/test/SwanTest.t.sol index 60d6e34..f127553 100644 --- a/test/SwanTest.t.sol +++ b/test/SwanTest.t.sol @@ -238,12 +238,18 @@ contract SwanTest is Helper { vm.prank(_agentOwnerToFail); _agentToFail.purchase(); + address[] memory purchasedArtifacts = _agentToFail.getInventory(1); // Round 1 + assertTrue(purchasedArtifacts.length == 1, "Should purchase exactly one artifact"); + assertTrue(purchasedArtifacts[0] == output[1], "Should have purchased artifact 2"); + // Record logs and execute purchase Vm.Log[] memory logs = vm.getRecordedLogs(); bool foundArtifact1Skip = false; bool foundArtifact3Skip = false; bytes32 skipEventSig = 0x3c44a811ea05c98efb27db6d3cbc9d4e7b0eb204b81047d92adfa387d3b0e818; + bytes32 soldEventSig = 0x7b1dae0d1aa5992cbf93242e4c807f1f27f69b51255335200caa21c7a6e5ab61; + bool foundArtifact2Sold = false; for (uint256 i = 0; i < logs.length; i++) { if (logs[i].topics.length > 0 && logs[i].topics[0] == skipEventSig) { @@ -255,9 +261,19 @@ contract SwanTest is Helper { foundArtifact3Skip = true; } } + + if (logs[i].topics.length > 0 && logs[i].topics[0] == soldEventSig) { + // ArtifactSold(address owner, address agent, address artifact, uint256 price) + address artifact = address(uint160(uint256(logs[i].topics[3]))); + if (artifact == output[1]) { + // artifact2 address + foundArtifact2Sold = true; + } + } } assertTrue(foundArtifact1Skip, "artifact 1 should be skipped"); + assertTrue(foundArtifact2Sold, "artifact 2 should be purchased"); assertTrue(foundArtifact3Skip, "artifact 3 should be skipped"); }