diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3fbffbb..0000000 --- a/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -* -!*/ -!/.data -!/.github -!/.gitignore -!/README.md -!/comments.csv -!*.md -!**/*.md -!/Audit_Report.pdf diff --git a/001/001.md b/001/001.md new file mode 100644 index 0000000..a7382de --- /dev/null +++ b/001/001.md @@ -0,0 +1,74 @@ +Shaggy Smoke Mole + +Medium + +# Unsafe use of tx.origin in the mint function will leading to unauthorized LP token minting + +### Summary + +The use of `tx.origin` in the [`mint` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L166) of the Velar protocol allows for a phishing attack. If a user grants unlimited allowance to the contract for `base_token` and `quote_token`, a malicious contract can execute the `mint` function on their behalf, resulting in unauthorized minting of LP tokens. The root cause is the use of `tx.origin` to determine the user, which is unsafe as it can be manipulated by intermediate contracts. + +### Root Cause + +In [`core.vy:166`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L166) there is an unsafe use of `tx.origin` as it can cause unauthorized minting. +The choice to use tx.origin may be because of `api.vy` contract between user and `core.vy` contract. There is a good explaination of why it is unsafe in the Solidity documentation: https://docs.soliditylang.org/en/latest/security-considerations.html#tx-origin. + +The `mint` function in `API.vy` allows users to mint LP tokens by depositing `base_token` and `quote_token` into the contract. The function uses `tx.origin` to identify the user initiating the transaction: + +```python +user : address = tx.origin +``` + +This design is problematic because `tx.origin` represents the original sender of the transaction, not necessarily the direct caller. In scenarios where a user interacts with another contract that in turn calls the `mint` function, `tx.origin` will still point to the original user. + +### Internal pre-conditions + +1. The victim must have granted an allowance to the core contract address for both `base_token` and `quote_token`, enabling the contract to transfer tokens on the victim's behalf. + +2. The victim must initiate a transaction with a smart contract that allows the attacker to indirectly call the `mint` function at any point during the transaction. This can be easily achieved, for example, if the victim interacts with an automated router like Uniswap's that swaps tokens based on optimal trade routes. The attacker could create a malicious token involved in the trade, and within the `transfer` function of this malicious token, the attacker can execute a call to the Velor protocol's `mint` function. Because `tx.origin` will still refer to the victim, the Velor protocol will perceive the transaction as initiated by the victim. + +### External pre-conditions + +_No response_ + +### Attack Path + +If a user has set an unlimited allowance for `base_token` and `quote_token` to this contract, a malicious contract can execute a phishing attack by: + +1. Encouraging the user to initiate a transaction on the malicious contract. As explained in internal pre-conditions, it is not hard. +2. Having the malicious contract call the `mint` function on `API.vy`. +3. Using `tx.origin` to transfer tokens from the user’s address to the contract, minting new LP tokens without the user's explicit consent. + + +### Impact + +- **Unauthorized Token Minting:** An attacker can exploit this vulnerability to force a user to mint LP tokens, leading to unauthorized token movements, and potential **loss of funds**. + +### PoC + +_No response_ + +### Mitigation + +To mitigate this vulnerability, replace `tx.origin` with a `user` parameter passed to the `mint` function. This parameter should be set by the `msg.sender` when the `API.vy` contract calls the function. The updated function signature should look like this: + +```python +@external +def mint( + user : address, + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + base_amt : uint256, + quote_amt : uint256, + ctx : Ctx) -> uint256: +``` + +Additionally, the contract call on `api.vy:101` should be updated to: + +```python + return self.CORE.mint(msg.sender, 1, base_token, quote_token, lp_token, base_amt, quote_amt, ctx) +``` + +By passing `msg.sender` as the `user`, the function ensures that only the immediate caller is authorized to initiate the minting process, thus preventing phishing attacks. \ No newline at end of file diff --git a/001/002.md b/001/002.md new file mode 100644 index 0000000..9d44bec --- /dev/null +++ b/001/002.md @@ -0,0 +1,71 @@ +Shaggy Smoke Mole + +Medium + +# Unsafe use of tx.origin in the burn function will lead to unauthorized LP token burn + +### Summary + +The use of `tx.origin` in the [`burn` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L192) of the Velar protocol exposes users to phishing attacks. If a user grants unlimited allowance to the contract for `base_token` and `quote_token`, a malicious contract can execute the `burn` function on their behalf, resulting in unauthorized burning of LP tokens and potential loss of the underlying assets. The root cause is the use of `tx.origin` to determine the user, which is unsafe as it can be manipulated by intermediate contracts. + +### Root Cause + +In [`core.vy:202`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L195), there is an unsafe use of `tx.origin`, which can result in unauthorized token burns. The choice to use `tx.origin` is likely due to the `API.vy` contract serving as an intermediary between the user and the `core.vy` contract. However, `tx.origin` usage is considered insecure because it references the original sender of a transaction, which can lead to unintended consequences when intermediary contracts are involved. The Solidity documentation provides a detailed explanation of why `tx.origin` is unsafe: [Solidity Security Considerations - `tx.origin`](https://docs.soliditylang.org/en/latest/security-considerations.html#tx-origin). + +The `burn` function in `core.vy` allows users to burn LP tokens and retrieve their `base_token` and `quote_token`. The function uses `tx.origin` to identify the user initiating the transaction: + +```python +user : address = tx.origin +``` + +This design is problematic because `tx.origin` represents the original sender of the transaction, not necessarily the direct caller. In scenarios where a user interacts with another contract that in turn calls the `burn` function, `tx.origin` will still point to the original user. + +### Internal Pre-conditions + +1. The victim must have granted an allowance to the core contract address for both `base_token` and `quote_token`, enabling the contract to transfer tokens on the victim's behalf. + +2. The victim must initiate a transaction with a smart contract that allows the attacker to indirectly call the `burn` function at any point during the transaction. This can be easily achieved, for example, if the victim interacts with an automated router like Uniswap's that swaps tokens based on optimal trade routes. The attacker could create a malicious token involved in the trade, and within the `transfer` function of this malicious token, the attacker can execute a call to the Velor protocol's `burn` function. Because `tx.origin` will still refer to the victim, the Velor protocol will perceive the transaction as initiated by the victim. + +### External Pre-conditions + +_No response_ + +### Attack Path + +If a user has set an unlimited allowance for `base_token` and `quote_token` to this contract, a malicious contract can execute a phishing attack by: + +1. Encouraging the user to initiate a transaction on the malicious contract. As explained in internal pre-conditions, it is not hard. +2. Having the malicious contract call the `burn` function on `API.vy`. +3. Using `tx.origin` to transfer tokens from the user’s address to the contract, burning the user's LP tokens and retrieving the underlying assets without the user's explicit consent. + +### Impact + +- **Unauthorized Token Burning and Asset Transfer:** An attacker can exploit this vulnerability to force a user to burn their LP tokens, leading to unauthorized retrieval of underlying assets and potential **loss of funds**. + +### PoC + +_No response_ + +### Mitigation + +To mitigate this vulnerability, replace `tx.origin` with a `user` parameter passed to the `burn` function. This parameter should be set by the `msg.sender` when the `API.vy` contract calls the function. The updated function signature should look like this: + +```python +@external +def burn( + user : address, + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + lp_amt : uint256, + ctx : Ctx) -> Tokens: +``` + +Additionally, the contract call on `api.vy:128` should be updated to: + +```python + return self.CORE.burn(msg.sender, 1, base_token, quote_token, lp_token, lp_amt, ctx) +``` + +By passing `msg.sender` as the `user`, the function ensures that only the immediate caller is authorized to initiate the burning process, thus preventing phishing attacks. \ No newline at end of file diff --git a/001/003.md b/001/003.md new file mode 100644 index 0000000..f594794 --- /dev/null +++ b/001/003.md @@ -0,0 +1,72 @@ +Shaggy Smoke Mole + +Medium + +# Unsafe use of tx.origin in the open function will lead to unauthorized position opening + +### Summary + +The use of `tx.origin` in the [`open` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L230) of the Velar protocol exposes users to phishing attacks. If a user grants unlimited allowance to the contract for `base_token` or `quote_token`, a malicious contract can execute the `open` function on their behalf, resulting in unauthorized creation of leveraged positions. The root cause is the use of `tx.origin` to determine the user, which is unsafe as it can be manipulated by intermediate contracts. + +### Root Cause + +In [`core.vy:241`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L241), there is an unsafe use of `tx.origin`, which can lead to unauthorized opening of leveraged positions. The choice to use `tx.origin` might be due to the `API.vy` contract serving as an intermediary between the user and the `core.vy` contract. However, `tx.origin` is considered insecure because it references the original sender of a transaction, which can lead to unintended consequences when intermediary contracts are involved. The Solidity documentation provides a detailed explanation of why `tx.origin` is unsafe: [Solidity Security Considerations - `tx.origin`](https://docs.soliditylang.org/en/latest/security-considerations.html#tx-origin). + +The `open` function in `core.vy` allows users to open leveraged positions by depositing collateral and leveraging it against the pool. The function uses `tx.origin` to identify the user initiating the transaction: + +```python +user : address = tx.origin +``` + +This design is problematic because `tx.origin` represents the original sender of the transaction, not necessarily the direct caller. In scenarios where a user interacts with another contract that in turn calls the `open` function, `tx.origin` will still point to the original user. + +### Internal Pre-conditions + +1. The victim must have granted an allowance to the core contract address for either `base_token` or `quote_token`, enabling the contract to transfer tokens on the victim's behalf. + +2. The victim must initiate a transaction with a smart contract that allows the attacker to indirectly call the `open` function at any point during the transaction. This can be easily achieved, for example, if the victim interacts with an automated router like Uniswap's that swaps tokens based on optimal trade routes. The attacker could create a malicious token involved in the trade, and within the `transfer` function of this malicious token, the attacker can execute a call to the Velor protocol's `open` function. Because `tx.origin` will still refer to the victim, the Velor protocol will perceive the transaction as initiated by the victim. + +### External Pre-conditions + +_No response_ + +### Attack Path + +If a user has set an unlimited allowance for `base_token` or `quote_token` to this contract, a malicious contract can execute a phishing attack by: + +1. Encouraging the user to initiate a transaction on the malicious contract. As explained in internal pre-conditions, it is not hard. +2. Having the malicious contract call the `open` function on `API.vy`. +3. Using `tx.origin` to transfer tokens from the user’s address to the contract, opening a leveraged position without the user's explicit consent. + +### Impact + +- **Unauthorized Position Opening:** An attacker can exploit this vulnerability to force a user to open leveraged positions, leading to unauthorized token movements and potential **financial losses** due to unexpected margin calls or liquidations. + +### PoC + +_No response_ + +### Mitigation + +To mitigate this vulnerability, replace `tx.origin` with a `user` parameter passed to the `open` function. This parameter should be set by the `msg.sender` when the `API.vy` contract calls the function. The updated function signature should look like this: + +```python +@external +def open( + user : address, + id : uint256, + base_token : address, + quote_token : address, + long : bool, + collateral0 : uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: +``` + +Additionally, the contract call on `api.vy:158` should be updated to: + +```python + return self.CORE.open(msg.sender, 1, base_token, quote_token, long, collateral0, leverage, ctx) +``` + +By passing `msg.sender` as the `user`, the function ensures that only the immediate caller is authorized to initiate the opening of a leveraged position, thus preventing phishing attacks. \ No newline at end of file diff --git a/001/004.md b/001/004.md new file mode 100644 index 0000000..e9200a5 --- /dev/null +++ b/001/004.md @@ -0,0 +1,70 @@ +Shaggy Smoke Mole + +Medium + +# Unsafe use of tx.origin in the open function will lead to unauthorized position closing + +### Summary + +The use of `tx.origin` in the [`close` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L272) of the Velar protocol exposes users to phishing attacks. If a user grants unlimited allowance to the contract for `base_token` or `quote_token`, a malicious contract can execute the `close` function on their behalf, resulting in unauthorized closure of leveraged positions. The root cause is the use of `tx.origin` to determine the user, which is unsafe as it can be manipulated by intermediate contracts. + +### Root Cause + +In [`core.vy:281`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L281), there is an unsafe use of `tx.origin`, which can lead to unauthorized closing of positions. The choice to use `tx.origin` might be due to the `API.vy` contract serving as an intermediary between the user and the `core.vy` contract. However, `tx.origin` is considered insecure because it references the original sender of a transaction, which can lead to unintended consequences when intermediary contracts are involved. The Solidity documentation provides a detailed explanation of why `tx.origin` is unsafe: [Solidity Security Considerations - `tx.origin`](https://docs.soliditylang.org/en/latest/security-considerations.html#tx-origin). + +The `close` function in `core.vy` allows users to close their leveraged positions by settling them against the pool. The function uses `tx.origin` to identify the user initiating the transaction: + +```python +user : address = tx.origin +``` + +This design is problematic because `tx.origin` represents the original sender of the transaction, not necessarily the direct caller. In scenarios where a user interacts with another contract that in turn calls the `close` function, `tx.origin` will still point to the original user. + +### Internal Pre-conditions + +1. The victim must have granted an allowance to the core contract address for either `base_token` or `quote_token`, enabling the contract to transfer tokens on the victim's behalf. + +2. The victim must initiate a transaction with a smart contract that allows the attacker to indirectly call the `close` function at any point during the transaction. This can be easily achieved, for example, if the victim interacts with an automated router like Uniswap's that swaps tokens based on optimal trade routes. The attacker could create a malicious token involved in the trade, and within the `transfer` function of this malicious token, the attacker can execute a call to the Velor protocol's `close` function. Because `tx.origin` will still refer to the victim, the Velor protocol will perceive the transaction as initiated by the victim. + +### External Pre-conditions + +_No response_ + +### Attack Path + +If a user has set an unlimited allowance for `base_token` or `quote_token` to this contract, a malicious contract can execute a phishing attack by: + +1. Encouraging the user to initiate a transaction on the malicious contract. As explained in internal pre-conditions, it is not hard. +2. Having the malicious contract call the `close` function on `API.vy`. +3. Using `tx.origin` to transfer tokens from the user’s address to the contract, closing a leveraged position without the user's explicit consent. + +### Impact + +- **Unauthorized Position Closure:** An attacker can exploit this vulnerability to force a user to close their leveraged positions, leading to unauthorized token movements and potential **financial losses** due to unexpected liquidation or settlement conditions. + +### PoC + +_No response_ + +### Mitigation + +To mitigate this vulnerability, replace `tx.origin` with a `user` parameter passed to the `close` function. This parameter should be set by the `msg.sender` when the `API.vy` contract calls the function. The updated function signature should look like this: + +```python +@external +def close( + user : address, + id : uint256, + base_token : address, + quote_token : address, + position_id : uint256, + ctx : Ctx) -> PositionValue: +``` + +Additionally, the contract call on `api.vy:183` should be updated to: + +```python + return self.CORE.close(msg.sender, 1, base_token, quote_token, position_id, ctx) +``` + +By passing `msg.sender` as the `user`, the function ensures that only the immediate caller is authorized to initiate the closing of a leveraged position, thus preventing phishing attacks. \ No newline at end of file diff --git a/001/005.md b/001/005.md new file mode 100644 index 0000000..4f0dd4b --- /dev/null +++ b/001/005.md @@ -0,0 +1,70 @@ +Shaggy Smoke Mole + +Medium + +# Unsafe use of tx.origin in the open function will lead to unauthorized liquidation + +### Summary + +The use of `tx.origin` in the [`liquidate` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L308) of the Velar protocol exposes users to phishing attacks. If a user grants unlimited allowance to the contract for `base_token` or `quote_token`, a malicious contract can execute the `liquidate` function on their behalf, resulting in unauthorized liquidation of positions. The root cause is the use of `tx.origin` to determine the liquidator, which is unsafe as it can be manipulated by intermediate contracts. + +### Root Cause + +In [`core.vy:318`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L318), there is an unsafe use of `tx.origin`, which can lead to unauthorized liquidation of positions. The choice to use `tx.origin` might be due to the `API.vy` contract serving as an intermediary between the user and the `core.vy` contract. However, `tx.origin` is considered insecure because it references the original sender of a transaction, which can lead to unintended consequences when intermediary contracts are involved. The Solidity documentation provides a detailed explanation of why `tx.origin` is unsafe: [Solidity Security Considerations - `tx.origin`](https://docs.soliditylang.org/en/latest/security-considerations.html#tx-origin). + +The `liquidate` function in `core.vy` allows users to liquidate a position by calling the function and transferring the liquidation fee to the liquidator. The function uses `tx.origin` to identify the user initiating the transaction: + +```python +user : address = tx.origin # liquidator +``` + +This design is problematic because `tx.origin` represents the original sender of the transaction, not necessarily the direct caller. In scenarios where a user interacts with another contract that in turn calls the `liquidate` function, `tx.origin` will still point to the original user. + +### Internal Pre-conditions + +1. The victim must have granted an allowance to the core contract address for either `base_token` or `quote_token`, enabling the contract to transfer tokens on the victim's behalf. + +2. The victim must initiate a transaction with a smart contract that allows the attacker to indirectly call the `liquidate` function at any point during the transaction. This can be easily achieved, for example, if the victim interacts with an automated router like Uniswap's that swaps tokens based on optimal trade routes. The attacker could create a malicious token involved in the trade, and within the `transfer` function of this malicious token, the attacker can execute a call to the Velor protocol's `liquidate` function. Because `tx.origin` will still refer to the victim, the Velor protocol will perceive the transaction as initiated by the victim. + +### External Pre-conditions + +_No response_ + +### Attack Path + +If a user has set an unlimited allowance for `base_token` or `quote_token` to this contract, a malicious contract can execute a phishing attack by: + +1. Encouraging the user to initiate a transaction on the malicious contract. As explained in internal pre-conditions, it is not hard. +2. Having the malicious contract call the `liquidate` function on `API.vy`. +3. Using `tx.origin` to transfer tokens from the user’s address to the contract, liquidating a position without the user's explicit consent. + +### Impact + +- **Unauthorized Position Liquidation:** An attacker can exploit this vulnerability to force a user to liquidate positions, leading to unauthorized token movements and potential **financial losses** due to unexpected liquidation conditions. + +### PoC + +_No response_ + +### Mitigation + +To mitigate this vulnerability, replace `tx.origin` with a `user` parameter passed to the `liquidate` function. This parameter should be set by the `msg.sender` when the `API.vy` contract calls the function. The updated function signature should look like this: + +```python +@external +def liquidate( + user : address, + id : uint256, + base_token : address, + quote_token : address, + position_id : uint256, + ctx : Ctx) -> PositionValue: +``` + +Additionally, the contract call on `api.vy:211` should be updated to: + +```python + return self.CORE.liquidate(msg.sender, 1, base_token, quote_token, position_id, ctx) +``` + +By passing `msg.sender` as the `user`, the function ensures that only the immediate caller is authorized to initiate the liquidation of a position, thus preventing phishing attacks. \ No newline at end of file diff --git a/001/031.md b/001/031.md new file mode 100644 index 0000000..39bdea6 --- /dev/null +++ b/001/031.md @@ -0,0 +1,62 @@ +Great Pickle Worm + +Medium + +# Use `msg.sender` instead of `tx.origin` + +### Summary + +In the `mint()`, `burn()`, `open()`, `close()`, and `liquidate()` functions, the protocol uses `tx.origin` to identify the user. Using `tx.origin` makes users more vulnerable to phishing attacks. + +### Root Cause +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L166 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L202 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L241 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L281 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L318 + +The protocol uses `tx.origin` instead of `msg.sender` as the user to execute the subsequent operations. +```solidity +user : address = tx.origin + +``` + +### Internal pre-conditions + + + +### External pre-conditions + + + +### Attack Path + +1. A malicious attacker deploys a contract. +2. A user interacts with the malicious contract. +3. Within the malicious contract’s function, it calls `mint()`, `burn()`, `open()`, `close()`, or similar operations. + +As a result, the user's assets can be manipulated without their authorization. + +### Impact + +The user is subjected to a phishing attack. + +### PoC + +@external +def mint( + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + base_amt : uint256, + quote_amt : uint256, + ctx : Ctx) -> uint256: + + self._INTERNAL() + + user : address = tx.origin + +### Mitigation + +Use `msg.sender` instead of `tx.origin`. \ No newline at end of file diff --git a/001/082.md b/001/082.md new file mode 100644 index 0000000..018f37d --- /dev/null +++ b/001/082.md @@ -0,0 +1,34 @@ +Kind Banana Sloth + +High + +# Usage of `tx.origin` to determine the user is prone to attacks + +## Summary +Usage of `tx.origin` to determine the user is prone to attacks + +## Vulnerability Detail +Within `core.vy` to user on whose behalf it is called is fetched by using `tx.origin`. +```vyper + self._INTERNAL() + + user : address = tx.origin +``` + +This is dangerous, as any time a user calls/ interacts with an unverified contract, or a contract which can change implementation, they're put under risk, as the contract can make a call to `api.vy` and act on user's behalf. + +Usage of `tx.origin` would also break compatibility with Account Abstract wallets. + +## Impact +Any time a user calls any contract on the BOB chain, they risk getting their funds lost. +Incompatible with AA wallets. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L166 + +## Tool used + +Manual Review + +## Recommendation +Instead of using `tx.origin` in `core.vy`, simply pass `msg.sender` as a parameter from `api.vy` \ No newline at end of file diff --git a/001/123.md b/001/123.md new file mode 100644 index 0000000..44a98a5 --- /dev/null +++ b/001/123.md @@ -0,0 +1,97 @@ +Bent Teal Wolf + +Medium + +# The use of `tx.origin` may cause a trader to lose funds + +## Summary + +Traders interact with the protocol through calls to the `api.vy` contract. Every interaction is then forwarded to the `core.vy` contract which is responsible for identifying the trader's address, pulling tokens from the address and performing the desired operation. + +## Vulnerability Detail + +The issue comes from the fact that the `core.vy` contract identifies the trader using `tx.origin`. This can lead to unexpected and undesirable behaviors especially when using `Smart Contract Wallets` (`Gnosis Safe` ; `Abstract Accounts` ; ...) + + + +```python +@external +def open( + id : uint256, + base_token : address, + quote_token : address, + long : bool, + collateral0 : uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + + self._INTERNAL() + +@> user : address = tx.origin +``` + +Let the following scenario : +- a trader owns a `Smart Contract Wallet`, with 3 other traders, that holds 4,000 USDT +- the trader also holds 5,000 USDT +- the trader tries to use the protocol and `approve()` the `core.vy` contract to pull `type(uint256).max` amount of `USDT` +- the trader opens a position with 100 USDT +- now the traders that own the smart wallet are confident and want to commit 3,000 USDT from their smart wallet +- they all sign the required set of transactions to `open()` a position in Velar (e.g. `USDT.approve()`) +- the initial trader triggers the set of transactions with the intention to open a position for the smart wallet +- Velar identifies the trader with `tx.origin` and performs the `open()` for the trader's address, not the smart wallet + +The initial trader commited 3,000 USDT from his own tokens in Velar + +He is now subjected to price fluctuation of the market and has an additional position opened, which he did not intend to. + +## Impact + +This design can potentially cause a loss of funds for the protocol users, performing internal operations with the address that initiated the transaction rather than a potential third party contract. + +## Tool used + +Manual Review + +## Recommendation + +In `api.vy` retrieve `msg.sender` and forward this address as a new parameter in all `core.vy` functions. + +For example : + +*`api.vy`* + +```diff +@external +def open( + base_token : address, + quote_token : address, + long : bool, + collateral0 : uint256, + leverage : uint256, # @audit does setting it to 0 break something? A : can't set it to 0 + desired : uint256, + slippage : uint256, + payload : Bytes[224] +) -> PositionState: + + ctx: Ctx = self.CONTEXT(base_token, quote_token, desired, slippage, payload) +- return self.CORE.open(1, base_token, quote_token, long, collateral0, leverage, ctx) ++ return self.CORE.open(1, base_token, quote_token, long, collateral0, leverage, msg.sender, ctx) +``` + +*`core.vy`* + +```diff +@external +def open( + id : uint256, + base_token : address, + quote_token : address, + long : bool, + collateral0 : uint256, + leverage : uint256, ++ user : address, + ctx : Ctx) -> PositionState: + + self._INTERNAL() +- user : address = tx.origin +``` diff --git a/001/127.md b/001/127.md new file mode 100644 index 0000000..a2f5c51 --- /dev/null +++ b/001/127.md @@ -0,0 +1,48 @@ +Innocent Wooden Capybara + +Medium + +# Use of `tx.origin` Allows Malicious Contracts to Execute Unauthorized Actions + +### Summary + +The use of `tx.origin` in the protocol's smart contracts introduces a critical vulnerability that allows any contract the user interacts with to impersonate the user and perform unauthorized actions within the protocol. This can lead to a wide range of attacks, including the manipulation of user funds, unauthorized liquidations, or opening and closing positions without the user's consent. +While maybe people might argue this is the user's fault but a good protocol should always check who's the msg.sender to provide more security + +### Root Cause + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L166 +The root cause is the use of `tx.origin` instead of msg.sender for user authentication in key protocol functions. `tx.origin` always refers to the address that originally initiated the transaction, regardless of which contracts were called in between. This means that if a user interacts with a malicious contract, that contract can trigger any function on the protocol with tx.origin still pointing to the user's address, effectively allowing the malicious contract to act on behalf of the user. + +### Internal pre-conditions + +1.The smart contract contains functions (`mint`, `burn`, `open`, `close`, `liquidate`, etc.) that rely on `tx.origin` to verify the identity of the user. +2. The functions perform critical actions such as transferring funds, opening or closing positions, or liquidating assets based on the `tx.origin` check. + +### External pre-conditions + +1. A user interacts with an untrusted or malicious smart contract. +2. The malicious smart contract initiates a transaction that calls the protocol's functions using the user's tx.origin. + +### Attack Path + + +1. An attacker deploys a malicious contract designed to interact with the protocol's smart contracts. This malicious contract could be designed to execute unauthorized actions like opening, closing, or liquidating positions on behalf of any user who interacts with it. +2. A user unknowingly interacts with the malicious contract. +3. The malicious contract, while being executed by the user, calls the protocol's smart contract functions (mint, burn, open, close, liquidate, etc.) using the user's tx.origin. +4.Since `tx.origin` returns the user's address, the protocol's contract mistakenly treats the call as if it was directly initiated by the user. +5.The malicious contract can perform any action that the user is allowed to perform, such as transferring funds, opening or closing positions, or even liquidating assets, without the user's explicit consent. + +### Impact + +1.Unauthorized Access: Any contract the user interacts with can perform actions on behalf of the user in the protocol, leading to unauthorized access and control over user funds and positions. +2.Financial Losses: Attackers can open, close, or liquidate positions without the user's knowledge, potentially leading to significant financial losses(closing a negative position before the asset bounce back up). +3.Loss of User Trust: The protocol could lose user trust due to the ability of third-party contracts to execute actions on their behalf without explicit permission. + +### PoC + +_No response_ + +### Mitigation + +Use `msg.sender` in the arguments, this way when the api calls the core you have the adress of user in the parameters \ No newline at end of file diff --git a/002/017.md b/002/017.md new file mode 100644 index 0000000..e1beef1 --- /dev/null +++ b/002/017.md @@ -0,0 +1,43 @@ +Amateur Nylon Canary + +Medium + +# Healthy positions may be liquidated because of inappropriate judgment + +## Summary +Healthy positions may be liquidated in one special case + +## Vulnerability Detail +In `is_liquidatable()`, there are some comments about the position's health. `A position becomes liquidatable when its current value is less than + a configurable fraction of the initial collateral`. +The problem is that when `pnl.remaining` equals `required`, this position should be healthy and cannot be liquidated. But this edge case will be though as unhealthy and can be liquidated. + +```vyper +@external +@view +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + """ + A position becomes liquidatable when its current value is less than + a configurable fraction of the initial collateral, scaled by + leverage. + """ + percent : uint256 = self.PARAMS.LIQUIDATION_THRESHOLD * position.leverage + required: uint256 = (position.collateral * percent) / 100 + return not (pnl.remaining > required) +``` + +## Impact +Healthy positions may be liquidated and take some loss. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L117-L138 + +## Tool used + +Manual Review + +## Recommendation +```diff +- return not (pnl.remaining > required) ++ return not (pnl.remaining >= required) +``` \ No newline at end of file diff --git a/002/057.md b/002/057.md new file mode 100644 index 0000000..182d757 --- /dev/null +++ b/002/057.md @@ -0,0 +1,53 @@ +Scrawny Boysenberry Mammoth + +Medium + +# `is_liquidatable` function in Params.vy is open to unfair liquidation of users + +## Summary +[is_liquidatable](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L119) function in `Params.vy` is not handling an edge case, which will result in liquidating users +## Vulnerability Detail +The protocol allows anyone to liquidate a position by calling the function [liquidate](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L186) in api.vy, which contains a chain of calls. It firstly calls to the same named function in Core.vy. Core's liquidate function makes a [call](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L325) to `is_liquidatable()` function in Positions.vy, which is defined as: +```python +@external +@view +def is_liquidatable(id: uint256, ctx: Ctx) -> bool: + """ + Determines whether position `id` is liquidatable at `ctx.price`. + """ + v: PositionValue = Positions(self).value(id, ctx) + return self.PARAMS.is_liquidatable(v.position, v.pnl) +``` +In this function there is another call made to `is_liquidatable()` in Params.vy. Now let's have a look on this function: +```python +@external +@view +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + """ + A position becomes liquidatable when its current value is less than + a configurable fraction of the initial collateral, scaled by + leverage. + """ + percent : uint256 = self.PARAMS.LIQUIDATION_THRESHOLD * position.leverage + required: uint256 = (position.collateral * percent) / 100 + return not (pnl.remaining > required) #@audit not handles equality correctly, unfair liq for user +``` +Both from the comment in the function and the check in the end, it can be seen the position will be liquidated when `pnl.remaining = required`. However , logically if the remaining value is equal to the required value, the position should not be liquidated. Imagine a user has realized his position is at the threshold and decided to take an action, but before he takes an action, the position may be liquidated due the wrong check. +## Impact +Users will be faced by unfair liquidation. The impact is high, the likelihood is low due the edge case. Hence ı believe it deserves medium severity +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L119 +## Tool used +Manual Review +## Recommendation +Adjust the check in params.vy:is_liquidatable() as : +```python +@external +@view +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + percent : uint256 = self.PARAMS.LIQUIDATION_THRESHOLD * position.leverage + required: uint256 = (position.collateral * percent) / 100 +- return not (pnl.remaining > required) ++ return pnl.remaining < required + +``` \ No newline at end of file diff --git a/002/067.md b/002/067.md new file mode 100644 index 0000000..3728d8d --- /dev/null +++ b/002/067.md @@ -0,0 +1,39 @@ +Joyful Punch Fly + +Medium + +# wrong implement of "is_liquidatable" + +## Summary +if pnl.remaining ==required then it will be liquidatable. +## Vulnerability Detail +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + """ + A position becomes liquidatable when its current value is less than + a configurable fraction of the initial collateral, scaled by + leverage. + """ + # Assume liquidation bots are able to check and liquidate positions + # every N seconds. + # We would like to avoid the situation where a position's value goes + # negative (due to price fluctuations and fee obligations) during + # this period. + # Roughly, the most a positions value can change is + # leverage * asset price variance + fees + # If N is small, this expression will be ~the price variance. + # E.g. if N = 60 and we expect a maximal price movement of 1%/minute + # we could set LIQUIDATION_THRESHOLD to 1 (* some constant to be on the + # safe side). + percent : uint256 = self.PARAMS.LIQUIDATION_THRESHOLD * position.leverage + required: uint256 = (position.collateral * percent) / 100 +@>> return not (pnl.remaining > required) +## Impact +pnl.remaining ==required then it will be liquidatable. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L138 +## Tool used + +Manual Review + +## Recommendation + return not (pnl.remaining >= required) \ No newline at end of file diff --git a/002/098.md b/002/098.md new file mode 100644 index 0000000..7de0da6 --- /dev/null +++ b/002/098.md @@ -0,0 +1,151 @@ +Mammoth Blonde Walrus + +High + +# params.vy::is_liquidatable allows to liquidate positions when pnl.remaining equals required value + +## Summary +params.vy::is_liquidatable allows to liquidate positions when pnl.remaining equals required value because comparission operator doesnt check when pnl.remaining equals to required, allowing to liquidate healthy positions + +## Vulnerability Detail +params::is_liquidatable comparission doesnt check when pnl.remaining equals to required, so it allows to liquidate healthy position +```solidity +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + //... snippet + percent : uint256 = self.PARAMS.LIQUIDATION_THRESHOLD * position.leverage + required: uint256 = (position.collateral * percent) / 100 + return not (pnl.remaining > required) # <@= It should be >= +``` +Assume a position p with pnl.remaining = required then is_liquidatable will return: +```solidity + return not (pnl.remaining > required) = return not (False) = return True +``` +So will return true for a healthy position and breaks code invariant in this function as stated in is_liquidatable's source code comment: +```solidity +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + """ + A position becomes liquidatable when its current value is less than + a configurable fraction of the initial collateral, scaled by + leverage. + """ +``` +The following proof of concept shows an example scenario +This PoC defines a position with pnl.remaining = required. +If pnl.remaining is equal to required it shouldnt be liquidatable, however params.vy@is_liquidatable returns that it is. + +For this example, params.vy@is_liquidatable calculated values are: + percent: 1 + position.collateral: 10_000_000 + required: 100_000 + pnl.remaining: 100_000 + +In this case pnl.remaining is equal to required, however to check if a position is liquidatable this method calculates: + pnl.remaining > required ie + 100000 > 100000 = False +So it returns: + not (pnl.remaining > required) => not (False) => True +Ie returns that positions are liquidatable when pnl.remaining = required +So this allows to liquidate positions that shouldnt be liquidable + +Create test_liquidatable_pos.py file in test directory: +```python +from ape import accounts +import pytest +from ape.logging import logger +from conftest import tokens + +# Params.vy +# helpers +BASE_FEE = 1 # * 1_000_000 + + +Status = { + 'OPEN' : 1, + 'CLOSED' : 2, + 'LIQUIDATABLE': 4, +} + +# dummy values +POOL = { + 'id' : 1, + 'symbol' : 'VEL-STX', + 'base_token' : 0, #VEL, + 'quote_token' : 0, #STX, + 'lp_token' : 0, #lpToken, + 'base_reserves' : 0, + 'quote_reserves' : 0, + 'base_interest' : 0, + 'quote_interest' : 0, + 'base_collateral' : 0, + 'quote_collateral' : 0, +} + +POS = { + 'id' : 1, + 'pool' : 1, + 'user' : 0, + 'status' : Status['OPEN'], + 'long' : True, + 'collateral' : 1, + 'leverage' : 1, + 'interest' : 1, + 'entry_price' : 1, + 'exit_price' : 0, + 'opened_at' : 1, + 'closed_at' : 0, + 'collateral_tagged' : tokens(0, 0), + 'interest_tagged' : tokens(0, 0), +} + +PNL = { + 'loss' : 0, + 'profit' : 0, + 'remaining': 0, + 'payout' : 0, +} + +# helpers +def pool(bc, qc): + return { + **POOL, + 'base_reserves' : bc*100, + 'quote_reserves' : qc*100, + 'base_interest' : bc//100, + 'quote_interest' : qc//100, + 'base_collateral' : bc, + 'quote_collateral' : qc, + } + +# uses position.collateral, position.leverage & pnl.remaining +def test_is_liquidatable(params): + b: bool = params.is_liquidatable( + { **POS, 'collateral': 10_000_000 }, + { **PNL, 'remaining' : 100_000 }, + ) + print("is_liquidatable => ",b) +``` +Exec test with: +```bash +ape test -k test_is_liquidatable -s tests/test_liquidatable_pos.py +``` +Observe that it returns that this position is liquidatable when it should return False +This method is used by position::is_liquidatable and by api::liquidate methods so, it will allow to liquidate healthy positions + +## Impact +Allows to liquidate unintended positions + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L138 + +## Tool used + +Manual Review + +## Recommendation +Modify is_liquidatable function to: +```solidity +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + percent : uint256 = self.PARAMS.LIQUIDATION_THRESHOLD * position.leverage + required: uint256 = (position.collateral * percent) / 100 + return not (pnl.remaining >= required) +``` \ No newline at end of file diff --git a/002/128.md b/002/128.md new file mode 100644 index 0000000..df53031 --- /dev/null +++ b/002/128.md @@ -0,0 +1,33 @@ +Passive Hemp Chipmunk + +Medium + +# Premature Liquidation Due to Wrong Check in `params.is_liquidatable()` + +## Summary +The `is_liquidatable` function in `params.vy` incorrectly considers positions as liquidatable when the remaining collateral exactly equals the required amount, leading to unnecessary liquidations. + +## Vulnerability Detail +[params.vy#L138](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L138) +```vyper +return not (pnl.remaining > required) +``` + +This condition returns true when `pnl.remaining` is less than or equal to required. As a result, positions where the remaining collateral exactly matches the required amount `(pnl.remaining == required)` are marked as liquidatable. + +## Impact +Users whose positions have exactly the required collateral can be liquidated. This leads to unnecessary losses for these users. It also creates opportunities for malicious actors to trigger liquidations on positions that should still be considered safe. + +## Code Snippet +[params.vy#L138](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L138) + +## Tool used +Manual Review + +## Recommendation +Modify the condition to allow positions with exactly the required collateral to remain safe: + +```diff +- return not (pnl.remaining > required) ++ return pnl.remaining < required +``` \ No newline at end of file diff --git a/003/083.md b/003/083.md new file mode 100644 index 0000000..e816ea0 --- /dev/null +++ b/003/083.md @@ -0,0 +1,70 @@ +Hot Purple Buffalo + +Medium + +# Funding Paid != Funding Received + +## Summary +Due to special requirements around receiving funding fees for a position, the funding fees received can be less than that paid. These funding fee payments are still payed, but a portion of them will not be withdrawn, and become stuck funds. This also violates the contract specification that `sum(funding_received) = sum(funding_paid)`. + +## Vulnerability Detail +In [`calc_fees`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L257-L263) there are two special conditions that impact a position's receipt of funding payments: + +```vyper + # When there are negative positions (liquidation bot failure): + avail : uint256 = pool.base_collateral if pos.long else ( + pool.quote_collateral ) + # 1) we penalize negative positions by setting their funding_received to zero + funding_received: uint256 = 0 if remaining == 0 else ( + # 2) funding_received may add up to more than available collateral, and + # we will pay funding fees out on a first come first serve basis + min(fees.funding_received, avail) ) +``` + +If the position has run out of collateral by the time it is being closed, he will receive none of his share of funding payments. Additionally, if the available collateral is not high enough to service the funding fee receipt, he will receive only the greatest amount that is available. + +These funding fee payments are still always made ([deducted](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L250) from remaining collateral), whether they are received or not: + +```vyper + c1 : Val = self.deduct(c0, fees.funding_paid) +``` + +When a position is closed under most circumstances, the pool will have enough collateral to service the corresponding fee payment: + +```vyper +# longs +base_collateral : [self.MATH.MINUS(fees.funding_received)], +quote_collateral: [self.MATH.PLUS(fees.funding_paid), + self.MATH.MINUS(pos.collateral)], +... +# shorts +base_collateral : [self.MATH.PLUS(fees.funding_paid), # <- + self.MATH.MINUS(pos.collateral)], +quote_collateral: [self.MATH.MINUS(fees.funding_received)], +``` + +When positions are closed, the original collateral (which was placed into the pool upon opening) is removed. However, the amount of funding payments a position made is added to the pool for later receipt. Thus, when positions are still open there is enough position collateral to fulfill the funding fee payment and when they close the funding payment made by that position still remains in the pool. + +Only when the amount of funding a position paid exceeded its original collateral, will there will not be enough collateral to service the receipt of funding fees, as alluded to in the comments. However, it's possible for users to pay the full funding fee, but if the borrow fee exceeds the collateral balance remaining thereafter, they will not receive any funding fees. As a result, it's possible for funding fees to be paid which are never received. + +Further, even in the case of funding fee underpayment, setting the funding fee received to 0 does not remedy this issue. The funding fees which he underpaid were in a differing token from those which he would receive, so this only furthers the imbalance of fees received to paid. + +## Impact +`core.vy` includes a specification for one of the invariants of the protocol: +```vyper +# * funding payments match +# sum(funding_received) = sum(funding_paid) +``` + +This invariant is clearly broken as some portion of paid funding fees will not be received under all circumstances, so code is not to spec. This will also lead to some stuck funds, as a portion of the paid funding fees will never be deducted from the collateral. This can in turn lead to dilution of fees for future funding fee recipients, as the payments will be distributed evenly to the entire collateral including these stuck funds which will never be removed. + +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +Consider an alternative method of accounting for funding fees, as there are many cases under the current accounting where fees received/paid can fall out of sync. + +For example, include a new state variable that explicitly tracks unpaid funding fee payments and perform some pro rata or market adjustment to future funding fee recipients, specifically for *that token*. \ No newline at end of file diff --git a/003/086.md b/003/086.md new file mode 100644 index 0000000..e8e3ff1 --- /dev/null +++ b/003/086.md @@ -0,0 +1,74 @@ +Hot Purple Buffalo + +Medium + +# Positions Can Fail to Close Due to Accounting Failure + +## Summary +Due to how funding fee payments are accounted for in the system, closing a position can fail due to insufficient collateral balance. This violates one of the system invariants that `any open position can always be closed`. It can also interfere with liquidations and cause loss of user funds in the event that the position moves unfavorably in the meantime. + +## Vulnerability Detail +Due to the asynchronous nature of how funding fees are paid from one side of a pool to the other, it's possible to end up in a situation where there is not a large enough collateral balance to satisfy the return of collateral to a user's positions. In fact, this is even acknowledged and partially accounted for within [`calc_fees`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L244): + +```vyper + fees : SumFees = self.FEES.calc( + pos.pool, pos.long, pos.collateral, pos.opened_at) +... + avail : uint256 = pool.base_collateral if pos.long else ( + pool.quote_collateral ) +... + funding_received: uint256 = 0 if remaining == 0 else ( + min(fees.funding_received, avail) ) +``` + +This funding received is in turn [deducted](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L195) from the collateral of the other side of the pool: + +```vyper +# long + base_collateral : [self.MATH.MINUS(fees.funding_received)], +... +# short + quote_collateral: [self.MATH.MINUS(fees.funding_received)], +``` + +However, as acknowledged above due to negative positions the funding receiving can exceed the paid amount, which can result in 0 collateral tokens remaining in the pool for the other side. + +At this stage if a position on the other side of the pool tried to close, it would almost always fail: + +```vyper +# long + quote_collateral: [self.MATH.PLUS(fees.funding_paid), + self.MATH.MINUS(pos.collateral)], +... +# short + base_collateral : [self.MATH.PLUS(fees.funding_paid), + self.MATH.MINUS(pos.collateral)], +``` + +since the position's collateral which is subtracted is always greater or equal to the funding fees it paid. Ironically the only positions which can still be withdrawn at this time are those which have underpaid their funding fees, so `fees.funding_paid` added equals `pos.collateral` subtracted. + +## Impact +`core.vy includes a specification for one of the invariants for this protocol: + +```vyper +# - the protocol handles the accounting needed to maintain the sytem invariants: +# * any open position can always be closed +# close(open(collateral)) = collateral + pnl - fees +``` + +This invariant is broken as there are some edge cases where positions are unable to close (they are not always able to). Thus the current code is not to spec. Additionally, closing positions is a time-sensitive operation, especially related to liquidations, and there is the possibility for a position to move unfavorably during the period in which it cannot be closed. + +This can be alleviated by additional depositors on the other side of the pool bringing in additional collateral. Additionally, it's not likely to occur, however it is acknowledged by the team as a possibility due to the handling of negative positions in funding fee payments. + +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +In order to avoid this scenario the funding payment a user is receiving should never exceed the collateral balance of the pool. This accounting mismatch should be accounted for far in advance of this occurring. + +Consider modifying the funding payment / receipt structure, either by changing its accounting or making more synchronous payments with receipts. + +These approaches can be implemented in a number of ways. For example, when funding fees are underpaid, track the exact quantity of underpayment and apply a pro rata / market adjustment to future receipts of that token funding payment. \ No newline at end of file diff --git a/003/093.md b/003/093.md new file mode 100644 index 0000000..8516c31 --- /dev/null +++ b/003/093.md @@ -0,0 +1,36 @@ +Kind Banana Sloth + +Medium + +# If position goes to 0, the funding fees it should've received are never redistributed and are forever stuck + +## Summary +If position goes to 0, the funding fees it should've received are never redistributed and are forever stuck + +## Vulnerability Detail +If a user position's collateral goes to 0, the user loses all of it, including any funding fees they should've received +```vyper + # 1) we penalize negative positions by setting their funding_received to zero + funding_received: uint256 = 0 if remaining == 0 else ( + # 2) funding_received may add up to more than available collateral, and + # we will pay funding fees out on a first come first serve basis + min(fees.funding_received, avail) ) +``` + +However, the problem is that the amount they should've received is later not subtracted from the collateral and will remain permanently stuck. + +For this reason, we can see that `FeesPaid` has a `funding_received_want` arg which is intended to be used for that exact reason but is actually never used. + +## Impact +Loss of funds + + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L268 + +## Tool used + +Manual Review + +## Recommendation +Use the intended `funding_received_want` and subtract it from the collateral so it is not stuck and instead redistributed to LP providers \ No newline at end of file diff --git a/003/096.md b/003/096.md new file mode 100644 index 0000000..5983bd7 --- /dev/null +++ b/003/096.md @@ -0,0 +1,43 @@ +Kind Banana Sloth + +High + +# User could have impossible to close position if funding fees grow too big. + +## Summary +User could have impossible to close position if funding fees grow too big. + +## Vulnerability Detail +In order to prevent positions from becoming impossible to be closed due to funding fees surpassing collateral amount, there's the following code which pays out funding fees on a first-come first-serve basis. +```vyper + # 2) funding_received may add up to more than available collateral, and + # we will pay funding fees out on a first come first serve basis + min(fees.funding_received, avail) ) +``` + +However, this wrongly assumes that the order of action would always be for the side which pays funding fees to close their position before the side which claims the funding fee. + +Consider the following scenario: +1. There's an open long (for total collateral of `X`) and an open short position. Long position pays funding fee to the short position. +2. Eventually the funding fee grows larger than the whole long position (`X + Y`). it is due liquidation, but due to bot failure is not yet liquidated (which based on comments is expected and possible behaviour) +3. A new user opens a new long position, once with `X` collateral. (total quote collateral is currently 2X) +4. The original long is closed. This does not have an impact on the total quote collateral, as it is increased by the `funding_paid` which in our case will be counted as exactly as much as the collateral (as in these calculations it cannot surpass it). And it then subtracts that same quote collateral. +5. The original short is closed. `funding_received` is calculated as `X + Y` and therefore that's the amount the total quote collateral is reduced by. The new total quote collateral is `2X - (X + Y) = X - Y`. +6. Later when the user attempts to close their position it will fail as it will attempt subtracting `(X - Y) - X` which will underflow. + +Marking this as High, as a user could abuse it to create a max leverage position which cannot be closed. Once it is done, because the position cannot be closed it will keep on accruing funding fees which are not actually backed up by collateral, allowing them to double down on the attack. + +## Impact +Loss of funds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L250 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L263 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L211 + +## Tool used + +Manual Review + +## Recommendation +Fix is non-trivial. \ No newline at end of file diff --git a/004/010.md b/004/010.md new file mode 100644 index 0000000..22f9c19 --- /dev/null +++ b/004/010.md @@ -0,0 +1,124 @@ +Shaggy Mocha Rat + +Medium + +# A user may open more positions than the protocol intends. + +## Summary +The protocol limits a user to open `max_position` positions. However, a user can create more positions than this threshold. + +## Vulnerability Detail +Let's look at `open()` of `positions.vy`. +```vyper +@external +def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + self._INTERNAL() + + # Opening a position with leverage can be thought of as purchasing + # an amplified number of tokens. + # Longs buy base tokens with quote collateral and shorts buy quote + # tokens with base collateral (alternatively, longs buy base and shorts + # sell base). + virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( + self.MATH.base_to_quote(collateral, ctx) ) + interest : uint256 = virtual_tokens * leverage + + pos: PositionState = PositionState({ + id : self.next_position_id(), + pool : pool, + user : user, + status : Status.OPEN, + long : long, + collateral : collateral, + leverage : leverage, + interest : interest, + entry_price: ctx.price, + exit_price : 0, + opened_at : block.number, + closed_at : 0, + + collateral_tagged: Tokens({base: 0, quote: collateral}) if long else ( + Tokens({base: collateral, quote: 0}) ), + interest_tagged : Tokens({base: interest, quote: 0}) if long else ( + Tokens({base: 0, quote: interest}) ), + }) + ps: PoolState = self.POOLS.lookup(pool) + +@ assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + +@ self.insert_user_position(user, pos.id) + return self.insert(pos) +``` +As you can see above, user can open `max_position` positions. +Let's look at the scenario below. +Alice opened `max_position` positions and now is going to open another position. +She calls `open()` of `api.vy`. +But there not occurs revert and then she opens `max_positions + 1` position. +## Impact +User can open more positions than the protocol intends. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L107-L151 + +## Tool used +Manual Review + +Manual Review + +## Recommendation +Pls modify the `open()` of `positions.vy` as below +```vyper +@external +def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + self._INTERNAL() + + # Opening a position with leverage can be thought of as purchasing + # an amplified number of tokens. + # Longs buy base tokens with quote collateral and shorts buy quote + # tokens with base collateral (alternatively, longs buy base and shorts + # sell base). + virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( + self.MATH.base_to_quote(collateral, ctx) ) + interest : uint256 = virtual_tokens * leverage + + pos: PositionState = PositionState({ + id : self.next_position_id(), + pool : pool, + user : user, + status : Status.OPEN, + long : long, + collateral : collateral, + leverage : leverage, + interest : interest, + entry_price: ctx.price, + exit_price : 0, + opened_at : block.number, + closed_at : 0, + + collateral_tagged: Tokens({base: 0, quote: collateral}) if long else ( + Tokens({base: collateral, quote: 0}) ), + interest_tagged : Tokens({base: interest, quote: 0}) if long else ( + Tokens({base: 0, quote: interest}) ), + }) + ps: PoolState = self.POOLS.lookup(pool) + +- assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + + self.insert_user_position(user, pos.id) ++ assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + return self.insert(pos) +``` \ No newline at end of file diff --git a/004/024.md b/004/024.md new file mode 100644 index 0000000..a9699fe --- /dev/null +++ b/004/024.md @@ -0,0 +1,133 @@ +Fantastic Rusty Cottonmouth + +Medium + +# A user might open more positions than the protocol is designed to allow. + +### Summary + +The protocol restricts a user to a maximum of max_position positions. However, a user can still open positions beyond this limit. + +### Root Cause + +_No response_ + +### Internal pre-conditions + +Already user opened `max_position` positions. + +### External pre-conditions + +_No response_ + +### Attack Path + +Let's look at `open()` of `positions.vy`. +```vyper +@external +def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + self._INTERNAL() + + # Opening a position with leverage can be thought of as purchasing + # an amplified number of tokens. + # Longs buy base tokens with quote collateral and shorts buy quote + # tokens with base collateral (alternatively, longs buy base and shorts + # sell base). + virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( + self.MATH.base_to_quote(collateral, ctx) ) + interest : uint256 = virtual_tokens * leverage + + pos: PositionState = PositionState({ + id : self.next_position_id(), + pool : pool, + user : user, + status : Status.OPEN, + long : long, + collateral : collateral, + leverage : leverage, + interest : interest, + entry_price: ctx.price, + exit_price : 0, + opened_at : block.number, + closed_at : 0, + + collateral_tagged: Tokens({base: 0, quote: collateral}) if long else ( + Tokens({base: collateral, quote: 0}) ), + interest_tagged : Tokens({base: interest, quote: 0}) if long else ( + Tokens({base: 0, quote: interest}) ), + }) + ps: PoolState = self.POOLS.lookup(pool) + +@ assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + +@ self.insert_user_position(user, pos.id) + return self.insert(pos) +``` +As you can see above, user can open `max_position` positions. + +### Impact + +The protocol restricts a user to a maximum of max_position positions. However, a user can still open positions beyond this limit. + +### PoC + +_No response_ + +### Mitigation + +It is recommended to modify the [`open()`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L107-L151) as following +```vyper +@external +def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + self._INTERNAL() + + # Opening a position with leverage can be thought of as purchasing + # an amplified number of tokens. + # Longs buy base tokens with quote collateral and shorts buy quote + # tokens with base collateral (alternatively, longs buy base and shorts + # sell base). + virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( + self.MATH.base_to_quote(collateral, ctx) ) + interest : uint256 = virtual_tokens * leverage + + pos: PositionState = PositionState({ + id : self.next_position_id(), + pool : pool, + user : user, + status : Status.OPEN, + long : long, + collateral : collateral, + leverage : leverage, + interest : interest, + entry_price: ctx.price, + exit_price : 0, + opened_at : block.number, + closed_at : 0, + + collateral_tagged: Tokens({base: 0, quote: collateral}) if long else ( + Tokens({base: collateral, quote: 0}) ), + interest_tagged : Tokens({base: interest, quote: 0}) if long else ( + Tokens({base: 0, quote: interest}) ), + }) + ps: PoolState = self.POOLS.lookup(pool) + +- assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + + self.insert_user_position(user, pos.id) ++ assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + return self.insert(pos) +``` \ No newline at end of file diff --git a/004/046.md b/004/046.md new file mode 100644 index 0000000..3f4998d --- /dev/null +++ b/004/046.md @@ -0,0 +1,75 @@ +Joyful Punch Fly + +Medium + +# More than MAX_POSITIONS can be open + +## Summary +we can open more than MAX_POSITIONS in open as we are checking first ."get_nr_user_positions(user) <= MAX_POSITIONS" and then we are adding a new position that do open position more than MAX_POSITIONS. +## Vulnerability Detail +def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + self._INTERNAL() + + # Opening a position with leverage can be thought of as purchasing + # an amplified number of tokens. + # Longs buy base tokens with quote collateral and shorts buy quote + # tokens with base collateral (alternatively, longs buy base and shorts + # sell base). + virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( + self.MATH.base_to_quote(collateral, ctx) ) + interest : uint256 = virtual_tokens * leverage + + pos: PositionState = PositionState({ + id : self.next_position_id(), + pool : pool, + user : user, + status : Status.OPEN, + long : long, + collateral : collateral, + leverage : leverage, + interest : interest, + entry_price: ctx.price, + exit_price : 0, + opened_at : block.number, + closed_at : 0, + + collateral_tagged: Tokens({base: 0, quote: collateral}) if long else ( + Tokens({base: collateral, quote: 0}) ), + interest_tagged : Tokens({base: interest, quote: 0}) if long else ( + Tokens({base: 0, quote: interest}) ), + }) + ps: PoolState = self.POOLS.lookup(pool) + +@>> assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + +@>> self.insert_user_position(user, pos.id) + return self.insert(pos) + + + +@ def get_nr_user_positions(user: address) -> uint256: + return len(self.USER_POSITIONS[user]) + +@ def insert_user_position(user: address, id: uint256) -> bool: + # initialized to empty + ids: DynArray[uint256, 500] = self.USER_POSITIONS[user] + ids.append(id) + self.USER_POSITIONS[user] = ids + return True +## Impact +we can open more than MAX_POSITIONS in open function. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L150 +## Tool used + +Manual Review + +## Recommendation +@>> assert Positions(self).get_nr_user_positions(user) < MAX_POSITIONS \ No newline at end of file diff --git a/005/014.md b/005/014.md new file mode 100644 index 0000000..f46a2e7 --- /dev/null +++ b/005/014.md @@ -0,0 +1,46 @@ +Amateur Nylon Canary + +Medium + +# Incorrect borrowing fee calculation + +## Summary +Traders with high collateral, low leverage will pay more borrowing fee than traders with low collateral, high leverage. + +## Vulnerability Detail +Traders in markets can add some leverage in order to get higher profits. LPs have to lock `collateral * leverage` value token to match this leverage position. Traders have to pay some borrowing fee for the locked token. The borrowing fee should be related with locked token amount. +The problem is that the borrowing fee is related with this position's collateral amount, not related with leverage. This could cause some positions with less locked token amount need to pay more borrowing fees. +For example: +- Alice opens one long position with 50000 USDT, 2x leverage in BTC/USDT market. +- Bob opens one long position with 10000 USDT, 20x leverage in BTC/USDT market. +- Lps have to lock more BTC tokens for Bob compared with Alice's position. However, Alice has to pay more borrowing fee because she has one higher collateral amount. + +```vyper +@external +@view +def calc_fees(id: uint256) -> FeesPaid: + pos : PositionState = Positions(self).lookup(id) + pool : PoolState = self.POOLS.lookup(pos.pool) + # Here collateral is this position's collateral. + fees : SumFees = self.FEES.calc( +@> pos.pool, pos.long, pos.collateral, pos.opened_at) +``` +```vyper +@external +@view +def calc(id: uint256, long: bool, collateral: uint256, opened_at: uint256) -> SumFees: + period: Period = self.query(id, opened_at) + P_b : uint256 = self.apply(collateral, period.borrowing_long) if long else ( + self.apply(collateral, period.borrowing_short) ) +``` +## Impact +Trading positions with less locked interest may have to pay more borrowing fee. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/fees.vy#L263-L274 + +## Tool used +Manual Review + +## Recommendation +Borrowing fee should be in propotion to positions' locked interest. \ No newline at end of file diff --git a/005/121.md b/005/121.md new file mode 100644 index 0000000..a6ef07c --- /dev/null +++ b/005/121.md @@ -0,0 +1,56 @@ +Dancing Topaz Perch + +Medium + +# Fees should be calculated based on `position.interest` not `position.collateral` + +### Summary + +Borrowing fees and funding fees are calculated based on `position.collateral`. However, when a user opens a position, `position.interest` amount of tokens are locked and it means the actual amount that he borrows from the protocol is `position.interest`. As a result, users pay unfair fees and the liquidity providers get less fee than they are entitled to receive. + +### Root Cause + +In the [`fees.vy:267-272`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/fees.vy#L267-L274), fees to pay to the protocol is calculated using the collateral of position. + +### Internal pre-conditions + +There is BTC/USDT (base/quote) pool. + +### External pre-conditions + +None + +### Attack Path + +1. Alice open the long position with leverage 3. +2. Alice close the position and pay fees to the protocol. + +### Impact + +Users pay fees based on the collateral they provide regardless of the actual amount they borrowed from the protocol. The liquidity providers get less fee than they are entitled to receive. This reduces their incentives to provide liquidity to the protocol and breaks core functionality of the protocol. + +### PoC + +When Alice opens a long position, she provide collateral as quote token and borrow base token from the protocol using the leverage. +```vyper +122: virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( +123: self.MATH.base_to_quote(collateral, ctx) ) +124: interest : uint256 = virtual_tokens * leverage +``` +Then, the pool locks `interest` amount of base token whose value is equivalent to 3 times of collateral she provide. +When Alice close the position, she pay borrowing fees and funding fees. +However, it uses collateral as the amount of asset, not the actual amount she borrowed from the protocol - `position.interest`. +```vyper +265: def calc(id: uint256, long: bool, collateral: uint256, opened_at: uint256) -> SumFees: +266: period: Period = self.query(id, opened_at) +267: P_b : uint256 = self.apply(collateral, period.borrowing_long) if long else ( +268: self.apply(collateral, period.borrowing_short) ) +269: P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( +270: self.apply(collateral, period.funding_short) ) +271: R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( +272: self.multiply(collateral, period.received_short) ) +``` + +### Mitigation + +It is recommended to calculate fees based on `position.interest`, not based on `position.collateral`. \ No newline at end of file diff --git a/006.md b/006.md new file mode 100644 index 0000000..8ba0a96 --- /dev/null +++ b/006.md @@ -0,0 +1,26 @@ +Joyful Punch Fly + +Medium + +# from_amount can revert if price 0. + +## Summary +when asset price drop to zero then the from_amount gets reverted which prevents the user from closing any position. +## Vulnerability Detail + +def from_amount(amount: uint256, price: uint256, one1: uint256) -> uint256: + """ + Returns volume implied by price. + """ + @>> return (amount * one1) / price +## Impact +Long position should be automatically liquidated when their value drops to zero.The collateral of the position would typically be used to cover the losses.However due to revert caused by division by zero ,these long position will not be closed properly. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L61Long position +## Tool used + +Manual Review + +## Recommendation +when closing the long position while price is zero,payout will be zero for user.donot call quote_to_base function to convert the final amount which will prevent division by zero. +or do a limit on price so that price cannot be zero. diff --git a/006/016.md b/006/016.md new file mode 100644 index 0000000..564380c --- /dev/null +++ b/006/016.md @@ -0,0 +1,49 @@ +Amateur Nylon Canary + +High + +# Incorrect funding fee calculation + +## Summary +Funding fee should be based on the open interest's difference between long side and short side, not based on the reserve utilization difference between long side and short side. + +## Vulnerability Detail +Funding fee should be based on the open interest's difference between long side and short side. But in velar, the funding fee is based on the reserve utilization between long side and short size. +The problem is that the reserve for base token and quote token is possible to be imbalance. Considering below scenario: +- Alice opens one Long position, position size 50000 USD. Some base tokens will be locked. Assume the base reserve utilization is 10%. +- Bob opens one Short position, position size is 5000 USD. Some quote tokens will be locked.Assume the quote reserve utilization is 20%. +Because we have more open interest in Long side, long side positions should pay funding fee to the short side positions. But because the base reserve utilization is shorter than the quote reserve utilization, the short side with less open interest has to pay funding fee to long side positions. +Considering that LP holders can mint share via any ratio between base token and quote token, the utilization is easy to be manipulated and the pool is quite easy to imbalance. + +```vyper +@external +@view +def dynamic_fees(pool: PoolState) -> DynFees: + + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) + ... + funding_long : uint256 = self.funding_fee( + borrowing_long, long_utilization, short_utilization) +``` +```vyper +@internal +@view +def funding_fee(base_fee: uint256, col1: uint256, col2: uint256) -> uint256: + imb: uint256 = self.imbalance(col1, col2) + if imb == 0: return 0 + else : return self.check_fee(self.scale(base_fee, imb)) +``` + +## Impact +Size with less open interest may pay funding fees to size with more open interest. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L82-L87 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L31-L49 +## Tool used + +Manual Review + +## Recommendation +Calculate funding fee based on the open interest. \ No newline at end of file diff --git a/006/076.md b/006/076.md new file mode 100644 index 0000000..02989a3 --- /dev/null +++ b/006/076.md @@ -0,0 +1,66 @@ +Dancing Topaz Perch + +High + +# Funding fees should be calculated based on position volumes rather than utilization ratio + +## Summary +Funding fees for perp positions are calculated using utilization ratio of base/quote assets. +This calculation is invalid since the protocol allows imbalance between base/quote assets in the pool. + +## Vulnerability Detail +In the protocol, funding fees are paid between long and shot perp traders. +The basic design is that at any moment, if long perp trades happen more than short perp trades, long position openers pay short position openers the funding fee, and vice versa. This funding fees provides stability of the protocol and prevents loss of LPs and the protocol. + +However, the funding fee calculation is executed in improper way, as shown below: + +```vyper +# How funding_long and funding_short are calculated + +funding_long : uint256 = self.funding_fee( + borrowing_long, long_utilization, short_utilization) +funding_short : uint256 = self.funding_fee( + borrowing_short, short_utilization, long_utilization) + +... + +# funding_fee implementation + +def funding_fee(base_fee: uint256, col1: uint256, col2: uint256) -> uint256: + imb: uint256 = self.imbalance(col1, col2) + if imb == 0: return 0 + else : return self.check_fee(self.scale(base_fee, imb)) +``` + +As shown in the code snippet, funding fees are calculated using long and short utilization of the pool. +Thus, if `long utilization > short utilization`, long position openers pay funding fees for short position openers, and vice versa. + +The problem is that the protocol allows imbalance between base and quote assets in the pool, for example, assuming `1 BTC = 50,000 USDC`, the value of base/quote assets might be different like `1 BTC + 10,000 USDC`. So as a result, even though `long utilization > short utilization`, actual notional long position size can be smaller than notional short size. + +This vulnerability can cause significant loss to LP providers and the overall protocol because of reverse party paying funding fees. + +Here's the scenario that loss can happen because of this vulnerability. + +- It's bullish time, and everyone believes that BTC price will go up. +- The pool contains 200 BTC($10m in value) + 1M USDC ($1M in value) +- Since people know that BTC prices will go up, relatively much people open long positions. +- Assuming, 100 BTC of notional size of long positions are open. ($5m in value) +- The short openers are the ones who are willing to take risk of BTC price goes up but getting funding fees which might profit them in the end. +- Assuming, 600k USDC of notional size of short positions are open. ($0.6m in value) +- At this moment, `long_utilization = 50, short_utilization = 60`, but in position values, long value = $5m, short value = $0.6m. +- As a result, short position openers pay funding fees to long position openers. + +This logic allows more and more long position openers to open their positions and less incentivize short openers. +At the end, it incurs significant losses to LP providers and to the protocol. + +## Impact +Loss for LP providers and the protocol. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L46-L49 + +## Tool used +Manual Review + +## Recommendation +The funding fee calculation logic has to be modified so that it uses actual value of positions rather than the utilization ratio to determine which party pays the funding fees. diff --git a/007.md b/007.md new file mode 100644 index 0000000..0efcd3e --- /dev/null +++ b/007.md @@ -0,0 +1,103 @@ +Joyful Punch Fly + +Medium + +# Precision loss + +## Summary +There are many precision loss issues in smart contracts . + +## Vulnerability Detail +def utilization(reserves: uint256, interest: uint256) -> uint256: + """ + Reserve utilization in percent (rounded down). + """ + @>> return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) + + +def static_fees(collateral: uint256) -> Fee: +@> fee : uint256 = collateral / self.PARAMS.PROTOCOL_FEE + remaining: uint256 = collateral - fee + return Fee({x: collateral, fee: fee, remaining: remaining}) + + +if user-supplied collateral is less than 1000, the collateral fee will always be zero, and for an amount greater than 1000, due to precision loss, the fee will be less than expected. + + +def to_amount(price: uint256, volume: uint256, one1: uint256) -> uint256: + """ + Converts unit price to value of volume at that price. + """ +@>> return (price * volume) / one1 + + +def from_amount(amount: uint256, price: uint256, one1: uint256) -> uint256: + """ + Returns volume implied by price. + """ +@>> return (amount * one1) / price + + +def base_to_quote(tokens: uint256, ctx: Ctx) -> uint256: + lifted : Tokens = self.lift(Tokens({base: tokens, quote: ctx.price}), ctx) +@>> amt0 : uint256 = self.to_amount(lifted.quote, lifted.base, self.one(ctx)) + lowered: Tokens = self.lower(Tokens({base: 0, quote: amt0}), ctx) + return lowered.quote + +@external +@pure + def quote_to_base(tokens: uint256, ctx: Ctx) -> uint256: + l1 : Tokens = self.lift(Tokens({base: 0, quote: tokens}), ctx) + l2 : Tokens = self.lift(Tokens({base: 0, quote: ctx.price}), ctx) +@>> vol0 : uint256 = self.from_amount(l1.quote, l2.quote, self.one(ctx)) + lowered: Tokens = self.lower(Tokens({base: vol0, quote: 0}), ctx) + return lowered.base + + +there is a precision loss in converting base_to_quote and quote_to_base.when we convert from quote token USDT(6 decimal) to base token WBTC(8 decimal) ,there is a precision loss. + +def balanced(state: Value, burn_value: uint256, ctx: Ctx) -> Tokens: + """ + + """ + if state.have_more_base: + if state.base_excess_as_quote >= burn_value: + return Tokens({base: Math(self).quote_to_base(burn_value, ctx), quote: 0}) + else: + base1: uint256 = state.base_excess_as_base + left : uint256 = burn_value - state.base_excess_as_quote + quote: uint256 = left / 2 + @>> base2: uint256 = Math(self).quote_to_base(quote, ctx) + base : uint256 = base1 + base2 + return Tokens({base: base, quote: quote}) + else: + if state.quote_excess_as_quote >= burn_value: + return Tokens({base: 0, quote: burn_value}) + else: + quote1: uint256 = state.quote_excess_as_quote + left : uint256 = burn_value - quote1 + quote2: uint256 = left / 2 + @>> base : uint256 = Math(self).quote_to_base(quote2, ctx) + quote : uint256 = quote1 + quote2 + return Tokens({base: base, quote: quote}) + +there is a precision loss here. + + +## Impact +precision loss is happening as we may lose the fee gain.we are also losing precision in base_to_quote also + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L59 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L53 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L61 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L127 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L81 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L73 +## Tool used + +Manual Review + +## Recommendation + +we should reduce the precision loss . \ No newline at end of file diff --git a/007/020.md b/007/020.md new file mode 100644 index 0000000..66972a9 --- /dev/null +++ b/007/020.md @@ -0,0 +1,46 @@ +Rough Fiery Baboon + +High + +# Liquidation is allowed when the protocol is paused + +## Summary +The protocol allows liquidation when it is paused i.e users can't open new positions but can get liquidated +## Vulnerability Detail +Althoug there is no direct functionality to pause the protocol yet there is an indirect way to pause the protocol but partially , i.e. only disallowing users from openinig new positions. Now lets see how : + +In [core.open()](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L230-L268) it is opening a new position in [Position.vy](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L260) and which has a check to ensure that the position is a legal position and can be opened +[is_legal_position](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L148) now when we see this check in `params.vy` contract : + +```python +@external +@view +def is_legal_position(pool: PoolState, position: PositionState) -> bool: + min_size: bool = position.collateral >= (self.PARAMS.MIN_LONG_COLLATERAL if position.long else + self.PARAMS.MIN_SHORT_COLLATERAL) + # Max size limits are not really enforceable since users can just + # open multiple identical positions. + # This can be used as a pause button (set to zero to prevent new + # positions from being opened). + + max_size: bool = position.collateral <= (self.PARAMS.MAX_LONG_COLLATERAL if position.long else + self.PARAMS.MAX_SHORT_COLLATERAL) + min_leverage: bool = position.leverage >= (self.PARAMS.MIN_LONG_LEVERAGE if position.long else + self.PARAMS.MIN_SHORT_LEVERAGE) + max_leverage: bool = position.leverage <= (self.PARAMS.MAX_LONG_LEVERAGE if position.long else + self.PARAMS.MAX_SHORT_LEVERAGE) + return min_size and max_size and min_leverage and max_leverage +``` +The code comment above suggests that `MAX_LONG_COLLATERAL` can be set to 0 and so the check will not allow any new position to be opened. + +While there is no direct mechanism for pausing the protocol ,there is a mechanism when `MAX_LONG_COLLATERAL` will be set to `0` and so the user can't really open new position or add to the existing position, but there is a problem with this mechanism, since there is no check in liquidation , which allows liquidators to liquidate the positions while the protocol is indirectly paused. +## Impact +Users won't be able to get a chance to add to their position and get unfairly liquidated. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L102-L115 +## Tool used + +Manual Review + +## Recommendation +Use a mechanism to pause the whole protocol even liquidations and close. \ No newline at end of file diff --git a/007/100.md b/007/100.md new file mode 100644 index 0000000..aa32b24 --- /dev/null +++ b/007/100.md @@ -0,0 +1,36 @@ +Huge Taupe Starling + +Medium + +# Inability to hedge during paused state exposes traders to liquidation + +## Summary +The admin of the protocol has the ability to pause the opening of positions while keeping liquidation available. In such a case, users with open positions would be unable to hedge their positions during market volatility by opening opposite positions. This exposes them to liquidation or losses that could otherwise be mitigated. + +## Vulnerability Detail +As described [here](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L107) the admin can pause position openings by setting `MAX_LONG_COLLATERAL` and `MAX_SHORT_COLLATERAL` to zero via the [`set_params()` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L24-L27). +```python + # This can be used as a pause button (set to zero to prevent new + # positions from being opened). +``` + +While this mechanism prevents users from opening new positions, liquidations remain unpaused, allowing liquidators to act. This is problematic in perpetual DEXes (or generally futures contracts) because of the concept of hedging, which traders use to minimize losses by opening positions in the opposite direction. When new positions are paused, traders are unable to hedge during a volatile market, potentially facing liquidation or losses that could have been mitigated. + +As described by [Investopedia](https://www.investopedia.com/terms/h/hedge.asp): +> Hedging is commonly used to offset potential losses in currency trading. A foreign currency trader who is speculating on the movements of a currency might open a directly opposing position to limit losses from price fluctuations. Thus, the trader retains some upside potential no matter what happens. + +If a trader holds a large position and the market moves against them, they might open new positions in the opposite direction to hedge their exposure. For example, if a trader is long and the price drops, they may want to short to reduce risk. If new position openings are paused, the trader is unable to hedge and becomes fully exposed to market movements. If the price continues moving against them and reaches the liquidation threshold, they risk liquidation without a chance to prevent the loss. + +## Impact +If traders cannot hedge their positions during periods of market volatility due to the paused state, they face potential losses or liquidation. As hedging is an important risk management strategy in futures contracts, this could discourage traders from using the protocol altogether. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L107-L108 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L24-L27 + +## Tool used + +Manual Review + +## Recommendation +Liquidations should be paused when new position openings are paused \ No newline at end of file diff --git a/008.md b/008.md new file mode 100644 index 0000000..c4b4c28 --- /dev/null +++ b/008.md @@ -0,0 +1,21 @@ +Joyful Punch Fly + +Medium + +# Protocol Fee cannot be withdrawn + +## Summary +there is no way to withdraw the protocol fee + +## Vulnerability Detail +Protocol Fee cannot be withdrawn in core.vy. +## Impact + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L68 +## Tool used + +Manual Review + +## Recommendation +implement a withdraw function \ No newline at end of file diff --git a/008/027.md b/008/027.md new file mode 100644 index 0000000..be4d23f --- /dev/null +++ b/008/027.md @@ -0,0 +1,59 @@ +Hot Purple Buffalo + +Medium + +# Hardcoded Value Breaks Core Contract Functionality + +## Summary +The use of a hardcoded value for the pool id in [`apy.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy) proxied calls makes every other pool in the system inaccessible. + +## Vulnerability Detail +The comments indicate that `apy.vy` is the entry-point contract, which proxy's calls to core. However, each call made to `core.vy` includes a hardcoded value of 1 for the pool id: +```vyper + return self.CORE.mint(1, base_token, quote_token, lp_token, base_amt, quote_amt, ctx) + return self.CORE.burn(1, base_token, quote_token, lp_token, lp_amt, ctx) + return self.CORE.open(1, base_token, quote_token, long, collateral0, leverage, ctx) + return self.CORE.close(1, base_token, quote_token, position_id, ctx) + return self.CORE.liquidate(1, base_token, quote_token, position_id, ctx) +``` + +Within each of these functions in [`core.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L168-L172), the following checks are enforced: +```vyper + pool : PoolState = self.POOLS.lookup(id) +... + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token, ERR_PRECONDITIONS +``` + +However the pools contract is [designed to support](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L96) multiple pools with differing pool id: +```vyper +def fresh( + symbol : String[65], + base_token : address, + quote_token: address, + lp_token : address) -> PoolState: + self._INTERNAL() + return self.insert(PoolState({ + id : self.next_pool_id(), +... + +def next_pool_id() -> uint256: + id : uint256 = self.POOL_ID + nxt: uint256 = id + 1 + self.POOL_ID = nxt + return nxt +``` + +Thus, any user action with `core.vy` can interact only with this first pool and all pools with an id differing from 1 will be inaccessible. + +## Impact +Core contract functionality is broken, leading to a DoS (which doesn't compromise funds, since pools cannot be interacted with to begin with). + +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +Add an `id: uint256` parameter to the mentioned functions of `api.vy`, and forward this value to the calls of `core.vy`, rather than hardcoding 1. \ No newline at end of file diff --git a/008/109.md b/008/109.md new file mode 100644 index 0000000..9b8e021 --- /dev/null +++ b/008/109.md @@ -0,0 +1,58 @@ +Faint Raspberry Tadpole + +Medium + +# Hard coded pool id in `api.vy` will cause only the first pool can be operated. + +### Summary + +When calling the function of `core.vy` in `api.vy`, the pool id is hard-coded to 1, which results in that only the first pool can be operated, and subsequent pools added cannot be operated. + + +### Root Cause + +When calling `core.vy` to `mint` ([`api.vy:101`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L101)), `burn` ([`api.vy:128`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L128)), `open` ([`api.vy:158`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L158)), `close` ([`api.vy:183`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L183)) and `liquidate` ([`api.vy:211`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L211)), the pool id is required to specify which pool to operate, however the pool id is hard coded to `1`. + +### Internal pre-conditions + +1. The protocol is supposed to be able to integrate different standard tokens (see [readme](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/README.md#q-if-you-are-integrating-tokens-are-you-allowing-only-whitelisted-tokens-to-work-with-the-codebase-or-any-complying-with-the-standard-are-they-assumed-to-have-certain-properties-eg-be-non-reentrant-are-there-any-types-of-weird-tokens-you-want-to-integrate)) +2. Deployer can setup (`core.vy:fresh`) multiple pools for different token pairs and LP token. +```solidity +def fresh( + symbol : String[65], + base_token : address, + quote_token: address, + lp_token : address): + assert msg.sender == self.DEPLOYER, ERR_PERMISSIONS + assert not self.POOLS.exists_pair(base_token, quote_token), ERR_PRECONDITIONS + assert not self.POOLS.exists_pair(quote_token, base_token), ERR_PRECONDITIONS + assert not self.POOLS.exists_lp(lp_token), ERR_PRECONDITIONS + + user: address = msg.sender +> pool: PoolState = self.POOLS.fresh(symbol, base_token, quote_token, lp_token) + fees: FeeState = self.FEES.fresh(pool.id) +``` +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L137-L149 + +3. The `mint`, `burn`, `open`, `close` and `liquidate` operations in `core.vy` require a pool id parameter to specifiy which pool to operate on. But `api.vy` hard code the pool id to `1` when calling these operations. This means only the first pool can be operated on, and subsequent added pools can never be operated on, which breaks the core functionality of the protocol. + + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Only the first pool can be operated on, and subsequent added pools can never be operated on, which breaks the core functionality of the protocol. + +### PoC + +_No response_ + +### Mitigation + +Add a `poolId` parameter to `api.vy`'s functions (`mint`, `burn`, `open`, `close` and `liquidate`), and use that input `poolId` paramenter to call `core.vy`. \ No newline at end of file diff --git a/009.md b/009.md new file mode 100644 index 0000000..0849876 --- /dev/null +++ b/009.md @@ -0,0 +1,38 @@ +Joyful Punch Fly + +Medium + +# token insufficiently validated + +## Summary +The fresh function in the core smart contract does not verify if it can mint the lp token at he lp_token address being passed to the function. + +in case the core smart contract cannot mint the lp token,reserves cannot be added to the pool. +Another pool cannot be created with the same tokens since the fresh function prevents creating a pool with the same tokens twice putting the protocol in an undesirable state. +## Vulnerability Detail +def fresh( + symbol : String[65], + base_token : address, + quote_token: address, + lp_token : address): + assert msg.sender == self.DEPLOYER, ERR_PERMISSIONS + assert not self.POOLS.exists_pair(base_token, quote_token), ERR_PRECONDITIONS + assert not self.POOLS.exists_pair(quote_token, base_token), ERR_PRECONDITIONS + @> assert not self.POOLS.exists_lp(lp_token), ERR_PRECONDITIONS + + user: address = msg.sender + pool: PoolState = self.POOLS.fresh(symbol, base_token, quote_token, lp_token) + fees: FeeState = self.FEES.fresh(pool.id) + + log Create(user, pool.id) + +## Impact +Reserves cannot be added to the created pool,A new pool with the same quote/base token pair cannot be created +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L137 +## Tool used + +Manual Review + +## Recommendation +check core smart contract is the owner of lp_token \ No newline at end of file diff --git a/009/040.md b/009/040.md new file mode 100644 index 0000000..0715aaf --- /dev/null +++ b/009/040.md @@ -0,0 +1,216 @@ +Brilliant Burlap Elephant + +Medium + +# Attacker can manipulate funding rates to cause financial loss for users + +### Summary + +The funding fee calculation based on pool-specific utilization imbalances will cause financial loss for users as an attacker will manipulate the funding rates by opening and quickly closing large positions in a single pool. + + +### Root Cause + +In `gl-sherlock/contracts/params.vy`, the [`dynamic_fees` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L33-L56) calculates funding fees based on the utilization imbalance within a specific pool, which allows for manipulation through large, transient positions. + + +```python +File: params.vy +33: def dynamic_fees(pool: PoolState) -> DynFees: +34: """ +35: Borrowing fees scale linearly based on pool utilization, from +36: MIN_FEE to MAX_FEE. +37: Funding fees scale base on the utilization imbalance off of the +38: borrowing fee. +39: """ +40: long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) +41: short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) +42: borrowing_long : uint256 = self.check_fee( +43: self.scale(self.PARAMS.MAX_FEE, long_utilization)) +44: borrowing_short : uint256 = self.check_fee( +45: self.scale(self.PARAMS.MAX_FEE, short_utilization)) +46: funding_long : uint256 = self.funding_fee( +47: borrowing_long, long_utilization, short_utilization) +48: funding_short : uint256 = self.funding_fee( +49: borrowing_short, short_utilization, long_utilization) +50: return DynFees({ +51: borrowing_long : borrowing_long, +52: borrowing_short: borrowing_short, +53: funding_long : funding_long, +54: funding_short : funding_short, +55: }) +56: + +``` + +### Internal pre-conditions + +1. The pool must have sufficient liquidity to allow for large position openings. +2. The funding fee calculation must be sensitive to short-term changes in utilization. + + +### External pre-conditions + +1. Market conditions must allow for the creation of large positions. + +### Attack Path + +1. Attacker calls [`open` function in `core.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L230-L269) through `api.vy` to create a large long position, causing an extreme imbalance in utilization. +2. The [`dynamic_fees` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L33-L56) in `params.vy` calculates a high funding rate based on the new utilization imbalance. +3. Attacker immediately calls `close` function in `core.vy` through `api.vy` to exit the position. +4. The high funding rate persists, affecting other users in the pool. + + +### Impact + +The users suffer an approximate loss due to artificially inflated funding rates. The attacker potentially gains from the manipulation of funding rates, while other users in the pool face increased costs or potential liquidations. + + +### PoC + +1. Attacker calls `open` function with a large collateral and high leverage: + + +```python +File: core.vy +230: def open( +231: id : uint256, +232: base_token : address, +233: quote_token : address, +234: long : bool, +235: collateral0 : uint256, +236: leverage : uint256, +237: ctx : Ctx) -> PositionState: +238: +239: self._INTERNAL() +240: +241: user : address = tx.origin +242: pool : PoolState = self.POOLS.lookup(id) +243: +244: cf : Fee = self.PARAMS.static_fees(collateral0) +245: fee : uint256 = cf.fee +246: collateral : uint256 = cf.remaining +247: +248: assert pool.base_token == base_token , ERR_PRECONDITIONS +249: assert pool.quote_token == quote_token, ERR_PRECONDITIONS +250: assert collateral > 0 , ERR_PRECONDITIONS +251: assert fee > 0 , ERR_PRECONDITIONS +252: +253: if long: assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" +254: else : assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" +255: +256: # transfer protocol fees to separate contract +257: if long: assert ERC20(quote_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" +258: else : assert ERC20(base_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" +259: +260: position: PositionState = self.POSITIONS.open(user, id, long, collateral, leverage, ctx) +261: self.POOLS.open(id, position.collateral_tagged, position.interest_tagged) +262: self.FEES.update(id) +263: +264: self.INVARIANTS(id, base_token, quote_token) +265: +266: log Open(user, ctx, pool, position) +267: +268: return position +269: + +``` + + +2. This causes an extreme imbalance in the pool's utilization, which is then used in the `dynamic_fees` function: + + +```python +File: params.vy +40: long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) +41: short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) + +``` + + +3. The `dynamic_fees` function calculates a high funding rate: + + +```python +File: params.vy +46: funding_long : uint256 = self.funding_fee( +47: borrowing_long, long_utilization, short_utilization) +48: funding_short : uint256 = self.funding_fee( +49: borrowing_short, short_utilization, long_utilization) + +``` + + +4. Attacker immediately calls `close` function to exit the position: + + +```python +File: core.vy +272: def close( +273: id : uint256, +274: base_token : address, +275: quote_token : address, +276: position_id : uint256, +277: ctx : Ctx) -> PositionValue: +278: +279: self._INTERNAL() +280: +281: user : address = tx.origin +282: pool : PoolState = self.POOLS.lookup(id) +283: position: PositionState = self.POSITIONS.lookup(position_id) +284: +285: assert pool.base_token == base_token , ERR_PRECONDITIONS +286: assert pool.quote_token == quote_token, ERR_PRECONDITIONS +287: assert id == position.pool , ERR_PRECONDITIONS +288: assert user == position.user , ERR_PRECONDITIONS +289: +290: value : PositionValue = self.POSITIONS.close(position_id, ctx) +291: base_amt : uint256 = self.MATH.eval(0, value.deltas.base_transfer) +292: quote_amt: uint256 = self.MATH.eval(0, value.deltas.quote_transfer) +293: self.POOLS.close(id, value.deltas) +294: self.FEES.update(id) +295: +296: if base_amt > 0: +297: assert ERC20(base_token).transfer(user, base_amt, default_return_value=True), "ERR_ERC20" +298: if quote_amt > 0: +299: assert ERC20(quote_token).transfer(user, quote_amt, default_return_value=True), "ERR_ERC20" +300: +301: self.INVARIANTS(id, base_token, quote_token) +302: +303: log Close(user, ctx, pool, value) +304: return value + +``` + + +5. The high funding rate persists, affecting other users in the pool as described in the fees calculation: + + +```python +File: fees.vy +169: # 2) Funding fee receipts are a little more complicated: +170: # - assume wlog that shorts are paying longs +171: # - let C_s_i = total short collateral at block i +172: # C_l_i = total long collateral at block i +173: # c_j = a single long position's collateral (sum(c_j) = C_l_i) +174: # f_i = funding fee at block i +175: # - short positions in aggregate pay T_i = C_s_i * f_i in funding fees at block i +176: # - an individual long position receives a share of this total payment: +177: # c_j/C_l_i * T_i +178: # - notice that we don't know c_j here, so we store the fee payment per unit +179: # collateral instead (as an incremental sum as for borrowing fees) +180: # +181: # paid = f_0 * C_l_0 + ... + f_i * C_l_i +182: # received = f_0 * C_l_0 * 1/C_s_0 + ... + f_i * C_l_i * 1/C_s_i +183: # +184: # Notice that rounding errors (down) should be safe in the sense that paid >= received. +185: paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms) +186: received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) +187: +188: paid_short_term : uint256 = self.apply(fs.short_collateral, fs.funding_short * new_terms) + +``` + +### Mitigation + +To mitigate this issue, implement a time-weighted average utilization for funding fee calculations. This will reduce the impact of short-term utilization spikes caused by large, transient positions. \ No newline at end of file diff --git a/009/088.md b/009/088.md new file mode 100644 index 0000000..51e6d6d --- /dev/null +++ b/009/088.md @@ -0,0 +1,51 @@ +Kind Banana Sloth + +Medium + +# Liquidity providers can remove liquidity to force positions into high fees + +## Summary +Liquidity providers can remove liquidity to force positions into high fees + +## Vulnerability Detail +Within the protocol, position fees are based on utilization of the total LP amount. +```vyper +def dynamic_fees(pool: PoolState) -> DynFees: + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) + borrowing_long : uint256 = self.check_fee( + self.scale(self.PARAMS.MAX_FEE, long_utilization)) + borrowing_short : uint256 = self.check_fee( + self.scale(self.PARAMS.MAX_FEE, short_utilization)) + funding_long : uint256 = self.funding_fee( + borrowing_long, long_utilization, short_utilization) + funding_short : uint256 = self.funding_fee( + borrowing_short, short_utilization, long_utilization) + return DynFees({ + borrowing_long : borrowing_long, + borrowing_short: borrowing_short, + funding_long : funding_long, + funding_short : funding_short, + }) +``` +The problem is that this number is dynamic and LP providers can abuse it at any point + +Consider the following scenario: +1. There's a lot of liquidity in the pool +2. User opens a position for a fraction of it, expecting low fees due the low utilization +3. LP providers withdraw most of liquidity, forcing high utilization ratio and ultimately high fees. + + +## Impact +Loss of funds + + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L33 + +## Tool used + +Manual Review + +## Recommendation +Consider using a different way to calculate fees which is not manipulatable by the LP providers. \ No newline at end of file diff --git a/010/044.md b/010/044.md new file mode 100644 index 0000000..05eec12 --- /dev/null +++ b/010/044.md @@ -0,0 +1,159 @@ +Brilliant Burlap Elephant + +Medium + +# Users will experience inconsistencies in position management due to unremoved closed positions + +### Summary + +The [`close` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L367-L390) in the Positions contract fails to remove closed position IDs from the `USER_POSITIONS` mapping, causing inconsistencies in position management for users as closed positions will still appear in their position list. + + +### Root Cause + +In `gl-sherlock/contracts/positions.vy` the [`close` function ](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L367-L390) updates the position status to `CLOSED` but does not remove the closed position ID from the `USER_POSITIONS` mapping. + + +### Internal pre-conditions + +1. User needs to open a position using the `open` function. +2. User needs to close the position using the `close` function. + +### External pre-conditions + +None. + +### Attack Path + +1. User calls `open` function to create a new position. + + +```python +File: positions.vy +107: @external +108: def open( +109: user : address, +110: pool : uint256, +111: long : bool, +112: collateral: uint256, +113: leverage : uint256, +114: ctx : Ctx) -> PositionState: +115: self._INTERNAL() +116: +117: # Opening a position with leverage can be thought of as purchasing +118: # an amplified number of tokens. +119: # Longs buy base tokens with quote collateral and shorts buy quote +120: # tokens with base collateral (alternatively, longs buy base and shorts +121: # sell base). +122: virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( +123: self.MATH.base_to_quote(collateral, ctx) ) +124: interest : uint256 = virtual_tokens * leverage +125: +126: pos: PositionState = PositionState({ +127: id : self.next_position_id(), +128: pool : pool, +129: user : user, +130: status : Status.OPEN, +131: long : long, +132: collateral : collateral, +133: leverage : leverage, +134: interest : interest, +135: entry_price: ctx.price, +136: exit_price : 0, +137: opened_at : block.number, +138: closed_at : 0, +139: +140: collateral_tagged: Tokens({base: 0, quote: collateral}) if long else ( +141: Tokens({base: collateral, quote: 0}) ), +142: interest_tagged : Tokens({base: interest, quote: 0}) if long else ( +143: Tokens({base: 0, quote: interest}) ), +144: }) +145: ps: PoolState = self.POOLS.lookup(pool) +146: +147: assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS +148: assert self.PARAMS.is_legal_position(ps, pos) +149: +150: self.insert_user_position(user, pos.id) +151: return self.insert(pos) +152: + +``` + + +2. User calls `close` function to close the position. + + +```python +File: positions.vy +367: def close(id: uint256, ctx: Ctx) -> PositionValue: +368: self._INTERNAL() +369: pos: PositionState = Positions(self).lookup(id) +370: assert pos.status == Status.OPEN , ERR_PRECONDITIONS +371: assert block.number > pos.opened_at, ERR_PRECONDITIONS +372: self.insert(PositionState({ +373: id : pos.id, +374: pool : pos.pool, +375: user : pos.user, +376: status : Status.CLOSED, +377: long : pos.long, +378: collateral : pos.collateral, +379: leverage : pos.leverage, +380: interest : pos.interest, +381: entry_price: pos.entry_price, +382: exit_price : ctx.price, +383: opened_at : pos.opened_at, +384: closed_at : block.number, +385: +386: collateral_tagged: pos.collateral_tagged, +387: interest_tagged : pos.interest_tagged, +388: })) +389: return Positions(self).value(id, ctx) + +``` + + +3. User calls [`lookup_user_positions` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L92-L99) to view their positions. + + +```python +File: positions.vy +92: @external +93: @view +94: def lookup_user_positions(user: address) -> DynArray[PositionState, 500]: +95: ids: DynArray[uint256, 500] = self.USER_POSITIONS[user] +96: res: DynArray[PositionState, 500] = [] +97: for id in ids: +98: res.append(Positions(self).lookup(id)) +99: return res + +``` + + + +### Impact + +The users suffer from inconsistencies in their position management, as closed positions will still appear in their position list. This can lead to confusion, potential errors in user interfaces, and incorrect calculations when iterating through user positions. + +### PoC + +1. Alice opens a position: +```python +position = positions.open(alice_address, pool_id, True, 1000, 2, ctx) +``` + +2. Alice closes the position: +```python +positions.close(position.id, ctx) +``` + +3. Alice checks her positions: +```python +user_positions = positions.lookup_user_positions(alice_address) +``` + +4. The closed position still appears in the `user_positions` list, causing confusion and potential issues. + + +### Mitigation + +To fix this issue, we need to add logic to remove the closed position ID from the `USER_POSITIONS` mapping in the `close` function. \ No newline at end of file diff --git a/010/055.md b/010/055.md new file mode 100644 index 0000000..2fe8116 --- /dev/null +++ b/010/055.md @@ -0,0 +1,91 @@ +Scrawny Boysenberry Mammoth + +Medium + +# `USER_POSITIONS ` HashMap is not updated in Positions.vy:close(), preventing users from opening new positions + +## Summary +[close](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L367) function in Positions.vy is not updating [USER_POSITIONS](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L82), which will not allow users to open new position when user's position hit max_position number set in the codebase +## Vulnerability Detail +Users can open by calling open() function in api.vy, which makes a call to open() function in Core.vy, which also includes a call to open() in Positions.vy. Now If we look at this function: +```python +@external +def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + self._INTERNAL() + + virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( + self.MATH.base_to_quote(collateral, ctx) ) + interest : uint256 = virtual_tokens * leverage + + pos: PositionState = PositionState({ + id : self.next_position_id(), + pool : pool, + user : user, + status : Status.OPEN, + long : long, + collateral : collateral, + leverage : leverage, + interest : interest, + entry_price: ctx.price, + exit_price : 0, + opened_at : block.number, + closed_at : 0, + + collateral_tagged: Tokens({base: 0, quote: collateral}) if long else ( + Tokens({base: collateral, quote: 0}) ), + interest_tagged : Tokens({base: interest, quote: 0}) if long else ( + Tokens({base: 0, quote: interest}) ), + }) + ps: PoolState = self.POOLS.lookup(pool) + +@> assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + +@> self.insert_user_position(user, pos.id) + return self.insert(pos) +``` +the num of user positions has to be less than or equal to ` MAX_POSITIONS `, and the new position id is added `USER_POSITIONS`. Now let's have a look at the close() in Positions.vy: +```python +@external +def close(id: uint256, ctx: Ctx) -> PositionValue: + self._INTERNAL() + pos: PositionState = Positions(self).lookup(id) + assert pos.status == Status.OPEN , ERR_PRECONDITIONS + assert block.number > pos.opened_at, ERR_PRECONDITIONS + self.insert(PositionState({ + id : pos.id, + pool : pos.pool, + user : pos.user, + status : Status.CLOSED, + long : pos.long, + collateral : pos.collateral, + leverage : pos.leverage, + interest : pos.interest, + entry_price: pos.entry_price, + exit_price : ctx.price, + opened_at : pos.opened_at, + closed_at : block.number, + + collateral_tagged: pos.collateral_tagged, + interest_tagged : pos.interest_tagged, + })) + return Positions(self).value(id, ctx) +``` +as it can be seen the function does not remove the closed position ID. Hence it is obvious that there is accumulation of closed Positions in `USER_POSITIONS`. This will lead some issues like: +1.Over time, even though the user may only have a few open positions(or even zero active position), due to the growing array the max position will be hit and the user won't be able to open new position +2. Also it will lead to a confusing user experience, where users may assume they still hold open positions, in fact, the positions are closed. +## Impact +User will not be able open new position when closed positions IDs+active position IDs reached MAX_POSITIONS. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L147C2-L150C42 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L367 +## Tool used +Manual Review +## Recommendation +Remove the closed position from the user's position Array when a position is closed \ No newline at end of file diff --git a/011.md b/011.md new file mode 100644 index 0000000..f564185 --- /dev/null +++ b/011.md @@ -0,0 +1,37 @@ +Hot Purple Buffalo + +Medium + +# Use of block.number to represent passage of time + +## Summary +The use of block.number will result in inaccurate or inconsistent fee calculations, since [block times are not fixed](https://etherscan.io/chart/blocktime). + +## Vulnerability Detail +block.number is used to calculate the passage of time since the last fee update in [`fees.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/fees.vy#L147). + +```solidity + new_terms: uint256 = block.number - fs.t1 +... + borrowing_long_sum : uint256 = self.extend(fs.borrowing_long_sum, fs.borrowing_long, new_terms) + borrowing_short_sum : uint256 = self.extend(fs.borrowing_short_sum, fs.borrowing_short, new_terms) + funding_long_sum : uint256 = self.extend(fs.funding_long_sum, fs.funding_long, new_terms) + funding_short_sum : uint256 = self.extend(fs.funding_short_sum, fs.funding_short, new_terms) +``` + +However, there have been numerous instances of inconsistent block times, in which case block number would not be representative of the passage of time. + +Given that the protocol will be deployed on a new chain, the risk of inconsistent block times is exacerbated, as has been observed with [Canto](https://crypto.news/canto-blockchain-struggles-to-restart-following-outage/), [Solana](https://crypto.news/solana-stops-processing-transactions-sol-sinks-by-4/), and [Starknet](https://crypto.news/starknet-faces-4-hour-outage-block-production-halts/), in which cases block production was halted entirely. Additionally, the BOB chain's [development roadmap](https://docs.gobob.xyz/docs/learn/bob-stack/roadmap) introduces possible future changes (or volatility) in block time, especially in transition periods where the settlement layer is changed. + +While the admin can change fee parameters in response to changing blocktimes on the BOB chain, this approach will not mitigate volatility in blocktimes, nor halts in block generation. +## Impact +When blocktimes speed up, users will overpay fees, while if blocktimes slow down, underpayments of fees will occur. Fee payments will generally be inconsistent, and trust in the system will be eroded. In the event of a halt in block production, no funding (or borrowing) payments will be made during that time, allowing some positions to remain unliquidated for a longer period of time and depriving positions / lps of any fees. + +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +Consider changing fee calculations to use timestamps instead of block numbers, given the predictability of block.timestamp, as it also does not compromise the security of the system. \ No newline at end of file diff --git a/011/050.md b/011/050.md new file mode 100644 index 0000000..8468295 --- /dev/null +++ b/011/050.md @@ -0,0 +1,165 @@ +Damaged Fern Bird + +High + +# pools.vy: calc_mint does not account for outstanding fees, allowing attackers to steal fees from LP + +### Summary + +Inside of the [calc_mint](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L163-L172) the contract calculates how much LP-Tokens should be minted when a provider adds new liquidity. This does not account for outstanding fees. Therefor a new user gets the same LP-Token ratio as older LP providers. **This allows new providers to steal fees.** + +### Root Cause + +In the [`calc_mint`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L163-L172) function, LP tokens are calculated based on the [`total_reserves`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L119-L121) , which reflect the current pool state but do not account for any pending fees. This allows a large token holder to deposit just before a regular user closes their position. + +When closing a position, the core contract calls [`close`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L258-L276) function, updating the pool reserves to reflect the added tokens paid as fee. As a result, the pool now contains more liquidity. + +The attacker can then unstake their LP tokens and, based on the ratio of their LP share relative to the total LP value before the attack, they are able to claim a significant portion of the fees paid by the user. + +### Internal pre-conditions + +1. Large position will be closed and attacker notices before the tx is settled (i.e. by looking at the mem-pool) + +### External pre-conditions + +N/A + +### Attack Path + +1. Mint large LP position +2. wait for closing transaction to settle +3. Burn the LP position + +### Impact + +The attacker is able to steal fees that should have been paid to the older LP. + +### PoC + +## Walkthrough + +A user opens a large position in the pool: +```vyper + # user opens positions and closes it after 1 weeks + tx = open(VEL, STX, True, d(999), 10, price=d(5), sender=long) + assert not tx.failed + + chain.pending_timestamp += round(datetime.timedelta(weeks=1).total_seconds()) +``` + +The attacker now mints new LP tokens with a relatively large position vs. the existing LPs. In this case the new LP is ~100x the old LP: +```vyper + mint(VEL, STX, LP, d(10_000_000), d(50_000_000), price=d(5), sender=lp_provider2) +``` + +Close tx settles and fees get paid to the pool: +```vyper + tx = close(VEL, STX, 1, price=d(5), sender=long) +``` + +The attacker now burns all his LP tokens: +```vyper + lp_amount_lp1 = LP.balanceOf(lp_provider) + burn(VEL, STX, LP, lp_amount_lp2, price=d(5), sender=lp_provider2) +``` + +The original LP only earned 1 of each token: +```vyper + lp1_inital_value = d(10_000) * 5 + d(50_000) + lp1_value = lp1_base * 5 + lp1_quote # (as quote tokens) + lp1_profit = lp1_value - lp1_inital_value # (as quote tokens) + assert lp1_profit < 10 # lp1_profit is 6: 1 of base and quote +``` + +The attacker earned most of the fees, ~120 quote tokens: +```vyper + lp2_inital_value = d(10_000_000) * 5 + d(50_000_000) + lp2_value = lp2_base * 5 + lp2_quote # (as quote tokens) + lp2_profit = lp2_value - lp2_inital_value # (as quote tokens) + assert lp2_profit > 100 # lp2_profit ~ 120 (as quote tokens) +``` + +## Diff + +Update the [`conftest.py`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/tests/conftest.py) to be the same as the currently planed parameters (based on sponsor msg in discord): +```diff +--- a/conftest.py.orig ++++ b/conftest.py +@@ -58,8 +58,8 @@ def LP2(project, core, owner): + # ----------- Params ----------------- + + PARAMS = { +- 'MIN_FEE' : 1, +- 'MAX_FEE' : 1, ++ 'MIN_FEE' : 10, ++ 'MAX_FEE' : 100, + + # Fraction of collateral (e.g. 1000). + 'PROTOCOL_FEE' : 1000, +``` + +```diff +--- a/test_positions.py.orig ++++ b/test_positions.py +@@ -137,3 +137,52 @@ def test_value(setup, positions, open, VEL, STX, owner, long): + 'borrowing_paid_want' : 0, + 'remaining' : 1998000 + } ++ ++def test_burn_fees(chain, setup, open, close, VEL, STX, LP, long, mint_token, core, mint, burn, lp_provider, lp_provider2): ++ setup() ++ ++ # user opens positions and closes it after 1 weeks ++ tx = open(VEL, STX, True, d(999), 10, price=d(5), sender=long) ++ assert not tx.failed ++ ++ chain.pending_timestamp += round(datetime.timedelta(weeks=1).total_seconds()) ++ ++ # ============= frontrun part ++ mint_token(VEL, d(10_000_000), lp_provider2) ++ mint_token(STX, d(50_000_000), lp_provider2) ++ assert not VEL.approve(core.address, d(10_000_000), sender=lp_provider2).failed ++ assert not STX.approve(core.address, d(50_000_000), sender=lp_provider2).failed ++ mint(VEL, STX, LP, d(10_000_000), d(50_000_000), price=d(5), sender=lp_provider2) ++ # ============= frontrun part ++ ++ # ============= user tx ++ tx = close(VEL, STX, 1, price=d(5), sender=long) ++ assert not tx.failed ++ # ============= user tx ++ ++ # ============= backrun part ++ lp_amount_lp2 = LP.balanceOf(lp_provider2) ++ burn(VEL, STX, LP, lp_amount_lp2, price=d(5), sender=lp_provider2) ++ # ============= backrun part ++ ++ # ============= original lp unstake ++ lp_amount_lp1 = LP.balanceOf(lp_provider) ++ burn(VEL, STX, LP, lp_amount_lp1, price=d(5), sender=lp_provider) ++ # ============= original lp unstake ++ ++ lp1_base = VEL.balanceOf(lp_provider) - d(90_000) # ignore the unstaked balance ++ lp1_quote = STX.balanceOf(lp_provider) - d(50_000) # ignore the unstaked balance ++ lp2_base = VEL.balanceOf(lp_provider2) ++ lp2_quote = STX.balanceOf(lp_provider2) ++ ++ # allow for small profit ++ lp1_inital_value = d(10_000) * 5 + d(50_000) ++ lp1_value = lp1_base * 5 + lp1_quote # (as quote tokens) ++ lp1_profit = lp1_value - lp1_inital_value # (as quote tokens) ++ assert lp1_profit < 10 # lp1_profit is 6: 1 of base and quote ++ ++ # LP2 made a profit ++ lp2_inital_value = d(10_000_000) * 5 + d(50_000_000) ++ lp2_value = lp2_base * 5 + lp2_quote # (as quote tokens) ++ lp2_profit = lp2_value - lp2_inital_value # (as quote tokens) ++ assert lp2_profit > 100 # lp2_profit ~ 120 (as quote tokens) +``` + + + + + +### Mitigation + +The contract `pools.vy` should call the `fees.vy` contract and look up the currently outstanding fees inside of the [`total_reserves`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L119-L121) function. \ No newline at end of file diff --git a/011/051.md b/011/051.md new file mode 100644 index 0000000..bfdccf8 --- /dev/null +++ b/011/051.md @@ -0,0 +1,166 @@ +Damaged Fern Bird + +High + +# Liquidity providers will not receive any outstanding fees for positions they backed + +### Summary + +When a liquidity provider leaves the pool by calling [`burn`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L104-L128) the core contract calls [`calc_burn`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L213-L223) to calculate the amount of base and quote token to distribute. This function does not consider outstanding fees. Therefore the existing LP does receive less tokens as he should. + +### Root Cause + +In [`calc_burn`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L213-L223) the contract uses [`total_reserves`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L119-L121) as the total pool value. But this does not consider outstanding fees that will only be paid when the user position is closed or liquidated. This is arguably not fair to the LP that is leaving. + +### Internal pre-conditions + +1. open positions with outstanding fees + +### External pre-conditions + +N/A + +### Attack Path + +N/A + +### Impact + +The existing LP will get less tokens as he should. The amount he loses out on is based on the amount of outstanding fees in open positions and his ratio compared to the remaining pool. If there are 0.1 ETH outstanding fees, and he owns 10% of the pool, he loses 0.01 ETH profit. + +### PoC + +## Walkthrough + +LP1 and LP2 mint their positions: +```vyper + mint(VEL, STX, LP, d(10_000), d(50_000), price=d(5), sender=lp_provider) + mint(VEL, STX, LP, d(10_000), d(50_000), price=d(5), sender=lp_provider2) +``` + +A user opens a position: +```vyper + tx = open(VEL, STX, True, d(999), 10, price=d(5), sender=long) +``` + +After 10 weeks some fees accumulated. At that time the LP2 burns his LP tokens: +```vyper + lp_amount_lp2 = LP.balanceOf(lp_provider2) + burn(VEL, STX, LP, lp_amount_lp2, price=d(5), sender=lp_provider2) +``` + +Afterwards the user closes his position: +```vyper + tx = close(VEL, STX, 1, price=d(5), sender=long) +``` + +And now also the LP1 burns his LP tokens: +```vyper + lp_amount_lp1 = LP.balanceOf(lp_provider) + burn(VEL, STX, LP, lp_amount_lp1, price=d(5), sender=lp_provider) +``` + +Calculating the profit of LP1 and LP2, one would assume that both should have the same profit. They both backed the user position for the same time (LP2 for a few blocks less at most). Therefor it is asserted that: +```vyper + assert lp1_profit == lp2_profit +``` + +This test will fail, as the profits are not distributed fairly. It reverts with: +```text + FAILED tests/test_positions.py::test_burn_fees - assert 41 == 0 +``` + +One can see that LP1 earned all the fees paid by the user and LP2 got none. + +## Full diff for POC + +Update the [`conftest.py`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/tests/conftest.py) to be the same as the currently planed parameters (based on sponsor msg in discord): +```diff +--- a/conftest.py.orig ++++ b/conftest.py +@@ -58,8 +58,8 @@ def LP2(project, core, owner): + # ----------- Params ----------------- + + PARAMS = { +- 'MIN_FEE' : 1, +- 'MAX_FEE' : 1, ++ 'MIN_FEE' : 10, ++ 'MAX_FEE' : 100, + + # Fraction of collateral (e.g. 1000). + 'PROTOCOL_FEE' : 1000, +``` + +Changes made to the existing test file [test_positions.py](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/tests/test_positions.py): +```diff +--- a/test_positions.py.orig ++++ b/test_positions.py +@@ -137,4 +137,54 @@ def test_value(setup, positions, open, VEL, STX, owner, long): + 'borrowing_paid_want' : 0, + 'remaining' : 1998000 + } +- +\ No newline at end of file ++ ++def test_burn_fees(chain, setup, open, close, VEL, STX, LP, long, mint_token, core, mint, burn, lp_provider, lp_provider2): ++ setup() ++ ++ # ============= 2nd LP joins: same size as LP1 ++ mint_token(VEL, d(100_000), lp_provider2) ++ mint_token(STX, d(100_000), lp_provider2) ++ assert not VEL.approve(core.address, d(100_000), sender=lp_provider2).failed ++ assert not STX.approve(core.address, d(100_000), sender=lp_provider2).failed ++ mint(VEL, STX, LP, d(10_000), d(50_000), price=d(5), sender=lp_provider2) ++ # ============= 2nd LP joins ++ ++ # ============= user opens positions ++ tx = open(VEL, STX, True, d(999), 10, price=d(5), sender=long) ++ assert not tx.failed ++ # ============= user opens positions ++ ++ # wait 10 weeks ++ chain.pending_timestamp += round(datetime.timedelta(weeks=10).total_seconds()) ++ ++ # ============= 2nd LP leaves again: user not yet closed ++ lp_amount_lp2 = LP.balanceOf(lp_provider2) ++ burn(VEL, STX, LP, lp_amount_lp2, price=d(5), sender=lp_provider2) ++ # ============= 2nd LP leaves again ++ ++ # ============= user close tx ++ tx = close(VEL, STX, 1, price=d(5), sender=long) ++ assert not tx.failed ++ # ============= user close tx ++ ++ # ============= original lp unstake ++ lp_amount_lp1 = LP.balanceOf(lp_provider) ++ burn(VEL, STX, LP, lp_amount_lp1, price=d(5), sender=lp_provider) ++ # ============= original lp unstake ++ ++ lp1_base = VEL.balanceOf(lp_provider) - d(90_000) # ignore the unstaked balance ++ lp1_quote = STX.balanceOf(lp_provider) - d(50_000) # ignore the unstaked balance ++ lp2_base = VEL.balanceOf(lp_provider2) - d(90_000) # ignore the unstaked balance ++ lp2_quote = STX.balanceOf(lp_provider2) - d(50_000) # ignore the unstaked balance ++ ++ # calcualte profit LP1 ++ lp1_inital_value = d(10_000) * 5 + d(50_000) ++ lp1_value = lp1_base * 5 + lp1_quote # (as quote tokens) ++ lp1_profit = lp1_value - lp1_inital_value # (as quote tokens) ++ ++ # calcualte profit LP2 ++ lp2_inital_value = d(10_000) * 5 + d(50_000) ++ lp2_value = lp2_base * 5 + lp2_quote # (as quote tokens) ++ lp2_profit = lp2_value - lp2_inital_value # (as quote tokens) ++ ++ assert lp1_profit == lp2_profit +``` + + + + + + + + +### Mitigation + +The contract `pools.vy` should call the `fees.vy` contract and look up the currently outstanding fees inside of the [`total_reserves`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L119-L121) function. \ No newline at end of file diff --git a/012.md b/012.md new file mode 100644 index 0000000..171abf5 --- /dev/null +++ b/012.md @@ -0,0 +1,33 @@ +Amateur Nylon Canary + +High + +# Traders may decrease their trading loss via mint/burn + +## Summary +When traders have some trading loss, they can decrease their trading loss via mint LP before closing position and burn LP after closing position. + +## Vulnerability Detail +When traders open one position with long or short size, traders have some negative unrealized Pnl. When traders close their positions, LP holders as traders' counterparty, will take traders' loss as their profits. +The problem is that there is not any limitation for mint()/burn() functions. Traders can make use of this problem to decrease their trading loss. +For example: +1. Alice opens one Long position in BTC/USDT market. +2. BTC price decreases and Alice's long position has some negative Pnl. +3. Alice wants to close her position. +4. Alice borrows one huge amount of BTC/USDT and mint in this BTC/USDT market to own lots of shares. +5. Alice closes her position and her loss will become LP's profit, this will increase LP share's price. +6. Alice burns her share with one higher share price. + +In this way, Alice can avoid most of her trading loss. + +## Impact +Traders have ways to avoid trading loss. This means that LPs will lose some profits that they deserve. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L154-L226 + +## Tool used +Manual Review + +## Recommendation +Add one LP locking mechanism. When someone mints some shares in the market, he cannot burn his shares immediately. After a cool down period, he can burn his shares. diff --git a/012/061.md b/012/061.md new file mode 100644 index 0000000..aac42fb --- /dev/null +++ b/012/061.md @@ -0,0 +1,94 @@ +Scrawny Boysenberry Mammoth + +Medium + +# Inadequate Validation in mint() may lead to liquidity pool imbalance + +## Summary +[mint](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L154) function in core.vy allows zero token contribution for base_token or quote_token, which will potentially lead to pool instability. +## Vulnerability Detail +mint() in core.vy is defined as: +```python +@external +def mint( + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + base_amt : uint256, + quote_amt : uint256, + ctx : Ctx) -> uint256: + + self._INTERNAL() + + user : address = tx.origin + total_supply: uint256 = ERC20(lp_token).totalSupply() + pool : PoolState = self.POOLS.lookup(id) + lp_amt : uint256 = self.POOLS.calc_mint(id, base_amt, quote_amt, total_supply, ctx) + + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token, ERR_PRECONDITIONS + assert pool.lp_token == lp_token , ERR_PRECONDITIONS + assert base_amt > 0 or quote_amt > 0 , ERR_PRECONDITIONS + assert lp_amt > 0 , ERR_PRECONDITIONS + + assert ERC20(base_token).transferFrom(user, self, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transferFrom(user, self, quote_amt, default_return_value=True), "ERR_ERC20" + assert ERC20Plus(lp_token).mint(user, lp_amt), "ERR_ERC20" + + self.POOLS.mint(id, base_amt, quote_amt) + self.FEES.update(id) + + self.INVARIANTS(id, base_token, quote_token) + + log Mint(user, ctx, pool, total_supply, lp_amt, base_amt, quote_amt) + + return lp_amt +``` +the line `assert base_amt > 0 or quote_amt > 0 , ERR_PRECONDITIONS` allows one of the amount to be zero. However ı do not think this is correct implementation. This is against standart practice for liquidity provision. Allowing one of the amounts to be zero would disrupt the intended balance between the two tokens in the pool and could potentially lead to inadequate liquidity for one side of the pair or manipulation. +My point is also corrected by the protocol test written for mint(). Here is the related [test](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/tests/core/test_core.py#L101): +```python +def test_mint(core, + api, oracle, pools, positions, fees, + mint, + owner, lp_provider, + VEL, STX, USD, LP, LP2, mint_token): + + tx = core.fresh("VEL-STX", VEL, STX, LP, sender=owner) + assert not tx.failed + tx = mint_token(VEL, 10_000_000_000, lp_provider) + assert not tx.failed + tx = mint_token(STX, 10_000_000_000, lp_provider) + assert not tx.failed + + VEL.approve(core.address, d(10_000), sender=lp_provider) + STX.approve(core.address, d(10_000), sender=lp_provider) + assert VEL.allowance(lp_provider, core) == d(10_000) + assert STX.allowance(lp_provider, core) == d(10_000) + + with ape.reverts(ERR_PRECONDITIONS): + mint(STX, VEL, LP , d(100), d(500), price=d(5), sender=lp_provider) # wrong token + mint(VEL, USD, LP , d(100), d(500), price=d(5), sender=lp_provider) + mint(VEL, STX, LP2, d(100), d(500), price=d(5), sender=lp_provider) + mint(VEL, STX, LP, 0, 0 , price=d(5), sender=lp_provider) # amt 0 + mint(VEL, STX, LP, 0, d(100), price=d(5), sender=lp_provider) #@audit-issue disagrees with the code + + tx = mint(VEL, STX, LP, d(100), d(500), price=d(5), sender=lp_provider) + assert not tx.failed + + logs = core.Mint.from_receipt(tx) + assert logs[0].user == lp_provider + assert logs[0].total_supply == 0 # before + assert logs[0].lp_amt == 1_000_000_000 + assert logs[0].base_amt == 100_000_000 + assert logs[0].quote_amt == 500_000_000 +``` +In the line `mint(VEL, STX, LP, 0, d(100), price=d(5), sender=lp_provider)`, `base_amt` is 0 and `quote_amt` is 100 and the test is expected to revert. However this contradicts with the code. Hence proving my point mentioned above. +## Impact +Potential pool Instability due to the zero contribution intentionally/untentionally, which may lead to undesired outcomes for users/protocol +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L154 +## Tool used +Manual Review,Vs Code +## Recommendation +Use `and` instead of `or` \ No newline at end of file diff --git a/012/070.md b/012/070.md new file mode 100644 index 0000000..8157e10 --- /dev/null +++ b/012/070.md @@ -0,0 +1,115 @@ +Scrawny Boysenberry Mammoth + +Medium + +# Failure of Liquidity Addition-Withdrawal in USDT Based Pools + +## Summary +`mint()` or `burn()` function in core.vy may not work when USDT is used as quote token. Because it will result in invariant break, which potentially lead some issues explained below. +## Vulnerability Detail +**First Scenario** +Liquidity can be added via mint() function in api.vy, which makes a call to the [mint()](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L155) in core.vy. Let's look at this function: +```python +@external +def mint( + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + base_amt : uint256, + quote_amt : uint256, + ctx : Ctx) -> uint256: + + self._INTERNAL() + + user : address = tx.origin + total_supply: uint256 = ERC20(lp_token).totalSupply() + pool : PoolState = self.POOLS.lookup(id) + lp_amt : uint256 = self.POOLS.calc_mint(id, base_amt, quote_amt, total_supply, ctx) #@audit-issue more lp will be minted in case usdt is FoT later + + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token, ERR_PRECONDITIONS + assert pool.lp_token == lp_token , ERR_PRECONDITIONS + assert base_amt > 0 or quote_amt > 0 , ERR_PRECONDITIONS + assert lp_amt > 0 , ERR_PRECONDITIONS + + assert ERC20(base_token).transferFrom(user, self, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transferFrom(user, self, quote_amt, default_return_value=True), "ERR_ERC20" #@audit-issue will transfer less in case USDT implements fee-on transfer + assert ERC20Plus(lp_token).mint(user, lp_amt), "ERR_ERC20" + + self.POOLS.mint(id, base_amt, quote_amt) #@audit reserves will be increased by incorrect amount + self.FEES.update(id) + + self.INVARIANTS(id, base_token, quote_token) + + log Mint(user, ctx, pool, total_supply, lp_amt, base_amt, quote_amt) + + return lp_amt +``` + USDT is in a category of ERC20 tokens have the possibility of adding fees in the future. Based on this, if it implements fee-on transfer, the amount transfered in the line `assert ERC20(quote_token).transferFrom(user, self, quote_amt, default_return_value=True), "ERR_ERC20"` will be less than actual amount, which will lead to breaking an invariant defined in [self.INVARIANTS](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L125C1-L133C68). Let's take a look at this function: +```python +@internal +def INVARIANTS(id: uint256, base_token: address, quote_token: address): + pool : PoolState = self.POOLS.lookup(id) + base_balance : uint256 = ERC20(base_token).balanceOf(self) + quote_balance: uint256 = ERC20(quote_token).balanceOf(self) + assert base_balance >= (pool.base_reserves + pool.base_collateral), ERR_INVARIANTS + assert quote_balance >= (pool.quote_reserves + pool.quote_collateral), ERR_INVARIANTS #@audit-issue this will revert + assert pool.base_reserves >= pool.base_interest, ERR_INVARIANTS + assert pool.quote_reserves >= pool.quote_interest, ERR_INVARIANTS +``` +The line `assert quote_balance >= (pool.quote_reserves + pool.quote_collateral), ERR_INVARIANTS` will revert. +Suppose the user transfers 100 USDT, and there's a 1% fee. The pool will only receive 99 USDT. The state variable quote_reserves will be updated as if the pool received 100 USDT, but in reality, the pool contract balance only holds 99 USDT. Therefore mint() will not be functioning as expected. +**Second Scenario:** +Consider a pool is created with one of the pair token being USDT when it has no fee-on transfer. The pool has been working with many users and after a while USDT has implemented fee-on transfer. Let's see what happens when users wants to withdraw their liquidity. The [burn](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L191) in core.vy will be called: +```python +@external +def burn( + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + lp_amt : uint256, + ctx : Ctx) -> Tokens: + + self._INTERNAL() + + user : address = tx.origin + total_supply: uint256 = ERC20(lp_token).totalSupply() + pool : PoolState = self.POOLS.lookup(id) + amts : Tokens = self.POOLS.calc_burn(id, lp_amt, total_supply, ctx) + base_amt : uint256 = amts.base + quote_amt : uint256 = amts.quote + + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token, ERR_PRECONDITIONS + assert pool.lp_token == lp_token , ERR_PRECONDITIONS + assert base_amt > 0 or quote_amt > 0 , ERR_PRECONDITIONS + assert lp_amt > 0 , ERR_PRECONDITIONS + + assert ERC20(base_token).transfer(user, base_amt, default_return_value=True), "ERR_ERC20" #@audit balance will be decreased less than expected due the fee + assert ERC20(quote_token).transfer(user, quote_amt, default_return_value=True), "ERR_ERC20" + assert ERC20Plus(lp_token).burn(user, lp_amt), "ERR_ERC20" + + self.POOLS.burn(id, base_amt, quote_amt) #@reserves will be decreased by quote_amt + self.FEES.update(id) + + self.INVARIANTS(id, base_token, quote_token) + + log Burn(user, ctx, pool, total_supply, lp_amt, base_amt, quote_amt) + + return amts +``` +The check `quote_balance >= (pool.quote_reserves + pool.quote_collateral), ERR_INVARIANTS.` in `self.INVARIANTS()` will be reverted due the insufficient balance and making impossible for users to withdraw their liquidity. +For simplicity and to see the flow more clearly, the same example mentioned above can be given. Assume a user is withdrawing liquidity, and based on their LP share, the protocol determines they are entitled to withdraw 100 USDT. The protocol’s internal state shows `quote_reserves = 100 USDT`, which reflects the total USDT held by the contract for this pool. The contract calls `ERC20(quote_token).transfer(user, quote_amt)` where quote_amt = 100 USDT. If USDT applies a 1% fee, the contract sends 100 USDT to the transfer function, but the user only receives 99 USDT, and 1 USDT is taken as a fee. Now, the contract’s USDT balance is 99 USDT due the deducted fee while the protocol’s internal quote_reserves still expects 100 USDT. Therefore the invariant mentioned above will revert. +## Impact +For the first case, considering each pool has unique pair of token, another quote token can not be used and the core functionality of the pool will be broken, hence making the pool useless. +For the second case, users funds may potentially stuck in the contract. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L155 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L125C1-L133C68 + +## Tool used +Manual Review +## Recommendation +I would recommend check balance before/after of quote token and do the necessary implementation based on the actual deposit/withdrawal amount. diff --git a/013.md b/013.md new file mode 100644 index 0000000..4cba774 --- /dev/null +++ b/013.md @@ -0,0 +1,76 @@ +Dry Khaki Locust + +High + +# The protocol is not compatible with abstract wallets + +### Summary + +Abstract wallet users will not be able to work with the protocol due to `tx.origin`. + +### Root Cause + +The protocol has `api.vy` as an entry point, and other contracts strictly limit only `api.vy` or `core.vy` to call functions, whereas most functions in `core.vy` can only be called by `api.vy` contract. So the protocol uses `tx.origin` as a way to replace `msg.sender`, the following example is from [`core::mint`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L155): +```vyper +@external +def mint( + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + base_amt : uint256, + quote_amt : uint256, + ctx : Ctx) -> uint256: + + self._INTERNAL() + + user : address = tx.origin + total_supply: uint256 = ERC20(lp_token).totalSupply() + pool : PoolState = self.POOLS.lookup(id) + lp_amt : uint256 = self.POOLS.calc_mint(id, base_amt, quote_amt, total_supply, ctx) + + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token, ERR_PRECONDITIONS + assert pool.lp_token == lp_token , ERR_PRECONDITIONS + assert base_amt > 0 or quote_amt > 0 , ERR_PRECONDITIONS + assert lp_amt > 0 , ERR_PRECONDITIONS + + assert ERC20(base_token).transferFrom(user, self, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transferFrom(user, self, quote_amt, default_return_value=True), "ERR_ERC20" + assert ERC20Plus(lp_token).mint(user, lp_amt), "ERR_ERC20" + + self.POOLS.mint(id, base_amt, quote_amt) + self.FEES.update(id) + + self.INVARIANTS(id, base_token, quote_token) + + log Mint(user, ctx, pool, total_supply, lp_amt, base_amt, quote_amt) + + return lp_amt +``` + +For most cases, it works fine, as `tx.origin` will also be `msg.sender` to the API contract, but for abstract wallets, as it's essentially a contract, `tx.origin` will go back to the very origin of the transaction, and it will not be the wallet address. This makes abstract wallet users unable to interact with the pool. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Abstract wallet users will not be able to interact with the protocol. + +### PoC + +_No response_ + +### Mitigation + +Add a `account` argument to the parameter, and sets this value to `msg.sender` in the API contract, and pass it on to the CORE contract. \ No newline at end of file diff --git a/013/064.md b/013/064.md new file mode 100644 index 0000000..1d0586c --- /dev/null +++ b/013/064.md @@ -0,0 +1,66 @@ +Daring Shamrock Quail + +Medium + +# Liquidations can be prevented/DoSd due to the position user being on a token blacklist + +### Summary + +Since liquidations use a "push" approach, and send the remainder of the collateral to the position user, this can cause liquidations to fail, be temporarily DoSd due to the user either intentionally or unintentionally being put on a token blacklist (USDC, USDT). + +### Root Cause + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L336-L344 + +In Velar, the way that liquidations work is on the "push" principle, i.e. they send the remainder of the collateral in a certain position that is being liquidated (after subtracting the liquidation fees) to the user/owner of the position (the user who opened it): + +```solidity + + if base_amt_final.fee > 0: + assert ERC20(base_token).transfer(user, base_amt_final.fee, default_return_value=True), "ERR_ERC20" + if quote_amt_final.fee > 0: + assert ERC20(quote_token).transfer(user, quote_amt_final.fee, default_return_value=True), "ERR_ERC20" + if base_amt_final.remaining > 0: + assert ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True), "ERR_ERC20" + if quote_amt_final.remaining > 0: + assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" + +``` + +The problem is, if the position user is put on a token blacklist, tokens like USDC and USDT which will be utilized as quote tokens in the Velar protocol, have blacklists, which if a certain address is put on, they won't be able to transfer token to/from that address. + +Malicious users can also put themselves intentionally on such blacklists by interacting with sanctioned addresses or interacting with addresses which were part of a recent exploit, etc. and "sanction" themselves. + +In order for the liquidation to take place, the liquidation bots would have to wait until the remaining collateral is 0 (which means an increase in bad debt) in order to perform the liquidation. + +### Internal pre-conditions + +1. A user's position becomes liquidatable; +2. Internal liquidation bots try to liquidate the said position; +3. Liquidation reverts due to the position user's address being on the quote token's blacklist (USDC, USDT). +4. Liquidation will continue to revert until the collateral becomes 0 due to fees, which means an increasing risk of creating bad debt. + +### External pre-conditions + +1. User's address which they used to open the position is put on a USDC/USDT blacklist. +2. They'd have to wait until the remaining collateral/amount that needs to be sent becomes 0 so that the position is "liquidated". + + +### Attack Path + +1. User opens a position. +2. The user intentionally or unintentionally puts themselves (the address that they used to open the position) / is blacklisted on the USDC and/or USDT token blacklist. +3. The liquidation will fail/revert as long as there is any remaining amount that needs to be sent to the position user. +4. The liquidation bots will have to wait until there's 0 amount left to be sent to the user in order to liquidate the position, which means that bad debt can amount until that happens. + +### Impact + +Protocol will have an increasing risk of bad debt forming/amounting, due to liquidations reverting because of users being put on a token (USDC/USDT) blacklist. + +### PoC + +/ + +### Mitigation + +Implement a "pull" approach in which the funds that need to be sent to the user are "memorized" in an internal accounting system (mapping) and the user can pull the funds themselves. \ No newline at end of file diff --git a/013/110.md b/013/110.md new file mode 100644 index 0000000..8b0c78a --- /dev/null +++ b/013/110.md @@ -0,0 +1,33 @@ +Passive Hemp Chipmunk + +High + +# Blacklisted Users Cannot Be Liquidated + +## Summary +Users blacklisted by the base or quote token cannot be liquidated, as the liquidation function will revert when attempting to transfer tokens to the blacklisted user. + +## Vulnerability Detail +In the liquidation process, any remaining tokens are transferred back to the user being liquidated. However, if the user is blacklisted by either the base or quote token, these transfers will fail, causing the entire liquidation transaction to revert: + +[core.vy#L341-L344](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L341-L344) +```solidity + if base_amt_final.remaining > 0: + assert ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True), "ERR_ERC20" + if quote_amt_final.remaining > 0: + assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" +``` + +If `position.user` is blacklisted, these transfers will fail, and the assert statements will cause the transaction to revert. + +## Impact +Blacklisted users' positions cannot be liquidated, potentially leaving the protocol with bad debt and affecting its overall solvency. + +## Code Snippet +[core.vy#L341-L344](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L341-L344) + +## Tool used +Manual Review + +## Recommendation +Implement a pull-based mechanism for liquidated users to retrieve their funds, replacing the current push-based approach. \ No newline at end of file diff --git a/014/074.md b/014/074.md new file mode 100644 index 0000000..71637cf --- /dev/null +++ b/014/074.md @@ -0,0 +1,85 @@ +Magnificent Pewter Stork + +Medium + +# LPs cannot specify min amount received in burn function, causing loss of fund for them + +### Summary +LPs cannot set minimum base or quote amounts when burning LP tokens, leading to potential losses due to price fluctuations during transactions. + +### Root Cause +LPs cannot set the base received amount and the quote received amount + +### Impact +LPs may receive significantly lower amounts than expected when burning LP tokens, resulting in financial losses + +### Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L104 +### Internal pre-conditions +Consider change config in tests/conftest.py +I do it for better understanding,Fee doesn't important in this issue +```python +PARAMS = { + 'MIN_FEE' : 0, + 'MAX_FEE' : 0, + + 'PROTOCOL_FEE' : 1000 + ... +} +``` +### PoC +**Textual PoC:** +we assume protocol fee is zero in this example +1-Bob mints 20,000e6 LP token[base_reserve:10,000e6, quote_reserve:10,000e6] +2-Alice opens long position[collateral 1000 STX, LEV:5,Price:1] +3-Price goes up til $2 +4-Bob calls calc_burn[lp_amt:10,000e6,total_supply:20,000e6][return value:base 3750 VEL,quote 7500 STX] +5-Bob calls burn with above parameters +6-Alice calls close position +7-Alice's tx executed before Bob's tx +8-Bob's tx will be executed and Bob gets 3875 VEL and 4750 STX +9-Bob losts $2500 +**Coded PoC:** +place this test in tests/test_positions.py and run this command `pytest -k test_lost_assets -s` +```python + +def test_lost_assets(setup, VEL, STX, lp_provider, LP, pools, math, open, long, close, burn): + setup() + #Alice opens position + open(VEL, STX, True, d(1000), 5, price=d(1), sender=long) + + reserve = pools.total_reserves(1) + assert reserve.base == 10000e6 + assert reserve.quote == 10000e6 + #Bob calls calc_burn, Bob's assets in term of dollar is $15,000 + amts = pools.calc_burn(1, d(10000) , d(20000), ctx(d(2))) + + assert amts.base == 3752500000 + assert amts.quote == 7495000000 + + #Alice closes her position + bef = VEL.balanceOf(long) + close(VEL, STX, 1, price=d(2), sender=long) + after = VEL.balanceOf(long) + + vel_bef = VEL.balanceOf(lp_provider) + stx_bef = STX.balanceOf(lp_provider) + + amts = pools.calc_burn(1, d(10000) , d(20000), ctx(d(2))) + assert amts.base == 3877375030 + assert amts.quote == 4747749964 + #Bob's tx will be executed + burn(VEL, STX, LP, d(10000), price=d(2), sender=lp_provider) + + vel_after = VEL.balanceOf(lp_provider) + stx_after = STX.balanceOf(lp_provider) + + + print("vel_diff:", (vel_after - vel_bef) / 1e6)#3877.37503 VEL + print("stx_diff:", (stx_after - stx_bef) / 1e6)#4747.749964 STX + #received values in term of dollar is ~ $12,500,Bob lost ~ $2500 +``` + +### Mitigation + +Consider adding min_base_amount and min_quote_amount to the burn function's params or adding min_assets_value for example when the price is $2 LPs set this param to $14800, its mean received value worse has to be greater than $14800 \ No newline at end of file diff --git a/014/104.md b/014/104.md new file mode 100644 index 0000000..804620f --- /dev/null +++ b/014/104.md @@ -0,0 +1,80 @@ +Magnificent Pewter Stork + +Medium + +# Lp amount will be minted lower than what it really expected in mint function + +### Summary + +LPs may receive fewer tokens than expected when minting due to fluctuating pool reserves. Without specifying a minimum LP amount, this can lead to unexpected fund losses for LPs. Adding a min_lp_amount parameter to the Api::mint function can prevent this issue + + +### Internal pre-conditions +Consider change config in tests/conftest.py +I do it for better understanding,Fee doesn't important in this issue +```python +PARAMS = { + 'MIN_FEE' : 0, + 'MAX_FEE' : 0, + + 'PROTOCOL_FEE' :1000 + ... +} +``` +### Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L75 + +### Impact + +Lp amount will be minted lower than what it really expected + +### PoC +**Textual PoC:** +we assume protocol fee is zero in this example +1-Alice opens long position when price is $2 +2-Joe opens a short position when price is $2 +3-Price goes down to $1 +4-Bob calls `Pools::calc_mint` and he realize if he provide 1000 VEL to pool,he gets 1000 LP +5-Bob send his tx to network +6-liquidator bot liquids Alice's position +7-Bob's tx will be executed and Bob get 952 lp instead of 1000 +8-Joe closes his position and gets his profit,hence pool's reserve will be deducted +9-Bob burns his LPs token,and he gets 772 VEL instead of 1000 VEL +**Coded PoC:** +Place below test in `tests/test_positions.py` and run `pytest -k test_get_less_lp_amt_than_expected - s` +```python +def test_get_less_lp_amt_than_expected(setup, VEL, STX, lp_provider, LP, pools, open, long, close, burn, liquidator, liquidate, mint, short): + setup() + #Alice opens position + open(VEL, STX, True, d(1000), 5, price=d(2), sender=long) + #Joe opens short position + open(VEL, STX, False, d(1000), 5, price=d(2), sender=short) + + #Bob calls calc_mint + assert pools.calc_mint(1, d(1000), 0, d(20_000), ctx(d(1))) == d(1000) + + #Alice will be liquidated + liquidate(VEL, STX, 1, price = d(1), sender = liquidator) + + #Bob's tx will be executed + mint(VEL, STX, LP, d(1000), 0, price=d(1), sender=lp_provider) + + assert LP.totalSupply() == 20952426306#its mean,Bob get 48 lp token lower than expected + + #Joe closes his position + close(VEL, STX, 2, price=d(1), sender=short) + + + vel_bef = VEL.balanceOf(lp_provider) + #Bob burn his lp tokens + burn(VEL, STX, LP, 952426306, price=d(1), sender=lp_provider) + + vel_after = VEL.balanceOf(lp_provider) + + print("vel_diff:", (vel_after - vel_bef) / 1e6)#Bob lost $228 +``` + + +### Mitigation + +`Api::mint` should have another parameter like min_lp_amount which can be checked when the lp token wants to be mint \ No newline at end of file diff --git a/015.md b/015.md new file mode 100644 index 0000000..15b30b8 --- /dev/null +++ b/015.md @@ -0,0 +1,61 @@ +Amateur Nylon Canary + +Medium + +# Last LP in each pool may lose a few amount of reserve. + +## Summary +Last Lp's burn() may be reverted if malicious users leave 1 wei share in this pool. + +## Vulnerability Detail +At the end of each core function, we will trigger Fee's update. In the fee's update, we need to calculate the Lp tokens' utilization. The problem is that the utilization calculation will be reverted if reserves is not zero but less than 100. + +This scenario is possible, especially when malicious users intent to do this. +- Malicious users can mint one very small share in one market, for example, 1 share. +- When last LP wants to burn his shares, the left reserve will be one very small amount but larger than 0. Because malicious users hold 1 share. +- When we want to update the fees, the transaction will be reverted because the utilization calculation will be reverted. + +The last LP holder can still burn his most share via leaving some amount of share(reserves) into the pool. +Although this amount is not large, considering that this case could happen in every market, more LPs may take some loss because of this finding. + +```vyper +@internal +@pure +def utilization(reserves: uint256, interest: uint256) -> uint256: + # Then the last LP want to withdraw, will be reverted. +@> return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) +``` + +```vyper +@external +@view +def calc_mint( + id : uint256, + base_amt : uint256, + quote_amt : uint256, + total_supply: uint256, + ctx : Ctx) -> uint256: + pv: uint256 = self.MATH.value(Pools(self).total_reserves(id), ctx).total_as_quote + mv: uint256 = self.MATH.value(Tokens({base: base_amt, quote: quote_amt}), ctx).total_as_quote + return Pools(self).f(mv, pv, total_supply) + +# ts --> total supply +@external +@pure +def f(mv: uint256, pv: uint256, ts: uint256) -> uint256: + if ts == 0: return mv + else : return (mv * ts) / pv + +``` +## Impact +Last LP holder in each pool may have to leave a few amount of reserve in this market. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L57-L63 + +## Tool used + +Manual Review + +## Recommendation +Make sure the utilization calculation will not be reverted. \ No newline at end of file diff --git a/015/078.md b/015/078.md new file mode 100644 index 0000000..125b794 --- /dev/null +++ b/015/078.md @@ -0,0 +1,47 @@ +Dancing Topaz Perch + +Medium + +# Improper calculation of utilization ratio + +## Summary +Utilization ratio calculation is executed in improper way that might lead to DoS. + +## Vulnerability Detail +```vyper +def utilization(reserves: uint256, interest: uint256) -> uint256: + """ + Reserve utilization in percent (rounded down). + """ + return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) +``` + +Here's the code snippet of `utilization` function that calculates utilization ration based on `interest` and `reserves` and returns as percentage between 0 and 100. + +As shown in the code snippet, it calculates `reserves / 100` first and then divides the `interest`. +This means that the transaction reverts when `reserves` is between 1 and 99 because of division by zero. + +The `utilization` function is called whenever there's an action in the protocol, e.g. opening and closing positions. +So as a result, when `reserves` is less than 100, the protocol does not work until someone adds LP to the pool, even though this can be rare cases in usual. + +## Impact +DoS of protocol because if incorrect calculation. +Another impact is the description says it does rounding down, but it will round-up in some cases. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L59-L63 + +## Tool used +Manual Review + +## Recommendation +The calculation should be modified as follows: + +```diff +def utilization(reserves: uint256, interest: uint256) -> uint256: + """ + Reserve utilization in percent (rounded down). + """ +- return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) ++ return 0 if (reserves == 0 or interest == 0) else (interest * 100 / reserves) +``` diff --git a/015/097.md b/015/097.md new file mode 100644 index 0000000..eb39c74 --- /dev/null +++ b/015/097.md @@ -0,0 +1,101 @@ +Mammoth Blonde Walrus + +Medium + +# params.vy::utilization will revert if reserves < 100 and this will provoke a div by zero leading to reverting txs + +## Summary +params.vy::utilization will revert if reserves < 100 leading to division by zero so, all txs will fail when this is the case when trying to calculate borrowing and funding dynamic fees + +## Vulnerability Detail +param.vy::utilization perform interest / (reserves / 100) +However if reserves < 100 , reserves / 100 division will be zero +So interest / (reserves / 100) will become interest / 0 +leading to an unchecked division by zero and all txs will fail +```solidity +def utilization(reserves: uint256, interest: uint256) -> uint256: + """ + Reserve utilization in percent (rounded down). + """ + return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) # <@= +``` +The following proof of concept shows an example scenario +params::utilization method is called by params::dynamicfees to calculate borrowing and funding dynamic fees. +So this PoC calls dynamic fees for a pool with reserves > 100 and for a pool with reserves < 100, the first call will suceeds but the second call will fail when calculate dynamic fees: +Create file test_param_reserves_reverts.py in tests dir with the following content: +```solidity +import ape +import pytest +POOL = { + 'id' : 1, + 'symbol' : 'VEL-STX', + 'base_token' : 0, #VEL, + 'quote_token' : 0, #STX, + 'lp_token' : 0, #lpToken, + 'base_reserves' : 0, + 'quote_reserves' : 0, + 'base_interest' : 0, + 'quote_interest' : 0, + 'base_collateral' : 0, + 'quote_collateral' : 0, +} + +BASE_FEE = 1 # * 1_000_000 +def test_params_reverts_reserve_gteq100_not(params,owner): + p = { + **POOL, + 'base_interest' : 100, + 'base_reserves' : 100, + 'quote_interest' : 100, + 'quote_reserves' : 100, + 'base_collateral' : 100, + 'quote_collateral': 100, + } + fees = params.dynamic_fees(p) + assert fees == { + 'borrowing_long' : BASE_FEE, + 'borrowing_short' : BASE_FEE, + 'funding_long' : 0, + 'funding_short' : 0, + } + +def test_params_reverts_reserve_lt100_yes(params,owner): + p = { + **POOL, + 'base_interest' : 100, + 'base_reserves' : 99, + 'quote_interest' : 100, + 'quote_reserves' : 100, + 'base_collateral' : 100, + 'quote_collateral': 100, + } + fees = params.dynamic_fees(p) + assert fees == { + 'borrowing_long' : BASE_FEE, + 'borrowing_short' : BASE_FEE, + 'funding_long' : 0, + 'funding_short' : 0, + } +``` +Call tests with: +```bash +ape test tests/test_param_reserves_reverts.py +``` +Observe the first call succeeds and the second fails but none of them should fail + +## Impact +Loss of functionality: availability to open/close position cause inability to calculate fees + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L63 + +## Tool used + +Manual Review + +## Recommendation +```solidity +def utilization(reserves: uint256, interest: uint256) -> uint256: + return 0 if (reserves == 0 or interest == 0 or reserves < 100) else (interest / (reserves / 100)) +``` + diff --git a/016/090.md b/016/090.md new file mode 100644 index 0000000..e92b0cd --- /dev/null +++ b/016/090.md @@ -0,0 +1,61 @@ +Hot Purple Buffalo + +Medium + +# Loss of Funds From Profitable Positions Running Out of Collateral + +## Summary +When profitable positions run out of collateral, they receive no payout, even if they had a positive PnL. This is not only disadvantageous to users, but it critically removes all liquidation incentive. These zero'd out positions will continue to underpay fees until someone liquidates the position for no fee, losing money to gas in the process. + +## Vulnerability Detail +When calculating the pnl of either a long or short position that is to be closed, if the collateral drops to zero due to fee obligations then they [do not receive a payout](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L295): + +```vyper +# remaining first calculating in `calc_fees` + c0 : uint256 = pos.collateral + c1 : Val = self.deduct(c0, fees.funding_paid) + c2 : Val = self.deduct(c1.remaining, fees.borrowing_paid) + # Funding fees prioritized over borrowing fees. + funding_paid : uint256 = c1.deducted + borrowing_paid : uint256 = c2.deducted + remaining : uint256 = c2.remaining + +... +# later passed to `calc_pnl` + final : uint256 = 0 if remaining == 0 else ( +... +# longs + payout : uint256 = self.MATH.quote_to_base(final, ctx) +... +# shorts + payout : uint256 = final +``` + +However, it's possible for a position to run out of collateral yet still have a positive PnL. In these cases, no payout is received. This [payout is what's sent](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L195) to the user **or liquidator** when a position is closed: + +```vyper +# longs + base_transfer : [self.MATH.PLUS(pnl.payout), +# shorts + quote_transfer : [self.MATH.PLUS(pnl.payout), +``` + +In these cases neither the user closing his position, nor the liquidator receives any payment. + +## Impact +While it may be intended design to penalize users for letting a position run out of collateral, this is a dangerous design choice. It's possible to end up in a situation where a position has run negative due to the funding fees and now has no incentive for liquidation. This will be the case even if it could have been profitable to liquidate this position due to the PnL of the position. + +This scenario is dependent on liquidation bots malfunctioning since the liquidatability of a position does not factor in profit (only losses). However, it is acknowledged as a possibility that this scenario may occur throughout the codebase as safeguards are introduced to protect against this scenario elsewhere. In this particular scenario, no particular safeguard exists. + +These positions will continue to decay causing further damage to the system until someone is willing to liquidate the position for no payment. It is unlikely that a liquidation bot would be willing to lose money to do so and would likely require admin intervention. By the time admin intervenes, it's most likely that further losses would have already resulted from the decay of the position. + +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +During liquidations, provide the liquidator with the remaining PnL even if the position has run negative due to fees. This will maintain the current design of penalizing negative positions while mitigating the possibility of positions with no liquidation incentive. + +Alternatively, include a user's PnL in his payout even if the collateral runs out. This may not be feasible due to particular design choices to ensure the user doesn't let his position run negative. \ No newline at end of file diff --git a/016/129.md b/016/129.md new file mode 100644 index 0000000..b0d4365 --- /dev/null +++ b/016/129.md @@ -0,0 +1,53 @@ +Huge Taupe Starling + +Medium + +# Liquidators lack incentives to close positions with zero payouts + +## Summary + +The liquidators do not liquidate positions with zero payouts, which can lead to further losses and positions go negative due to price fluctuations. Liquidators lack incentives to act as there is no profit from liquidating such positions. + +## Vulnerability Detail + +When the protocol attempts to calculate the value of a position to [check if it's liquidatable or not](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L352-L357), it first [deducts](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L249-L255) the `funding_paid` and `borrowing_paid` from the collateral: +```vyper + c0 : uint256 = pos.collateral + c1 : Val = self.deduct(c0, fees.funding_paid) + c2 : Val = self.deduct(c1.remaining, fees.borrowing_paid) + # Funding fees prioritized over borrowing fees. + funding_paid : uint256 = c1.deducted + borrowing_paid : uint256 = c2.deducted + remaining : uint256 = c2.remaining +``` + +The remaining collateral is then used to [calculate the PnL](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L277-L279) of the position. If the remaining collateral becomes zero, the protocol [sets the payout to zero](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L295-L298) regardless of profits, funding received, or other factors: +```vyper + # Positions whose collateral drops to zero due to fee obligations + # are liquidated and don't receive a payout. + final : uint256 = 0 if remaining == 0 else ( + 0 if loss > remaining else ( + remaining - loss if loss > 0 else ( + remaining + profit ) ) ) +``` + +Since the liquidation fee, which is paid to the liquidator, [is deducted from the payout amount](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L333-L334), a zero payout means liquidators have no incentive to liquidate the position. They would incur transaction fees with no profit. + +## Impact + +Positions with a zero payout will not be liquidated, which allows the position to incur further losses. This could eventually result in the positions go negative due to price fluctuations. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L352-L357 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L249-L255 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L277-L279 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L295-L298 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L333-L334 + +## Tool used + +Manual Review + +## Recommendation + +Ensure that liquidators are always compensated, even for positions with zero payout due to fees, perhaps by covering the liquidation fee from elsewhere in the protocol. \ No newline at end of file diff --git a/017/091.md b/017/091.md new file mode 100644 index 0000000..4094bc1 --- /dev/null +++ b/017/091.md @@ -0,0 +1,39 @@ +Kind Banana Sloth + +High + +# `base_to_quote` wrongly assume quote always has less decimals + +## Summary +`base_to_quote` wrongly assume quote always has less decimals + +## Vulnerability Detail +Let's look at the code of `base_to_quote` + +```vyper +def base_to_quote(tokens: uint256, ctx: Ctx) -> uint256: # price is in quote decimals + lifted : Tokens = self.lift(Tokens({base: tokens, quote: ctx.price}), ctx) # get scaled to whichever has more decimals + amt0 : uint256 = self.to_amount(lifted.quote, lifted.base, self.one(ctx)) # amount gets calculated in whichever has more decimals + lowered: Tokens = self.lower(Tokens({base: 0, quote: amt0}), ctx) # amount gets refactored to whichever has less decimals -> will be wrong if that's base + return lowered.quote +``` + +In the input params, `tokens` is in base decimals and the price is in `quote` decimals. +As we can see, the first line scales to whichever has more decimals. +Then the amount is calculated, once again scaled in whichever has more decimals. +Then it is lowered to whichever has lower decimals. + +It basically makes an assumption that the quote always has less decimals, which is not the case. If the pair is `WBTC/DAI` for example, it would be completely broken and allow for draining all assets. + +## Impact +Loss of funds. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L73 + +## Tool used + +Manual Review + +## Recommendation +Do proper decimals scaling \ No newline at end of file diff --git a/017/095.md b/017/095.md new file mode 100644 index 0000000..efc3a45 --- /dev/null +++ b/017/095.md @@ -0,0 +1,38 @@ +Kind Banana Sloth + +High + +# `quote_to_base` returns wrong results if base token has more decimals + +## Summary +`quote_to_base` returns wrong results if base token has more decimals + +## Vulnerability Detail +Let's look at the code of `quote_to_base`: +```vyper +def quote_to_base(tokens: uint256, ctx: Ctx) -> uint256: + l1 : Tokens = self.lift(Tokens({base: 0, quote: tokens}), ctx) + l2 : Tokens = self.lift(Tokens({base: 0, quote: ctx.price}), ctx) # both scaled to higher decimals + vol0 : uint256 = self.from_amount(l1.quote, l2.quote, self.one(ctx)) # result in higher decimals + lowered: Tokens = self.lower(Tokens({base: vol0, quote: 0}), ctx) # result in lower decimals + return lowered.base +``` +Let's consider one of the number has higher decimals and the other one has lower decimals + +As we can see, it first scales both the quote and the price to the higher decimals and then calculates `vol0` in the same higher decimals. +Then, on the next line, it scales down the number to the lower decimals. + +In case base token has more decimals than the quote token, the result will be incorrect and will allow for stealing all funds, due to wrong valuation of tokens. + +## Impact +Loss of funds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L85 + +## Tool used + +Manual Review + +## Recommendation +Use proper decimals scaling \ No newline at end of file diff --git a/018.md b/018.md new file mode 100644 index 0000000..7bea75c --- /dev/null +++ b/018.md @@ -0,0 +1,73 @@ +Amateur Nylon Canary + +Medium + +# Penalized funding received token will be locked in the contract + +## Summary +If one position's funding received is penalized, these funding received will be locked in the contract. + +## Vulnerability Detail +When we close one position, we will check the remaining collateral after we deduct the borrowing fee and possible funding paid. If there is not left collateral, the funding received will be set to 0 as one penalty. +In this case, the trader will not receive the funding received. And the `funding_received_want` will not be deducted from base_collateral considering one long position. +The problem is that traders with short position will still pay funding fees. This will cause `funding_received_want` will be left and anyone cannot withdraw this part. + +```vyper +@external +@view +def calc_fees(id: uint256) -> FeesPaid: + pos : PositionState = Positions(self).lookup(id) + pool : PoolState = self.POOLS.lookup(pos.pool) + # Here collateral is this position's collateral. + fees : SumFees = self.FEES.calc( + pos.pool, pos.long, pos.collateral, pos.opened_at) + # @audit-fp funding_paid, borrowing_paid will be paid via collateral. + c0 : uint256 = pos.collateral + # Funding paid and borrowing paid will be paid via the collateral. + c1 : Val = self.deduct(c0, fees.funding_paid) + c2 : Val = self.deduct(c1.remaining, fees.borrowing_paid) + # Funding fees prioritized over borrowing fees. + # deduct funding fee at first, and then deduct borrowing fee. + funding_paid : uint256 = c1.deducted + borrowing_paid : uint256 = c2.deducted + # collateral - funding paid fee - borrowing fee + remaining : uint256 = c2.remaining + funding_received: uint256 = 0 if remaining == 0 else ( + min(fees.funding_received, avail) ) +``` +```vyper +@external +@view +def value(id: uint256, ctx: Ctx) -> PositionValue: + # Get position state. + pos : PositionState = Positions(self).lookup(id) + # All positions will eventually become liquidatable due to fees. + fees : FeesPaid = Positions(self).calc_fees(id) + pnl : PnL = Positions(self).calc_pnl(id, ctx, fees.remaining) + + deltas: Deltas = Deltas({ + base_interest : [self.MATH.MINUS(pos.interest)], # unlock LP tokens. + quote_interest : [], + base_transfer : [self.MATH.PLUS(pnl.payout), + self.MATH.PLUS(fees.funding_received)], + base_reserves : [self.MATH.MINUS(pnl.payout)], # base reserve: when traders win, they will get LP's base token, + # funding fee is not related with LP holders. + +@> base_collateral : [self.MATH.MINUS(fees.funding_received)], # -> + ... + }) if pos.long else ... + +``` + +## Impact +Penalized funding received token will be locked in the contract + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L242-L272 + +## Tool used + +Manual Review + +## Recommendation +Once the funding received is penalized, we can consider to transfer this part funds to collector directly. \ No newline at end of file diff --git a/018/112.md b/018/112.md new file mode 100644 index 0000000..92d4f35 --- /dev/null +++ b/018/112.md @@ -0,0 +1,73 @@ +Dancing Topaz Perch + +Medium + +# When there is no opposite position, imbalance funding fees are locked in the pool forever + +### Summary + +When users close the position, they pay imbalance funding fees to the opposite position. +If there is no opposite position, no one can receive these fees and they are locked in the pool forever. + +### Root Cause + +In [`fees.vy:186`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/fees.vy#L186), if `fs.short_collateral = 0`, `received_short_term` is set as zero. + +### Internal pre-conditions + +1. There is some base and quote token reserves in the pool. +2. There is no short position in the pool. + +### External pre-conditions + +None + +### Attack Path + +1. Alice opens a long position. +2. After some time, Alice closes the position before someone opens a short position. + +Here, Alice pays imbalance funding fees. +But as there is no short positions to receive the imbalance funding fees and fees are locked to the pool's `quote_collateral`. + +### Impact + +Some imbalance funding fees are locked in the pool forever. + +### PoC + +In the `params.dynamic_fees` function, `funding_long` is calculated using `long_utilization` and `short_utilization` from [L47](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L46). + +```vyper +46: funding_long : uint256 = self.funding_fee( +47: borrowing_long, long_utilization, short_utilization) +``` + +As there is no short position, and `long_utilization > 0, short_utilization = 0`. +Thus, Alice should pay imbalance funding fees(`paid_long_term`) to short position from [L185](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/fees.vy#L185). + +```vyper +185: paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms) +186: received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) +``` + +As `fs.short_collateral = 0`, `received_short_term = 0` from L186. +When Alice closes the position, `fees.funding_paid` is subtracted from Alice's quote collateral and added to pool's `quote_collateral` value from [L211](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L208-L212). + +```vyper +209: quote_reserves : [self.MATH.PLUS(pos.collateral), +210: self.MATH.MINUS(fees.funding_paid)], +211: quote_collateral: [self.MATH.PLUS(fees.funding_paid), +212: self.MATH.MINUS(pos.collateral)], +[...] +223: quote_transfer : [self.MATH.PLUS(pnl.payout), +224: self.MATH.PLUS(fees.funding_received)], +225: quote_reserves : [self.MATH.MINUS(pnl.payout)], +226: quote_collateral: [self.MATH.MINUS(fees.funding_received)], +``` + +But, no one can take these fees from L224 and they are locked in the pool forever. + +### Mitigation + +Add the mechanism to manage the locked fees. \ No newline at end of file diff --git a/018/125.md b/018/125.md new file mode 100644 index 0000000..465ecab --- /dev/null +++ b/018/125.md @@ -0,0 +1,65 @@ +Dancing Topaz Perch + +Medium + +# Some imbalance funding fees are locked in the pool forever + +## Summary + +Short positions pay imbalance funding fees to long positions. To calculate these imbalance funding fees, the protocol tracks the funding fees that the unit quote token should receive. It then multiplies the long position's collateral by this value to determine the funding fee received for that position. However, The imbalance funding fees paid by short positions and the total amount of funding fees received by long positions differ due to precision loss. As a result, large amount of base tokens are locked in the pool. The same applies to the fees that long positions pay to short positions. + +## Vulnerability Detail + +For the sake of simplicity, let’s assume the following conditions. +- There are 1,000 long positions in BTC/USDT pool. +- Each long position has 10 USDT as collateral. Total long collateral is `10 * 1000 = 10,000` USDT. +- When long positions are closed, they receive imbalance funding fees from short positions. The total imbalance funding fee from these short positions amounts to 99,999 satoshi. + +Given these conditions, let's calculate the imbalance funding fee that each position receives on close. +The imbalance funding fee that each 1 USDT receives is calculated as follows. + +```vyper +File: contracts\fees.vy + +190: received_long_term : uint256 = self.divide(paid_short_term, fs.long_collateral) + +120: def divide(paid: uint256, collateral: uint256) -> uint256: +121: if collateral == 0: return 0 +122: else : return (paid * ZEROS) / collateral + +``` +As `paid_short_term = 99,999`, `fs.long_collateral = 1e10`, `received_long_term` is `99,999 * 1e27 / 1e10 = 99,999 * 1e17`. +When one long position whose collateral is 10 USDT is closed, the amount of imbalance funding fee to receive is `1e7 * 99,999 * 1e17 / 1e27 = 99` satoshi. + +```vyper +File: gl-sherlock\contracts\fees.vy + +271: R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( +272: self.multiply(collateral, period.received_short) ) + +126: def multiply(ci: uint256, terms: uint256) -> uint256: +127: return (ci * terms) / ZEROS +``` + +If all long positions are closed, total amount of imbalance funding fees are `99 * 1000 = 99,000` satoshi. +Remaining imbalance funding fee is `99,999 - 99,000 = 999` satoshi. +`999` satoshi is almost 0.5$ when 1 BTC is 50K USDT. + +In practice, the amount of collateral for long positions may vary from the collateral amounts included in the example. However, the frequency of closing positions is quite high, leading to frequent occurrences of precision loss. +Furthermore, in the BTC/USDT pool, since the base token is BTC, the value of the locked assets is substantial. + +## Impact + +The imbalance funding fees paid by short positions and the total amount of funding fees received by long positions differ due to precision loss. As a result, large amount of base tokens are locked in the pool. The same applies to the fees that long positions pay to short positions. + +## Tool used + +Manual Review + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/fees.vy#L185-L186 + +## Recommendation + +It is recommended to add function to withdraw assets from the pool. \ No newline at end of file diff --git a/019.md b/019.md new file mode 100644 index 0000000..50b803f --- /dev/null +++ b/019.md @@ -0,0 +1,40 @@ +Amateur Nylon Canary + +Medium + +# Later LP holders can get one part of previous LP holders' borrowing fee + +## Summary +Later LP holders can get one part of previous LP holders' borrowing fee because of the improper share price calculation. + +## Vulnerability Detail +When someone wants to mint some shares in one market, share's price will be calculated based on current total reserve's value and total supply. +LP holders will get some borrowing fees if traders open positions with leverage. When users close their positions, they will pay their borrowing fees and increase the share's price. +The problem is that when one LP wants to mint some shares, current `total_reserves` does not involve unrealized borrowing fees. This will cause new LP holders can mint shares via one cheaper share price than expected. They will share one part of borrowing fee which is generated before they mint shares. + +```vyper +@external +@view +def calc_mint( + id : uint256, + base_amt : uint256, + quote_amt : uint256, + total_supply: uint256, + ctx : Ctx) -> uint256: + pv: uint256 = self.MATH.value(Pools(self).total_reserves(id), ctx).total_as_quote + mv: uint256 = self.MATH.value(Tokens({base: base_amt, quote: quote_amt}), ctx).total_as_quote + return Pools(self).f(mv, pv, total_supply) +``` + +## Impact +Later LP holders can get one part of previous LP holders' borrowing fee because of the improper share price calculation. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L154-L188 + +## Tool used + +Manual Review + +## Recommendation +When we calculate the share's price, add the unrealized accured borrowing fee into current reserve. \ No newline at end of file diff --git a/019/115.md b/019/115.md new file mode 100644 index 0000000..d60680c --- /dev/null +++ b/019/115.md @@ -0,0 +1,46 @@ +Dancing Topaz Perch + +Medium + +# The `api.burn` function does not check the slippage for the received amount of base and quote tokens + +### Summary + +In the `api.burn` function, it checks slippage for price variance, but not for received amounts of base and quote tokens. +The received amount of tokens can be changed by price variance and available fees. +Attackers can reduce the fees users can take by frontrunning users. +As a result, liquidity providers may receive less base and quote tokens than they expected. + +### Root Cause + +There is no slippage check of based token and quote tokens in the [`api.burn`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/api.vy#L104-L128) function. + +### Internal pre-conditions + +None + +### External pre-conditions + +None + +### Attack Path + +1. Alice(lp) mints 100 `lp_token` by depositing 100 in quote token and the total supply of `lp_token` is 100. +There is no price movement for all steps of this scenario. +2. Several long and short positions provides the 10 fees to the pool. +3. Alice is about to burn 100 `lp_token`, allowing her to receive base and quote tokens totally same as 110 in quote token. +4. Bob(attacker) frontruns Alice : he mints 1000 `lp_token` by depositing 1000: the pool's total reverse is `1000 + 100 + 10 = 1110`. +5. Next, Alice's transaction is processed, and she receives `1110 * 100 / 1100 = int(100.9) = 100`. +As a result, Alice only receives 100 in quote token, not 110. + +### Impact + +Liquidity providers may receive less amount of base and quote tokens then they expected. + +### PoC + +None + +### Mitigation + +Add the slippage checking of received amount of tokens in the `api.burn` function. \ No newline at end of file diff --git a/019/116.md b/019/116.md new file mode 100644 index 0000000..91c0255 --- /dev/null +++ b/019/116.md @@ -0,0 +1,43 @@ +Dancing Topaz Perch + +Medium + +# The `api.close` function does not check the slippage for the paid amount of base and quote tokens. + +### Summary + +In the `api.close` function, it also checks slippage for price variance, but not for paid amounts of base and quote tokens. +The token amount that user should pay to close position can be changed by price variance and funding fees based on the imbalance between long and short interest. +Attackers can increase the funding fees user should pay by frontrunning. +As a result, position creator may have to pay more base and quote tokens than they expected. + +### Root Cause + +There is no slippage check of based token and quote tokens in the [`api.close`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/api.vy#L161-L183). + +### Internal pre-conditions + +None + +### External pre-conditions + +None + +### Attack Path + +1. Alice opens the long position and there is no price movement for all steps of this scenario. +2. After some time, Alice is about to close the position, allowing her to pay pay imbalance funding fees. +3. Bob(attacker) frontruns Alice : he opens the long position with large interest. +4. Next, Alice's transaction is processed, and she has to pay more imbalance funding fees by Bob. + +### Impact + +Position creators may have to pay more tokens than they expected. + +### PoC + +None + +### Mitigation + +Add the slippage checking of received amount of tokens in the `api.close` function. \ No newline at end of file diff --git a/020/130.md b/020/130.md new file mode 100644 index 0000000..7224b2b --- /dev/null +++ b/020/130.md @@ -0,0 +1,68 @@ +Innocent Wooden Capybara + +High + +# Inability to Close Position Due to Lack of Reserves Could Lead to Unfair Losses or Missed Profits + +### Summary + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L272 +If a user is unable to close their position due to insufficient reserves in the protocol( by design), they currently have no guaranteed mechanism to remove their position at the desired price once the reserves are replenished. This lack of functionality can lead to unfair outcomes where the user's position could become profitable, but they are still charged ongoing borrowing and funding fees until they can close the position, potentially resulting in significant financial losses or missed profits. + + + +### Root Cause + +The protocol does not provide a mechanism for users to lock in the desired closing price of their position when attempting to close it in conditions where the protocol lacks sufficient reserves. Consequently, users are forced to keep their positions open and continue paying borrowing and funding fees until they can close the position, which may not happen until after market conditions have changed, potentially eliminating any potential profit. + + + +### Internal pre-conditions + +1.The user has an open position that they wish to close. +2.The protocol's reserves are insufficient to cover the payout required for closing the position. +3.The protocol attempts to close the position but fails due to a lack of reserves. + +### External pre-conditions + +1.The market conditions make the user's position potentially profitable, or the user wants to close the position to stop paying borrowing and funding fees. +2.There is a lack of sufficient liquidity or reserves in the protocol to settle the user's payo + +### Attack Path + +1.Open a Position: + + - A user opens a leveraged position on an asset with the expectation of closing it at a future time when it becomes profitable or meets certain risk criteria. +2. Attempt to Close Position When Reserves Are Low: + +- The user tries to close the position when they believe it is profitable or to stop further fee accumulation. +- However, the protocol's reserves are insufficient to cover the payout required to close the position. + +3.Forced to Keep Position Open: + +- The protocol does not provide a mechanism for the user to lock in the price at which they attempted to close the position or to guarantee closure once reserves are available. +- The user is forced to keep the position open, continuing to accrue borrowing and funding fees. +4.Missed Profit or Losses Accrue: + +- By the time reserves are available, the market conditions may have changed such that the user’s position is no longer profitable or has accrued additional losses due to ongoing fees. + + +### Impact + +Users may miss out on potential profits and even worse an suffer unfair losses due to the inability to close their positions at the desired price when reserves are temporarily insufficient. +Users are exposed to a higher risk of liquidation as they cannot close their positions to manage risk when desired, leading to potential liquidation at less favorable prices. +Such scenarios will push Users to lose trust in the protocol if they feel that they cannot close their positions fairly or in a timely manner due to reserve limitations. Users are exposed to a higher risk of liquidation as they cannot close their positions to manage risk when desired, leading to potential liquidation at less favorable prices. + + +### PoC + +Assume a user opens a leveraged long position with 100 USDT collateral at a BTC price of 50,000 USDT. +The BTC price rises to 55,000 USDT, and the user attempts to close the position to lock in the profit. +However, the protocol's reserves are insufficient to cover the payout required for the user's position closure. +The user cannot close the position and must keep it open while continuing to pay borrowing and funding fees. +Over time, eitehr the BTC price returns to 50,000 USDT, or lower, and the user's position becomes unprofitable or the borrowing fees and funding fees became greater than the collateral. +The user misses out on the opportunity to lock in the profit at 55,000 USDT and incurs further losses due to ongoing fees, potentially leading to an unwanted liquidation. + +### Mitigation + +Allow users to lock in the price at which they attempt to close their position when the protocol lacks sufficient reserves. Once reserves are replenished, the position can be closed at the locked-in price, ensuring fair treatment. diff --git a/020/137.md b/020/137.md new file mode 100644 index 0000000..1cb3c2c --- /dev/null +++ b/020/137.md @@ -0,0 +1,50 @@ +Innocent Wooden Capybara + +High + +# Imbalance of Reserves Due to Repeated Opening and Closing of Positions in Volatile Market Conditions + +### Summary + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L230 +When opening a position, the protocol locks a portion of the reserves as interest. +When closing a position, the close function applies deltas to update the pool state, including reserves and interest. +However, there's no check to ensure that the reserves remain balanced after multiple opens and closes, especially in volatile market conditions. An attacker can exploit the lack of checks to maintain balanced reserves by repeatedly opening and closing positions in a manner that depletes one type of reserve (e.g., base tokens) while accumulating an excess of another (e.g., quote tokens). Over time, this could lead to an imbalanced reserve ratio, jeopardizing the protocol’s stability and potentially resulting in losses for liquidity providers (LPs). + +### Root Cause + +The root cause is the lack of a mechanism to rebalance the reserves after position closes, especially in volatile market conditions. The protocol assumes that opens and closes will naturally balance out over time, but this assumption can be exploited in trending markets. , this could lead to creating an imbalance between the quote asset and the base asset. thus, the pool might not have +sufficient assets to pay traders when closing a position + + +### Internal pre-conditions + +1.The protocol locks a portion of reserves as interest when opening a position. + +### External pre-conditions + +The market is volatile, with significant price movements expected. (bullish for the example) + + +### Attack Path + +1.People identifu a market likely to trend strongly upward. +2.People opens a large long position, locking up a significant portion of the quote token reserves. +3.As the market moves up, the suers position gains value in terms of base tokens. +4.Users closes their position, returning more quote tokens to the pool than were originally locked. +5.This process is repeated multiple times, each time further imbalancing the pool's reserves. +Eventually, the pool has an excess of quote tokens and a shortage of base tokens. + +### Impact + +1.The pool's reserves become increasingly imbalanced, with an excess of quote tokens and a shortage of base tokens. +2.Liquidity providers face potential financial losses as the protocol’s reserve ratio becomes distorted, affecting their ability to withdraw or burn their LP tokens at fair value. +3.The imbalance in reserves can lead to a loss of stability for the protocol, causing price slippage, reducing liquidity, and decreasing the overall trust of the participants. + +### PoC + +_No response_ + +### Mitigation + +Introduce checks to ensure that the reserve ratios remain balanced after every position close. If the reserves become imbalanced, apply corrective actions such as dynamic fees, liquidity rebalancing, or temporary suspension of certain operations to restore balance. \ No newline at end of file diff --git a/021.md b/021.md new file mode 100644 index 0000000..c3f2739 --- /dev/null +++ b/021.md @@ -0,0 +1,36 @@ +Rough Fiery Baboon + +High + +# Users won't be able to open a position when `PROTOCOL_FEE = 0` + +## Summary +If the `PROTOCOL_FEE` is set to 0 which is possible then it will not let users open a position in the pool with 0 PROTOCOL_FEE +## Vulnerability Detail +The [open](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L230-L268) function in `core.vy` is used to open the position and while doing so it fetches the [static_fees/Protocol fee](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L244) which is then further transferred to the protocol from the user. + +Now if we see the implementation of [static_fees function in PARAMA.vy](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L93-L97) + +```python +@external +@view +def static_fees(collateral: uint256) -> Fee: + fee : uint256 = collateral / self.PARAMS.PROTOCOL_FEE + remaining: uint256 = collateral - fee + return Fee({x: collateral, fee: fee, remaining: remaining}) +``` + +In the above function the fee calculation is done as +`fee : uint256 = collateral / self.PARAMS.PROTOCOL_FEE` +The collateral is divided by `PROTOCOL_FEE`, now if the `PROTOCOL_FEE = 0` then the whole function will just panic revert due to division by 0 and so the whole open function will revert due to this and due to which users will not be able to open any positions in a pool with 0 `PROTOCOL_FEE`. +## Impact +users will not be able to open any positions in a pool with 0 `PROTOCOL_FEE`. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L93-L97 +## Tool used + +Manual Review + +## Recommendation + +Add a logic in `static_fees` funciton to handle 0 `PROTOCOL_FEE`. \ No newline at end of file diff --git a/022.md b/022.md new file mode 100644 index 0000000..55b89af --- /dev/null +++ b/022.md @@ -0,0 +1,103 @@ +Creamy Snowy Baboon + +High + +# Initialization Vulnerability in core.vy Contract + +## Summary + +The `core.vy` contract has a potential security vulnerability where critical functions can be invoked before the contract has been fully initialized. This could lead to inconsistent behavior and unintended consequences if the contract is used prior to completing its initialization via the `__init__2` function. + +## Vulnerability Detail + +While the contract includes an `__init__2` function to complete setup, which can only be called once, there is no restriction that prevents users from calling other critical functions such as `fresh`, `mint`, `burn`, `open`, `close`, and `liquidate` before initialization is complete. + +## Impact + +This vulnerability can lead to: +- Unauthorized operations: Functions relying on initialized state may operate incorrectly or fail silently. +- Damage to contract state: Uninitialized state variables could lead to corrupted data, making the contract malfunction. +- Financial risk: Users interacting with the contract before it is fully set up could lose funds or cause the system to become financially unstable. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L136-L151 +```python +@external +def fresh( + symbol: String[65], + base_token: address, + quote_token: address, + lp_token: address): +@> # Missing initialization check here + assert msg.sender == self.DEPLOYER, ERR_PERMISSIONS + ... +``` + +## Tool Used +Manual Review + +## Recommendation + +Implement an initialization guard to ensure that critical functions cannot be called before the contract has been fully initialized. This can be done by adding an `_initialized` internal method that checks the `INITIALIZED` state variable and integrating this check into all critical external functions. + +### Recommended Code Change + +1. **Add Initialization Check**: + +```python +@internal +def _initialized(): + assert self.INITIALIZED, "ERR_NOT_INITIALIZED" +``` + +2. **Modify Critical Functions**: + +Ensure the initialization check is called before executing any core logic: + +```python +@external +def fresh( + symbol: String[65], + base_token: address, + quote_token: address, + lp_token: address): + + self._initialized() # Ensure the contract is initialized + assert msg.sender == self.DEPLOYER, "ERR_PERMISSIONS" + assert not self.POOLS.exists_pair(base_token, quote_token), "ERR_PRECONDITIONS" + ... +``` + +Apply the same change to other critical functions: + +```python +@external +def mint(...) -> uint256: + self._initialized() # Ensure the contract is initialized + self._INTERNAL() + ... + +@external +def burn(...) -> Tokens: + self._initialized() # Ensure the contract is initialized + self._INTERNAL() + ... + +@external +def open(...) -> PositionState: + self._initialized() # Ensure the contract is initialized + self._INTERNAL() + ... + +@external +def close(...) -> PositionValue: + self._initialized() # Ensure the contract is initialized + self._INTERNAL() + ... + +@external +def liquidate(...) -> PositionValue: + self._initialized() # Ensure the contract is initialized + self._INTERNAL() + ... +``` \ No newline at end of file diff --git a/023.md b/023.md new file mode 100644 index 0000000..413dcd2 --- /dev/null +++ b/023.md @@ -0,0 +1,53 @@ +Creamy Snowy Baboon + +High + +# Improper Comparison with self Across Multiple Files + +## Summary + +During the audit of the `core.vy` contract, a significant security vulnerability was identified. The vulnerability arises from the improper comparison of `msg.sender` with `self`. This incorrect practice undermines access control mechanisms within the contract. + +## Vulnerability Detail + +In the `core.vy` contract, there are instances where functions compare `msg.sender` to `self` to check permissions. This comparison is flawed since `self` refers to the contract instance, and `msg.sender` represents the address of the entity invoking the function. These two entities are inherently different and the comparison will always fail, causing the function to be unusable. + +## Impact + +1. **Functionality Blockage**: Functions relying on this failed comparison will be rendered unusable since the check always fails. This inhibits significant features of the contract. + +2. **Security Risk**: Incorrect access control reduces the contract's intended security boundaries, possibly leading to unauthorized access or restricted execution of essential functions. + +3. **Potential Exploitation**: Misuse of access control could lead to unintended financial exposures or disruptions in the contract's service. + +## Code Snippet + +**Incorrect Comparison in `core.vy`**: +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L114-L116 +```python +@internal +def _INTERNAL(): + assert (msg.sender == self.CORE or msg.sender == self), "ERR_PERMISSIONS" +``` + +This ensures that the comparison is done with authorized addresses instead of the contract instance. + +## Tool Used + +Manual Review + +## Recommendation + +1. **Fix the Access Control Check**: + - The proper check should verify `msg.sender` against an authorized address such as `self.DEPLOYER`. This ensures only the deployer or a designated core contract can invoke the function. + +2. **Corrected Code in `core.vy`**: + +```python +@internal +def _safe_internal(): + assert (msg.sender == self.DEPLOYER or msg.sender == self.CORE), "ERR_PERMISSIONS" +``` + +3. **Apply the Correct Check Consistently**: + - Ensure this updated comparison is used in place of `msg.sender == self` in all internal authorization checks within `core.vy`. diff --git a/025.md b/025.md new file mode 100644 index 0000000..09977da --- /dev/null +++ b/025.md @@ -0,0 +1,25 @@ +Rough Fiery Baboon + +Medium + +# Use safeTransfer()/safeTransferFrom() instead of transfer()/transferFrom() + +## Summary +The ERC20.transfer() and ERC20.transferFrom() functions return a boolean value indicating success. This parameter needs to be checked for success. Some tokens do not revert if the transfer failed but return false instead. +## Vulnerability Detail +Some tokens (like USDT) don't correctly implement the EIP20 standard and their transfer/ transferFrom function return void instead of a success boolean. Calling these functions with the correct EIP20 function signatures will always revert. +## Impact +Tokens that don't actually perform the transfer and return false are still counted as a correct transfer and tokens that don't correctly implement the latest EIP20 spec, like USDT, will be unusable in the protocol as they revert the transaction because of the missing return value. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L177-L178 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L215-L216 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L253-L258 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L297-L299 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L337-L344 + +## Tool used + +Manual Review + +## Recommendation +Use safeTransfer()/safeTransferFrom() instead of transfer()/transferFrom() \ No newline at end of file diff --git a/026.md b/026.md new file mode 100644 index 0000000..480963f --- /dev/null +++ b/026.md @@ -0,0 +1,35 @@ +Proper Vermilion Mule + +High + +# Contracts can always be reinitialized + +### Summary + +`__init__2` reverts if `INITIALIZED == true` or if `msg.sender != DEPLOYER`. + +But `init` never reverts, and sets `INITIALIZED` to `false`, and sets `DEPLOYER` to `msg.sender`. + +Hence, anyone can call `__init__`, and then call `__init__2` with arbitrary calldata at any time. + +This can be used to block any action until reinitialized (and then can be done again). + +Can also be used to atomically liquidate healthy positions. + +### Root Cause + +Every `__init__` in the codebase sets INITIALIZED to `false`. + +### Attack Path + +1. 1 ETH = 2000 USD. Alice opens a 1000 USDT position with 1 WETH as collateral. + +2. Bob changes oracle address via `api#__init__` to the one which returns price 1 WETH = 1 USDT, and liquidates Alice, seizing Alice's collateral. + +### Impact + +Theft of users' collateral by liquidating an otherwise healthy position. + +### Mitigation + +In all contracts with `__init__`, `self.INITIALIZED = False` line should be removed. \ No newline at end of file diff --git a/028.md b/028.md new file mode 100644 index 0000000..9dd9a04 --- /dev/null +++ b/028.md @@ -0,0 +1,66 @@ +Hot Purple Buffalo + +High + +# DoS of LP Withdrawals Due to Abuse of `unlocked_reserves` + +## Summary +LP withdrawals can be blocked by a malicious actor inflating the OI to intentionally increase `unlocked_reserves`. + +## Vulnerability Detail +The protocol locks user deposits in order to ensure future payout obligations can be met, a key functionality outlined in the comment specifications. The `pool.vy` function [`unlocked_reserves`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L125-L130) reports the token reserves not currently tied up to fulfill future payouts: + +```vyper +def unlocked_reserves(id: uint256) -> Tokens: + ps: PoolState = Pools(self).lookup(id) + return Tokens({ + base : ps.base_reserves - ps.base_interest, + quote: ps.quote_reserves - ps.quote_interest, + }) +``` + +This function is mentioned in [`calc_burn`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L216-L222) which is called when LPs are making a withdrawal: + +```vyper + unlocked: Tokens = Pools(self).unlocked_reserves(id) + ... + assert uv >= bv, ERR_PRECONDITIONS + assert amts.base <= unlocked.base, ERR_PRECONDITIONS + assert amts.quote <= unlocked.quote, ERR_PRECONDITIONS +``` + +Additionally, after every user action in `core.vy` (including burn and open) an [invariants](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L126-L133) check is performed with similar enforcement: + +```vyper +def INVARIANTS(id: uint256, base_token: address, quote_token: address): + pool : PoolState = self.POOLS.lookup(id) +... + assert pool.base_reserves >= pool.base_interest, ERR_INVARIANTS + assert pool.quote_reserves >= pool.quote_interest, ERR_INVARIANTS +``` + +Critically, the same comparison between `interest` and `reserves` is being made in both cases. Since `open` increases the `interest`, a malicious attacker can take out positions with large enough size and leverage to decrease `unlocked_reserves` to an arbitrarily small value. + +As long as he is well-capitalized, he can take out equal notional size positions on both the long and short side, eliminating price fluctuations and funding payments from the cost of this attack, and locking both quote and base reserves. While he would still pay borrowing rates, as well as the initial cost of opening a position, these costs are low enough to enable him to maintain this DoS (assuming he manages his position periodically to avoid liquidation). + +## Impact +With a large enough bankroll, all withdrawals can be blocked for several months at a nominal cost to the attacker. Anytime the unlocked reserves grows as a result of user actions (eg. mint, close), he can simply open more positions to extend the attack. + +Withdrawals can very easily be blocked for a period of 4 hours (DoS period mentioned in the [contest rules](https://audits.sherlock.xyz/contests/526)) with a much more modest bankroll and cost of attack. + +Opening new positions will also be blocked, but the blocking of withdrawals is much more problematic as this is a time-sensitive operation given fluctuating crypto prices and user liquidity needs (especially if the attack is carried out for the long durations described). This would significantly erode trust in the protocol and discourage future depositors, even after the attack has concluded. + +## Code Snippet + +## Tool used + +Manual Review + +## Recommendation +Specifically for the opening of positions, enforce a lower limit of accessible reserves in order to facilitate withdrawals even when positions can no longer be opened. + +For example at the end of opening a position, consider asserting: +```vyper + assert pool.base_reserves >= 2*pool.base_interest, ERR_INVARIANTS + assert pool.quote_reserves >= 2*pool.quote_interest, ERR_INVARIANTS +``` \ No newline at end of file diff --git a/029.md b/029.md new file mode 100644 index 0000000..4ddac0d --- /dev/null +++ b/029.md @@ -0,0 +1,114 @@ +Dry Khaki Locust + +High + +# If a position makes more profit than leverage rate, the position is locked + +### Summary + +When a position is doing great and making more profit than the interest, the position is locked and cannot be closed. + +### Root Cause + +When a position is closed, it's delta is calculated and passed to pool for a reserve update: + +```vyper + value : PositionValue = self.POSITIONS.close(position_id, ctx) + base_amt : uint256 = self.MATH.eval(0, value.deltas.base_transfer) + quote_amt: uint256 = self.MATH.eval(0, value.deltas.quote_transfer) + self.POOLS.close(id, value.deltas) +``` + +And the internal function `positions::value` is called to calculate the position's current value: + +```vyper +@external +def close(id: uint256, ctx: Ctx) -> PositionValue: + self._INTERNAL() + pos: PositionState = Positions(self).lookup(id) + assert pos.status == Status.OPEN , ERR_PRECONDITIONS + assert block.number > pos.opened_at, ERR_PRECONDITIONS + self.insert(PositionState({ + id : pos.id, + pool : pos.pool, + user : pos.user, + status : Status.CLOSED, + long : pos.long, + collateral : pos.collateral, + leverage : pos.leverage, + interest : pos.interest, + entry_price: pos.entry_price, + exit_price : ctx.price, + opened_at : pos.opened_at, + closed_at : block.number, + + collateral_tagged: pos.collateral_tagged, + interest_tagged : pos.interest_tagged, + })) + return Positions(self).value(id, ctx) +``` + +Eventually, [`positions::calc_pnl`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L283) is called, taking a long position as example, it calculates the value difference since the open time and the current time to find out loss or profit: + +```vyper +@external +@view +def calc_pnl_long(id: uint256, ctx: Ctx, remaining: uint256) -> PnL: + pos : PositionState = Positions(self).lookup(id) + ctx0 : Ctx = Ctx({price : pos.entry_price, + base_decimals : ctx.base_decimals, + quote_decimals: ctx.quote_decimals}) + vtokens: uint256 = pos.interest + val0 : uint256 = self.MATH.base_to_quote(vtokens, ctx0) + val1 : uint256 = self.MATH.base_to_quote(vtokens, ctx) + loss : uint256 = val0 - val1 if val0 > val1 else 0 + profit : uint256 = val1 - val0 if val1 > val0 else 0 + # Positions whose collateral drops to zero due to fee obligations + # are liquidated and don't receive a payout. + final : uint256 = 0 if remaining == 0 else ( + 0 if loss > remaining else ( + remaining - loss if loss > 0 else ( + remaining + profit ) ) ) + # Accounting in quote, payout in base. + payout : uint256 = self.MATH.quote_to_base(final, ctx) + assert payout <= pos.interest, ERR_INVARIANTS + return PnL({ + loss : loss, + profit : profit, + # Used to determine liquidatability. + # Could use final instead to account for positive pnl, + # which would allow positions in profit to be kept open longer + # but this lets us bound position lifetimes (which lets LPs estimate how + # long reserves will be locked) and unless fees are very high shouldn't + # make too much of a difference for users. + remaining: final - profit if final > profit else final, + payout : payout, + }) +``` + +The calculation is essentially converting base token to quote token by normalizing decimals and multiple by prices, which is fetched when `ctx` is created. For the same amount of quote tokens which position own initially bought in for base token, if the new price rises, position owner would gain profits by closing the position. The profit is basically `base_amount * (price_now - price_open)`. Near the end of the function, we see there is an assertion clause, which requires the payout, which is `remaining + profit` to be less than `interest`, which is `base_amount * leverage`. When the profit exceeds this value, it would trigger a revert, to stop the close operation, and in this case, a position can never be closed, and will be locked. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +1. Initially Alice opens a long position to buy 1000 base token with 2000 quote tokens and leverage rate of 100%(1). Assume the price is 1 base = 2 quote, so this makes the position interest to be 1000, as 2000 quote = 1000 base, which implies the virtual token is 1000 base as well. So interest = virtual * leverage = 1000 * 1 = 2000. +2. After a while, the price of base rises, makes 1 base = 2.2 quote, so this Alice's position actually worth 2200 quote tokens, and let's suppose that Alice's position still has 1900 worth of quote remaining, this makes her position's payout to be 2200 - 2000 + 1900 = 2100, but as 2100 > 2000, which is the interest, her close operation will be denied. + +### Attack Path + +_No response_ + +### Impact + +User's position can be locked for indefinite amount of time, until remaining is further deducted or the price drops. + +### PoC + +_No response_ + +### Mitigation + +Add an option to users to claim at max of `interest` amount of payout and close the position. \ No newline at end of file diff --git a/030.md b/030.md new file mode 100644 index 0000000..96244f7 --- /dev/null +++ b/030.md @@ -0,0 +1,170 @@ +Brilliant Burlap Elephant + +High + +# Attacker will profit from self-liquidation at the expense of the protocol + +### Summary + +Lack of checks in the liquidation process will cause a financial loss for the protocol as an attacker will open a leveraged position, wait for it to become liquidatable, and then liquidate it from another account to gain the liquidation fee and remaining collateral. + +### Root Cause + +In [`core.vy:308-349`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L308-L349) the liquidate function lacks a check to prevent self-liquidation + +The `liquidate` function in `core.vy` does not have a check to prevent the same user from liquidating their own position using a different account. + +### Internal pre-conditions + +A numbered list of conditions to allow the attack path or vulnerability path to happen: + +1. Attacker needs to open a leveraged position with sufficient collateral using the `open` function +2. Attacker needs to manipulate the position to become liquidatable (e.g., through market manipulation or waiting for price changes) +3. Attacker needs to have a separate account with enough funds to pay for gas fees + + +### External pre-conditions + +1. Market conditions need to be volatile enough to allow for position liquidatable + + +### Attack Path + +A numbered list of steps, talking through the attack path: + +1. Attacker calls `open` function to create a leveraged position +2. Attacker waits for or manipulates market conditions to make the position liquidatable +3. Attacker uses a separate account to call the `liquidate` function on their own position +4. The liquidation process executes, closing the position and transferring the liquidation fee to the attacker's second account and the remaining collateral to the original account +5. Attacker profits from the liquidation fees and retains the remaining collateral + + +### Impact + +The protocol suffers a financial loss as the attacker exploits the liquidation mechanism to extract value. The attacker gains the liquidation fee, which is intended to incentivize third-party liquidators, while also retaining the remaining collateral. This undermines the liquidation mechanism and potentially destabilizes the protocol. + + +### PoC + +1. Attacker opens a leveraged long position with 100 ETH collateral and 3x leverage: + +```python +File: core.vy +229: @external +230: def open( +231: id : uint256, +232: base_token : address, +233: quote_token : address, +234: long : bool, +235: collateral0 : uint256, +236: leverage : uint256, +237: ctx : Ctx) -> PositionState: +238: +239: self._INTERNAL() +240: +241: user : address = tx.origin +242: pool : PoolState = self.POOLS.lookup(id) +243: +244: cf : Fee = self.PARAMS.static_fees(collateral0) +245: fee : uint256 = cf.fee +246: collateral : uint256 = cf.remaining +247: +248: assert pool.base_token == base_token , ERR_PRECONDITIONS +249: assert pool.quote_token == quote_token, ERR_PRECONDITIONS +250: assert collateral > 0 , ERR_PRECONDITIONS +251: assert fee > 0 , ERR_PRECONDITIONS +252: +253: if long: assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" +254: else : assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" +255: +256: # transfer protocol fees to separate contract +257: if long: assert ERC20(quote_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" +258: else : assert ERC20(base_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" +259: +260: position: PositionState = self.POSITIONS.open(user, id, long, collateral, leverage, ctx) +261: self.POOLS.open(id, position.collateral_tagged, position.interest_tagged) +262: self.FEES.update(id) +263: +264: self.INVARIANTS(id, base_token, quote_token) +265: +266: log Open(user, ctx, pool, position) +267: +268: return position +269: + +``` + + +2. The price of ETH drops, making the position liquidatable. + +3. Attacker uses a second account to liquidate the position: + +```python +File: core.vy +308: def liquidate( +309: id : uint256, +310: base_token : address, +311: quote_token: address, +312: position_id: uint256, +313: ctx : Ctx) -> PositionValue: +314: +315: self._INTERNAL() +316: +317: # identical to close() +318: user : address = tx.origin #liquidator +319: pool : PoolState = self.POOLS.lookup(id) +320: position: PositionState = self.POSITIONS.lookup(position_id) +321: +322: assert pool.base_token == base_token , ERR_PRECONDITIONS +323: assert pool.quote_token == quote_token , ERR_PRECONDITIONS +324: assert id == position.pool , ERR_PRECONDITIONS +325: assert self.POSITIONS.is_liquidatable(position_id, ctx), ERR_PRECONDITIONS +326: +327: value : PositionValue = self.POSITIONS.close(position_id, ctx) +328: base_amt : uint256 = self.MATH.eval(0, value.deltas.base_transfer) +329: quote_amt: uint256 = self.MATH.eval(0, value.deltas.quote_transfer) +330: self.POOLS.close(id, value.deltas) +331: self.FEES.update(id) +332: +333: base_amt_final : Fee = self.PARAMS.liquidation_fees(base_amt) +334: quote_amt_final: Fee = self.PARAMS.liquidation_fees(quote_amt) +335: +336: # liquidator gets liquidation fee, user gets whatever is left +337: if base_amt_final.fee > 0: +338: assert ERC20(base_token).transfer(user, base_amt_final.fee, default_return_value=True), "ERR_ERC20" +339: if quote_amt_final.fee > 0: +340: assert ERC20(quote_token).transfer(user, quote_amt_final.fee, default_return_value=True), "ERR_ERC20" +341: if base_amt_final.remaining > 0: +342: assert ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True), "ERR_ERC20" +343: if quote_amt_final.remaining > 0: +344: assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" +345: +346: self.INVARIANTS(id, base_token, quote_token) +347: +348: log Liquidate(user, ctx, pool, value) +349: return value + +``` + + +4. The liquidation process executes, transferring the liquidation fee to the attacker's second account and the remaining collateral to the original account: + +```python + base_amt_final : Fee = self.PARAMS.liquidation_fees(base_amt) + quote_amt_final: Fee = self.PARAMS.liquidation_fees(quote_amt) + + # liquidator gets liquidation fee, user gets whatever is left + if base_amt_final.fee > 0: + assert ERC20(base_token).transfer(user, base_amt_final.fee, default_return_value=True), "ERR_ERC20" + if quote_amt_final.fee > 0: + assert ERC20(quote_token).transfer(user, quote_amt_final.fee, default_return_value=True), "ERR_ERC20" + if base_amt_final.remaining > 0: + assert ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True), "ERR_ERC20" + if quote_amt_final.remaining > 0: + assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" +``` + + +### Mitigation + +To mitigate this issue, add a check in the `liquidate` function to ensure that the liquidator is not the same as the position owner \ No newline at end of file diff --git a/032.md b/032.md new file mode 100644 index 0000000..c5ae342 --- /dev/null +++ b/032.md @@ -0,0 +1,140 @@ +Rhythmic Iron Cat + +High + +# Underestimated Pool Reserves in `pools.vy` Contract Impacting Core Operations and Fee Calculations + +## Summary +The `pools.vy` contract’s `open` function incorrectly omits accrued interest when updating `reserves`, leading to an underestimation of `reserves`. This issue impacts other functions, including core health assessments of `mint, burn, open, close and liquidate functions` and fee calculations. + +## Vulnerability Detail +The `open` function in the `pools.vy` contract fails to update the `reserves` in `PoolState` with the newly accrued interest. The `open` function does add new interest to the `interest` variable in `PoolState`, but it does not update the `reserves` with the new interest. This omission leads to an underestimation of the `reserves`. +[gl-sherlock/contracts/pools.vy:open_L247-L251](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/pools.vy#L247-L251) +```vyper + @external + def open(id: uint256, collateral: Tokens, interest: Tokens) -> PoolState: + """ + Update accounting to reflect a new position being opened. + """ + self._INTERNAL() + ps : PoolState = Pools(self).lookup(id) + reserves: Tokens = Pools(self).unlocked_reserves(id) + assert reserves.base >= interest.base , ERR_PRECONDITIONS + assert reserves.quote >= interest.quote, ERR_PRECONDITIONS + return self.insert(PoolState({ + id : ps.id, + symbol : ps.symbol, + base_token : ps.base_token, + quote_token : ps.quote_token, + lp_token : ps.lp_token, + + # @audit The `open` function does add new interest to the `interest` variable in `PoolState`, but it does not update the `reserves` with the new interest. + 247 base_reserves : ps.base_reserves, + 248 quote_reserves : ps.quote_reserves, + # lock reserves + 250 base_interest : ps.base_interest + interest.base, + 251 quote_interest : ps.quote_interest + interest.quote, + base_collateral : ps.base_collateral + collateral.base, + quote_collateral : ps.quote_collateral + collateral.quote, + })) +``` + +However, according to the `unlocked_reserves` function, the `reserves` consist of both `unlocked_reserves` and `interest`. +[gl-sherlock/contracts/pools.vy:unlocked_reserves_L128-L129](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/pools.vy#L128C1-L129C50) +```vyper + @external + @view + def unlocked_reserves(id: uint256) -> Tokens: + ps: PoolState = Pools(self).lookup(id) + return Tokens({ + +# @audit the `reserves` consist of both `unlocked_reserves` and `interest`. +128 base : ps.base_reserves - ps.base_interest, +129 quote: ps.quote_reserves - ps.quote_interest, + }) + +``` +This issue can affect the functionality of other contracts. For instance, the `INVARIANTS` function in the `core.vy` contract assesses the health of the pool by comparing `token balance >= reserves + collateral` and `reserves >= interest`. An inaccurate calculation of `reserves` may lead to incorrect evaluations, directly impacting whether the `mint`, `burn`, `open`, `close`, and `liquidate` functions can be used in the `core.vy` contract. +[gl-sherlock/contracts/core.vy:INVARIANTS_L130-L133](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L130C1-L133C68) +```vyper + # this works because each pool gets its own copy of all contracts + @internal + def INVARIANTS(id: uint256, base_token: address, quote_token: address): + pool : PoolState = self.POOLS.lookup(id) + base_balance : uint256 = ERC20(base_token).balanceOf(self) + quote_balance: uint256 = ERC20(quote_token).balanceOf(self) + +# @audit the `INVARIANTS` function in the `core.vy` contract assesses the health of the pool by comparing `token balance >= reserves + collateral` and `reserves >= interest`. +130 assert base_balance >= (pool.base_reserves + pool.base_collateral), ERR_INVARIANTS +131 assert quote_balance >= (pool.quote_reserves + pool.quote_collateral), ERR_INVARIANTS +132 assert pool.base_reserves >= pool.base_interest, ERR_INVARIANTS +133 assert pool.quote_reserves >= pool.quote_interest, ERR_INVARIANTS +``` +[gl-sherlock/contracts/core.vy:mint_L130-L133](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L184) +```vyper + @external + def mint( + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + base_amt : uint256, + quote_amt : uint256, + ctx : Ctx) -> uint256: +... +# @audit incorrect `reserves` may impact the functionality of `mint`, `burn`, `open`, `close`, and `liquidate` functions in the `core.vy` contract, for example: +184 self.INVARIANTS(id, base_token, quote_token) +... + +``` +Additionally, the `params.vy` contract uses the `utilization of reserves and interest` in calculating dynamic fees. Therefore, incorrect `reserves` values will also negatively affect users' returns. +[gl-sherlock/contracts/params.vy:dynamic_fees_L42-L49](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L42-L49) +```vyper + # fee computation (borrowing & funding fees) + @external + @view + def dynamic_fees(pool: PoolState) -> DynFees: + """ + Borrowing fees scale linearly based on pool utilization, from + MIN_FEE to MAX_FEE. + Funding fees scale base on the utilization imbalance off of the + borrowing fee. + """ + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) + +# @audit incorrect `reserves` impact the `utilization of reserves and interest` in calculating dynamic fees. +42 borrowing_long : uint256 = self.check_fee( +43 self.scale(self.PARAMS.MAX_FEE, long_utilization)) +44 borrowing_short : uint256 = self.check_fee( +45 self.scale(self.PARAMS.MAX_FEE, short_utilization)) +46 funding_long : uint256 = self.funding_fee( +47 borrowing_long, long_utilization, short_utilization) +48 funding_short : uint256 = self.funding_fee( +49 borrowing_short, short_utilization, long_utilization) + return DynFees({ + borrowing_long : borrowing_long, + borrowing_short: borrowing_short, + funding_long : funding_long, + funding_short : funding_short, + }) +``` + +## Impact +- Misleading `INVARIANTS` health checks could prevent or incorrectly allow operations like minting and liquidation in the `core.vy` contract. +- Fee calculations in `params.vy` will be inaccurate, affecting user rewards. +- Overall, failing to update `reserves` can cause systemic issues in contract operations and mismanagement of pool balances. + +## Code Snippet +[gl-sherlock/contracts/pools.vy:open_L247-L251](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/pools.vy#L247-L251) +[gl-sherlock/contracts/pools.vy:unlocked_reserves_L128-L129](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/pools.vy#L128C1-L129C50) +[gl-sherlock/contracts/core.vy:INVARIANTS_L130-L133](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L130C1-L133C68) +[gl-sherlock/contracts/core.vy:mint_L130-L133](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L184) +[gl-sherlock/contracts/params.vy:dynamic_fees_L42-L49](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L42-L49) + +## Tool used + +Manual Review + +## Recommendation +The `open` function in `pools.vy` could update the `reserves` by adding the new interest when updating `PoolState`. diff --git a/033.md b/033.md new file mode 100644 index 0000000..6de3c2f --- /dev/null +++ b/033.md @@ -0,0 +1,78 @@ +Great Pickle Worm + +Medium + +# Operations involving certain tokens that do not support 0 transfers may fail + +### Summary + +In the `mint()` and `burn()` functions, the protocol calls `ERC20.transferFrom()` or `ERC20.transfer()` to perform token transfers. +```solidity + assert base_amt > 0 or quote_amt > 0 , ERR_PRECONDITIONS + assert lp_amt > 0 , ERR_PRECONDITIONS + + assert ERC20(base_token).transferFrom(user, self, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transferFrom(user, self, quote_amt, default_return_value=True), "ERR_ERC20" + assert ERC20Plus(lp_token).mint(user, lp_amt), "ERR_ERC20" + + +``` + +```solidity +assert base_amt > 0 or quote_amt > 0 , ERR_PRECONDITIONS + assert lp_amt > 0 , ERR_PRECONDITIONS + + assert ERC20(base_token).transfer(user, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transfer(user, quote_amt, default_return_value=True), "ERR_ERC20" + assert ERC20Plus(lp_token).burn(user, lp_amt), "ERR_ERC20" + +``` + + +However, the protocol only checks if `base_amt > 0` or `quote_amt > 0`, meaning that one of these values could be 0. Some tokens, like LEND, do not support 0 transfers, which would cause the transaction to fail. + + +### Root Cause + +The protocol does not check if the amount is greater than 0 before performing the transfer. + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L174-L178 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L212-L216 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Some tokens, do not support 0 transfers, which would cause the transaction to fail. + +### PoC + +```solidity + assert ERC20(base_token).transfer(user, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transfer(user, quote_amt, default_return_value=True), "ERR_ERC20" + assert ERC20Plus(lp_token).burn(user, lp_amt), "ERR_ERC20" + +``` + +### Mitigation + +It is recommended to check if the amount is greater than 0 before performing the transfer, as shown in the following code. +```solidity + + if base_amt > 0: + assert ERC20(base_token).transfer(user, base_amt, default_return_value=True), "ERR_ERC20" + if quote_amt > 0: + assert ERC20(quote_token).transfer(user, quote_amt, default_return_value=True), "ERR_ERC20" + + +``` \ No newline at end of file diff --git a/034.md b/034.md new file mode 100644 index 0000000..b42fd69 --- /dev/null +++ b/034.md @@ -0,0 +1,63 @@ +Great Pickle Worm + +Medium + +# Tokens with no return value will cause the open position operation to fail + +### Summary + +In the `open()` function, the protocol calls `ERC20.transferFrom()` to take collateral from the user and checks the return value, which must be `true`. However, some tokens, such as USDT, do not return a value, which can cause the operation to fail. +```solidity + if long: assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" + else : assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" + + +``` + +### Root Cause + + +When the protocol calls the `transferFrom()` function to take funds from the user, it requires the return value to be `true`. However, some tokens, such as USDT, do not return a value, which can cause the operation to fail. + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L253-L254 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +Tokens without a return value, such as USDT, do not support opening positions. + +### Impact + +Opening a position with USDT will fail. + +### PoC + +```solidity + + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token, ERR_PRECONDITIONS + assert collateral > 0 , ERR_PRECONDITIONS + assert fee > 0 , ERR_PRECONDITIONS + + if long: assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" + else : assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" + + +``` + +### Mitigation + +It is recommended to add a default return value as code below +```solidity + assert ERC20(base_token).transferFrom(user, self, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transferFrom(user, self, quote_amt, default_return_value=True), "ERR_ERC20" + + +``` \ No newline at end of file diff --git a/035.md b/035.md new file mode 100644 index 0000000..bb1f253 --- /dev/null +++ b/035.md @@ -0,0 +1,131 @@ +Brilliant Burlap Elephant + +Medium + +# User can double-count funds by using the same token for both long and short positions + +### Summary + +A missing validation check will cause a double-counting of funds for the protocol as a user can use the same token for both long and short positions. + +### Root Cause + +In [`core.vy:open` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L230-L269), there is no check to ensure `base_token` and `quote_token` are not the same. +In [`api.vy:open` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L131-L159), there is no check to ensure `base_token` and `quote_token` are not the same. + + +### Internal pre-conditions + +1. User needs to call the [`open` function in `api.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L131-L159) with the same address for `base_token` and `quote_token`. +2. User needs to call the [`open` function in `core.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L230-L269) with the same address for `base_token` and `quote_token`. + +### External pre-conditions + +None. + + +### Attack Path + +1. User calls the `open` function in `api.vy` with the same address for `base_token` and `quote_token`. +2. The `open` function in `api.vy` calls the `open` function in `core.vy` with the same address for `base_token` and `quote_token`. +3. The `open` function in `core.vy` processes the request without checking if `base_token` and `quote_token` are the same. +4. The position is created with the same token for both long and short, leading to double-counting of funds. + + +### Impact + +The protocol suffers an approximate loss of funds due to double-counting. The attacker gains the ability to manipulate the protocol by using the same token for both long and short positions. + + +### PoC + +1. User calls the `open` function in `api.vy` with `base_token` and `quote_token` both set to the address of token A. +2. The `open` function in `api.vy` calls the `open` function in `core.vy` with `base_token` and `quote_token` both set to the address of token A. +3. The `open` function in `core.vy` processes the request and creates a position with token A as both the long and short token. +4. The position is created, and the funds are double-counted. + + +```python +File: api.vy +131: def open( +132: base_token : address, +133: quote_token : address, +134: long : bool, +135: collateral0 : uint256, +136: leverage : uint256, +137: desired : uint256, +138: slippage : uint256, +139: payload : Bytes[224] +140: ) -> PositionState: +141: """ +142: @notice Open a position +143: @param base_token Token representing the base coin of the pool (e.g. BTC) +144: @param quote_token Token representing the quote coin of the pool (e.g. USDT) +145: @param long Flag indicating whether to go long or short +146: @param collateral0 Collateral tokens to send (long positions are collateralized +147: in quote_token, short positions are collateralized in base token). +148: @param leverage How much leverage to use +149: @param desired Price to provide liquidity at (unit price using onchain +150: representation for quote_token, e.g. 1.50$ would be +151: 1500000 for USDT with 6 decimals) +152: @param slippage Acceptable deviaton of oracle price from desired price +153: (same units as desired e.g. to allow 5 cents of slippage, +154: send 50000). +155: @param payload Signed Redstone oracle payload +156: """ +157: ctx: Ctx = self.CONTEXT(base_token, quote_token, desired, slippage, payload) +158: return self.CORE.open(1, base_token, quote_token, long, collateral0, leverage, ctx) +159: + +``` + + + +```python +File: core.vy +230: def open( +231: id : uint256, +232: base_token : address, +233: quote_token : address, +234: long : bool, +235: collateral0 : uint256, +236: leverage : uint256, +237: ctx : Ctx) -> PositionState: +238: +239: self._INTERNAL() +240: +241: user : address = tx.origin +242: pool : PoolState = self.POOLS.lookup(id) +243: +244: cf : Fee = self.PARAMS.static_fees(collateral0) +245: fee : uint256 = cf.fee +246: collateral : uint256 = cf.remaining +247: +248: assert pool.base_token == base_token , ERR_PRECONDITIONS +249: assert pool.quote_token == quote_token, ERR_PRECONDITIONS +250: assert collateral > 0 , ERR_PRECONDITIONS +251: assert fee > 0 , ERR_PRECONDITIONS +252: +253: if long: assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" +254: else : assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" +255: +256: # transfer protocol fees to separate contract +257: if long: assert ERC20(quote_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" +258: else : assert ERC20(base_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" +259: +260: position: PositionState = self.POSITIONS.open(user, id, long, collateral, leverage, ctx) +261: self.POOLS.open(id, position.collateral_tagged, position.interest_tagged) +262: self.FEES.update(id) +263: +264: self.INVARIANTS(id, base_token, quote_token) +265: +266: log Open(user, ctx, pool, position) +267: +268: return position +269: + +``` + +### Mitigation + +To fix this issue, add a check in both `open` functions to ensure that `base_token` and `quote_token` are not the same. diff --git a/036.md b/036.md new file mode 100644 index 0000000..bf15ab9 --- /dev/null +++ b/036.md @@ -0,0 +1,58 @@ +Great Pickle Worm + +Medium + +# The user's positions can exceed `MAX_POSITIONS` + +### Summary + +In the `positions.open()` function, the protocol checks that the user's positions must be less than or equal to `MAX_POSITIONS`. +```solidity + assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + +``` + +This check is incorrect. It should be: + +`Positions(self).get_nr_user_positions(user) + 1 <= MAX_POSITIONS` + + +### Root Cause + +When checking if the user's positions are less than or equal to `MAX_POSITIONS`, it should use `Positions(self).get_nr_user_positions(user) + 1` for comparison with `MAX_POSITIONS`. + + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L147 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +When the user's positions are equal to `MAX_POSITIONS`, they can still add a new position. + +### Impact + +The user's positions can exceed `MAX_POSITIONS`. + +### PoC + +```solidity + assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + +``` + +### Mitigation + +It is recommended to modify the code as follows. +```solidity + assert Positions(self).get_nr_user_positions(user) +1 <= MAX_POSITIONS + +``` \ No newline at end of file diff --git a/037.md b/037.md new file mode 100644 index 0000000..317bf1f --- /dev/null +++ b/037.md @@ -0,0 +1,160 @@ +Brilliant Burlap Elephant + +Medium + +# Users will incur unnecessary fees and potential losses due to inability to adjust collateral + +### Summary + +The lack of functionality to adjust collateral in open positions will cause unnecessary fees and potential losses for users as they will be forced to close and reopen positions to adjust collateral. + + + +### Root Cause + +In `gl-sherlock/contracts/positions.vy` the contract lacks functions to adjust collateral for open positions. The [`open`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L108-L152) and [`close`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L367-L390) functions are the only ways to manage position collateral. + + +```python +File: positions.vy +108: def open( +109: user : address, +110: pool : uint256, +111: long : bool, +112: collateral: uint256, +113: leverage : uint256, +114: ctx : Ctx) -> PositionState: +115: self._INTERNAL() +116: +117: # Opening a position with leverage can be thought of as purchasing +118: # an amplified number of tokens. +119: # Longs buy base tokens with quote collateral and shorts buy quote +120: # tokens with base collateral (alternatively, longs buy base and shorts +121: # sell base). +122: virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( +123: self.MATH.base_to_quote(collateral, ctx) ) +124: interest : uint256 = virtual_tokens * leverage +125: +126: pos: PositionState = PositionState({ +127: id : self.next_position_id(), +128: pool : pool, +129: user : user, +130: status : Status.OPEN, +131: long : long, +132: collateral : collateral, +133: leverage : leverage, +134: interest : interest, +135: entry_price: ctx.price, +136: exit_price : 0, +137: opened_at : block.number, +138: closed_at : 0, +139: +140: collateral_tagged: Tokens({base: 0, quote: collateral}) if long else ( +141: Tokens({base: collateral, quote: 0}) ), +142: interest_tagged : Tokens({base: interest, quote: 0}) if long else ( +143: Tokens({base: 0, quote: interest}) ), +144: }) +145: ps: PoolState = self.POOLS.lookup(pool) +146: +147: assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS +148: assert self.PARAMS.is_legal_position(ps, pos) +149: +150: self.insert_user_position(user, pos.id) +151: return self.insert(pos) + +``` + + + +```python +File: positions.vy +367: def close(id: uint256, ctx: Ctx) -> PositionValue: +368: self._INTERNAL() +369: pos: PositionState = Positions(self).lookup(id) +370: assert pos.status == Status.OPEN , ERR_PRECONDITIONS +371: assert block.number > pos.opened_at, ERR_PRECONDITIONS +372: self.insert(PositionState({ +373: id : pos.id, +374: pool : pos.pool, +375: user : pos.user, +376: status : Status.CLOSED, +377: long : pos.long, +378: collateral : pos.collateral, +379: leverage : pos.leverage, +380: interest : pos.interest, +381: entry_price: pos.entry_price, +382: exit_price : ctx.price, +383: opened_at : pos.opened_at, +384: closed_at : block.number, +385: +386: collateral_tagged: pos.collateral_tagged, +387: interest_tagged : pos.interest_tagged, +388: })) +389: return Positions(self).value(id, ctx) +390: + +``` + + +### Internal pre-conditions + +1. User has an open position. +2. User wants to adjust the collateral of their open position (his position is near to be liquidatable). + + +### External pre-conditions + +None. + +### Attack Path + +1. User calls `open` function to create a position with initial collateral. +2. Market conditions change, his position is going to near liquidatable and the user wants to adjust his position's collateral. +3. User is forced to call the `close` function to close their position. +4. User calls `open` function again to create a new position with the desired collateral amount. + + +### Impact + +The users cannot adjust the collateral of their open positions without closing them, which can lead to inefficiencies and potential losses due to the need to close and reopen positions. + + +### PoC + +Alice opens a long position with 1000 USDT as collateral: + +```python +# Alice opens a position +alice_position = positions.open( + user=alice_address, + pool=1, + long=True, + collateral=1000 * 10**6, # 1000 USDT + leverage=2, + ctx=current_context +) +``` + +Market conditions change, and Alice wants to increase her collateral to 1500 USDT. However, there's no function to do this directly. Alice is forced to close her position and open a new one: + +```python +# Alice closes her position +close_value = positions.close(alice_position.id, current_context) + +# Alice opens a new position with increased collateral +new_alice_position = positions.open( + user=alice_address, + pool=1, + long=True, + collateral=1500 * 10**6, # 1500 USDT + leverage=2, + ctx=current_context +) +``` + +In this process, Alice incurs additional transaction costs for closing and reopening the position. She may also suffer from price slippage if the market price changed between these transactions. There is also a possibility to liquidate. + + +### Mitigation + +Introduce functions to adjust the collateral of an open position without closing it. This can be done by adding `increaseCollateral` and `decreaseCollateral` functions. diff --git a/038.md b/038.md new file mode 100644 index 0000000..1c48b97 --- /dev/null +++ b/038.md @@ -0,0 +1,1019 @@ +Hot Purple Buffalo + +High + +# Loss of Funds and Delayed Liquidations Due to Inaccurate Fee Accrual + +## Summary +All time-dependent fees in the system accrue according to an inaccurate approximation that will progressively lead to significant deviations from their implied cumulative value. This applies to both long/short rates, with both funding paid/received and borrowing rates impacted. As a result, liquidations will be delayed and LPs/funding recipients will be significantly underpaid. + +## Vulnerability Detail +Each [fee calculation](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/fees.vy#L164-L193) in `fees.vy`, such as + + +```vyper +borrowing_long_sum : uint256 = self.extend(fs.borrowing_long_sum, fs.borrowing_long, new_terms) +borrowing_short_sum : uint256 = self.extend(fs.borrowing_short_sum, fs.borrowing_short, new_terms) +funding_long_sum : uint256 = self.extend(fs.funding_long_sum, fs.funding_long, new_terms) +funding_short_sum : uint256 = self.extend(fs.funding_short_sum, fs.funding_short, new_terms) +``` + +makes use of the [same underlying calculation](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/fees.vy#L94-L98) through the use of `extend`: + +```vyper +def extend(X: uint256, x_m: uint256, m: uint256) -> uint256: + return X + (m*x_m) +``` + +That is, given a rate `r` and a number of intervals `n`, the fee contribution of each interval is given by $nr$, and the overall rate accrues as $\sum\limits_i n_ir_i$. However, this calculation is problematic since the percentage rates are naively added up and applied entirely at once, rather than applying them continuously. + +The exact fee calculation over each interval is given $(1+r)^n$ (now expressed as a ratio of 1), and the overall rate accrues as $\prod\limits_i (1 + {r_i})^{n_i}$. The Taylor expansion of each segment of this polynomial is given by $(1+r)^n = 1 + nr + \frac{1}{2}(n-1)nr^2 + \frac{1}{6}(n-2)(n-1)nr^3 + \cdots$ + +Notice that the existing calculation is effectively the linear taylor approximation for the true rate: +$(1+r)^n = 1 + nr +O(r^2)$ + +The error is bounded above by $R_2(r) \leq \frac{n(n-1)(1+r)^{n-2}}{2}r^2$. Critically, this approximation is relatively accurate **assuming n and r are not too large**. This assumption does not hold. + +Starting with `r`, fees are applied with 9 decimals of precision: + +```vyper +DENOM: constant(uint256) = 1_000_000_000 + +def apply(x: uint256, numerator: uint256) -> Fee: + fee : uint256 = (x * numerator) / DENOM +``` + +These fees are applied on a per-block basis. The BOB chain has an average block time of [2.0 seconds](https://explorer.gobob.xyz/). Using the existing fee calculation, `r = 10` will translate into an 8 hour funding rate of 0.0288%, or an annualized rate of 15.78%, a suitable estimate for what might be expected. + +Using the average block time of 2.0 seconds, we find that `n = 1.58e7` blocks in a 365 day calendar year. + +Given these values, the existing fee calculation $1 + nr$ yields a 15.78% annual interest rate while the exact value $(1+r)^n$ yields a 17.09% annual interest rate. This represents a 7.68% error in the interest rate over a single year. + +As n grows larger, the error continues to grow. Over a 4 year period, `n = 6.31e7` blocks and using the same `r = 10` from before we get interest rates of 63.12% and 83.03% for the current approach and the exact calculation, respectively. A 28.26% error in the interest rate results over this time period. This error steadily converges to 100%, with a **99.7% error over a 30 year period**. + +The assumption regarding the average value of `r = 10` (0.0288% 8 hour rate) was made based on [past funding rates for BTC](https://www.coinglass.com/funding/BTC). The funding rate over an 8 hour period has ranged between 0.01% and 0.25% over the past year. Given varying market conditions, as well as differing risk profiles for altcoins vs BTC, this is considered a fair and conservative estimate on average. Even if the assumed interest rate were halved (`r = 5`, 0.0144% 8 hour rate), errors of 3.9% and 15.0% occur over a 1 and 4 year period, respectively. On the other hand if doubled (`r = 20`, 0.0576% 8 hour rate), errors of 15% and 50% occur over the same respective timespans. + +Note that during periods of elevated rates, the errors described will grow non-linearly. For example, given `r = 100` (0.288% 8 hour rate), the existing method will yield an effective annual rate of 157.8%. The exact calculation yields a rate of 384.5%. While these rates are not representative of typical market conditions, it should paint a picture of how drastically inaccurate the current calculation could be, depending on the circumstances. The assumptions made above should serve as a rough estimate of average conditions, however periods with rates above this average are weighted more heavily than periods with lower rates. + +## Impact +Any Taylor approximation of the true rate is a strict underestimate. As a result, significant underpayments of funding fees and borrowing fees will consistently occur for every pool/position, disincentivizing LPs and carry traders and eroding confidence/trust in the system. Further, positions paying funding fees (historically longs) are effectively subsidized under the current model, increasing utilization and reducing capital efficiency. These positions (typically longs) will also take longer to liquidate and LP funds are locked for longer as a result. + +As a rough demonstration, consider a pool with an average long collateral value of $10M over a 4-year period, with an average 8-hour long funding rate of 0.0288% (as assumed above). Over this time period, longs are expected to pay $8.8M in funding fees to shorts. In reality they will pay $6.3M, **representing a shortfall of $2.5M** that should have been deducted from longs and paid to shorts. + +Assuming a 50% imbalance between shorts and longs, the average annual borrow rate is 31.56%, so LPs for this pool will also be underpaid by $5.0M from longs, in addition to a similar fee underpayments from shorts. + +Under this scenario, longs would have taken 25 months to liquidate from fees alone, when they should have taken 23 months to liquidate. This 10% deviation in absolute liquidation time underscores the true difference in liquidation time. When PnL/volatility are taken into account, these differences at the margin make all the difference and can result in LP funds being locked for far longer than this difference suggests. Additionally, positions which should have been liquidated may now post a profit, further increasing risk for LPs. + +These underpayments occur persistently, at all times and for every vault, and represent material losses for funding recipients and lenders, as well as significantly disrupt the proper liquidation process. + +## Code Snippet +For the PoC, `r = 10`, `long_collateral = short_collateral = 10^7 * 10^18` and `n = 6.3 * 10^7` blocks (4 years), as outlined above. + +The smart contracts were stripped to isolate the relevant logic, and [foundry](https://github.com/0xKitsune/Foundry-Vyper) was used for testing. To run the test, clone the repo and place `Fee.vy` in `vyper_contracts`, and place `Fee.t.sol`, `Cheat.sol`, and `IFee.sol` under `src/test`. + +
+Fee.t.sol + +```solidity + +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {CheatCodes} from "./Cheat.sol"; + +import "../../lib/ds-test/test.sol"; +import "../../lib/utils/Console.sol"; +import "../../lib/utils/VyperDeployer.sol"; + +import "./IFee.sol"; + +contract FeeTest is DSTest { + ///@notice create a new instance of VyperDeployer + VyperDeployer vyperDeployer = new VyperDeployer(); + CheatCodes vm = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + IFee fee; + uint256 constant YEAR = (60*60*24*(365) + 60*60*24 / 4); //includes leap year + + function setUp() public { + ///@notice deploy a new instance of ISimplestore by passing in the address of the deployed Vyper contract + fee = IFee(vyperDeployer.deployContract("Fee")); + } + + function testCalc() public { + uint256 blocksIn4Yr = (4*YEAR) / 2; + + vm.roll(blocksIn4Yr); + + fee.update(); + + // pools were initialized at block 0 + fee.calc(true, 1e25, 0); // (current) linear fee approximation + fee.calc2(true, 1e25, 0); // quadratic approximation + fee.calc3(true, 1e25, 0); // cubic approximation + } +} +``` +
+ +
+ Fee.vy + +```vyper + +struct DynFees: + funding_long : uint256 + funding_short : uint256 + +struct PoolState: + base_collateral : uint256 + quote_collateral : uint256 + +struct FeeState: + t1 : uint256 + funding_long : uint256 + funding_short : uint256 + long_collateral : uint256 + short_collateral : uint256 + funding_long_sum : uint256 + funding_short_sum : uint256 + received_long_sum : uint256 + received_short_sum : uint256 + +struct SumFees: + funding_paid : uint256 + funding_received: uint256 + +struct Period: + funding_long : uint256 + funding_short : uint256 + received_long : uint256 + received_short : uint256 + +#starting point hardcoded +@external +def __init__(): + self.FEE_STORE = FeeState({ + t1 : block.number, + funding_long : 10, + funding_short : 0, + long_collateral : 10_000_000_000_000_000_000_000_000, + short_collateral : 10_000_000_000_000_000_000_000_000, + funding_long_sum : 0, + funding_short_sum : 0, + received_long_sum : 0, + received_short_sum : 0, + }) + + self.FEE_STORE_AT[block.number] = self.FEE_STORE + + self.FEE_STORE2 = self.FEE_STORE + self.FEE_STORE_AT2[block.number] = self.FEE_STORE + self.FEE_STORE3 = self.FEE_STORE + self.FEE_STORE_AT3[block.number] = self.FEE_STORE + +# hardcoded funding rates for the scenario where funding is positive +@internal +@view +def dynamic_fees() -> DynFees: + return DynFees({ + funding_long : 10, + funding_short : 0, + }) + +# #hardcoded pool to have 1e24 of quote and base collateral +@internal +@view +def lookup() -> PoolState: + return PoolState({ + base_collateral : 10_000_000_000_000_000_000_000_000, + quote_collateral : 10_000_000_000_000_000_000_000_000, + }) + + +FEE_STORE : FeeState +FEE_STORE_AT : HashMap[uint256, FeeState] + +FEE_STORE2 : FeeState +FEE_STORE_AT2 : HashMap[uint256, FeeState] + +FEE_STORE3 : FeeState +FEE_STORE_AT3 : HashMap[uint256, FeeState] + +@internal +@view +def lookupFees() -> FeeState: + return self.FEE_STORE + +@internal +@view +def lookupFees2() -> FeeState: + return self.FEE_STORE2 + +@internal +@view +def lookupFees3() -> FeeState: + return self.FEE_STORE3 + +@internal +@view +def fees_at_block(height: uint256) -> FeeState: + return self.FEE_STORE_AT[height] + +@internal +@view +def fees_at_block2(height: uint256) -> FeeState: + return self.FEE_STORE_AT2[height] + +@internal +@view +def fees_at_block3(height: uint256) -> FeeState: + return self.FEE_STORE_AT3[height] + +@external +def update(): + fs: FeeState = self.current_fees() + fs2: FeeState = self.current_fees2() + fs3: FeeState = self.current_fees3() + + self.FEE_STORE_AT[block.number] = fs + self.FEE_STORE = fs + + self.FEE_STORE_AT2[block.number] = fs2 + self.FEE_STORE2 = fs2 + + self.FEE_STORE_AT3[block.number] = fs3 + self.FEE_STORE3 = fs3 + +#math +ZEROS: constant(uint256) = 1000000000000000000000000000 +DENOM: constant(uint256) = 1_000_000_000 + +@internal +@pure +def extend(X: uint256, x_m: uint256, m: uint256) -> uint256: + return X + (m*x_m) + +@internal +@pure +def o2(r: uint256, n: uint256) -> uint256: + if(n == 0): + return 0 + return r*n + ((n-1)*n*(r**2)/2)/DENOM + +@internal +@pure +def o3(r: uint256, n: uint256) -> uint256: + if(n == 0): + return 0 + if(n == 1): + return r*n + + return r*n + ((n-1)*n*(r**2)/2)/DENOM + ((n-2)*(n-1)*n*(r**3)/6)/DENOM**2 + +@internal +@pure +def apply(x: uint256, numerator: uint256) -> uint256: + """ + Fees are represented as numerator only, with the denominator defined + here. This computes x*fee capped at x. + """ + fee : uint256 = (x * numerator) / DENOM + fee_ : uint256 = fee if fee <= x else x + return fee_ + +@internal +@pure +def divide(paid: uint256, collateral: uint256) -> uint256: + if collateral == 0: return 0 + else : return (paid * ZEROS) / collateral + +@internal +@pure +def multiply(ci: uint256, terms: uint256) -> uint256: + return (ci * terms) / ZEROS + +@internal +@pure +def slice(y_i: uint256, y_j: uint256) -> uint256: + return y_j - y_i + +@internal +@view +def current_fees() -> FeeState: + """ + Update incremental fee state, called whenever the pool state changes. + """ + # prev/last updated state + fs : FeeState = self.lookupFees() + # current state + ps : PoolState = self.lookup() + new_fees : DynFees = self.dynamic_fees() + # number of blocks elapsed + new_terms: uint256 = block.number - fs.t1 + + funding_long_sum : uint256 = self.extend(fs.funding_long_sum, fs.funding_long, new_terms) + funding_short_sum : uint256 = self.extend(fs.funding_short_sum, fs.funding_short, new_terms) + + paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms) + received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) + + paid_short_term : uint256 = self.apply(fs.short_collateral, fs.funding_short * new_terms) + received_long_term : uint256 = self.divide(paid_short_term, fs.long_collateral) + + received_long_sum : uint256 = self.extend(fs.received_long_sum, received_long_term, 1) + received_short_sum : uint256 = self.extend(fs.received_short_sum, received_short_term, 1) + + if new_terms == 0: + return FeeState({ + t1 : fs.t1, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : fs.funding_long_sum, + funding_short_sum : fs.funding_short_sum, + received_long_sum : fs.received_long_sum, + received_short_sum : fs.received_short_sum, + }) + else: + return FeeState({ + t1 : block.number, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : funding_long_sum, + funding_short_sum : funding_short_sum, + received_long_sum : received_long_sum, + received_short_sum : received_short_sum, + }) + +@internal +@view +def current_fees2() -> FeeState: + """ + Update incremental fee state, called whenever the pool state changes. + """ + # prev/last updated state + fs : FeeState = self.lookupFees2() + # current state + ps : PoolState = self.lookup() + new_fees : DynFees = self.dynamic_fees() + # number of blocks elapsed + new_terms: uint256 = block.number - fs.t1 + + o2_l: uint256 = self.o2(fs.funding_long, new_terms) + o2_s: uint256 = self.o2(fs.funding_short, new_terms) + + funding_long_sum : uint256 = self.extend(fs.funding_long_sum, o2_l, 1) + funding_short_sum : uint256 = self.extend(fs.funding_short_sum, o2_s, 1) + + paid_long_term : uint256 = self.apply(fs.long_collateral, o2_l) + received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) + + paid_short_term : uint256 = self.apply(fs.short_collateral, o2_s) + received_long_term : uint256 = self.divide(paid_short_term, fs.long_collateral) + + received_long_sum : uint256 = self.extend(fs.received_long_sum, received_long_term, 1) + received_short_sum : uint256 = self.extend(fs.received_short_sum, received_short_term, 1) + + if new_terms == 0: + return FeeState({ + t1 : fs.t1, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : fs.funding_long_sum, + funding_short_sum : fs.funding_short_sum, + received_long_sum : fs.received_long_sum, + received_short_sum : fs.received_short_sum, + }) + else: + return FeeState({ + t1 : block.number, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : funding_long_sum, + funding_short_sum : funding_short_sum, + received_long_sum : received_long_sum, + received_short_sum : received_short_sum, + }) + +@internal +@view +def current_fees3() -> FeeState: + """ + Update incremental fee state, called whenever the pool state changes. + """ + # prev/last updated state + fs : FeeState = self.lookupFees3() + # current state + ps : PoolState = self.lookup() + new_fees : DynFees = self.dynamic_fees() + # number of blocks elapsed + new_terms: uint256 = block.number - fs.t1 + + o2_l: uint256 = self.o3(fs.funding_long, new_terms) + o2_s: uint256 = self.o3(fs.funding_short, new_terms) + + funding_long_sum : uint256 = self.extend(fs.funding_long_sum, o2_l, 1) + funding_short_sum : uint256 = self.extend(fs.funding_short_sum, o2_s, 1) + + paid_long_term : uint256 = self.apply(fs.long_collateral, o2_l) + received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) + + paid_short_term : uint256 = self.apply(fs.short_collateral, o2_s) + received_long_term : uint256 = self.divide(paid_short_term, fs.long_collateral) + + received_long_sum : uint256 = self.extend(fs.received_long_sum, received_long_term, 1) + received_short_sum : uint256 = self.extend(fs.received_short_sum, received_short_term, 1) + + if new_terms == 0: + return FeeState({ + t1 : fs.t1, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : fs.funding_long_sum, + funding_short_sum : fs.funding_short_sum, + received_long_sum : fs.received_long_sum, + received_short_sum : fs.received_short_sum, + }) + else: + return FeeState({ + t1 : block.number, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : funding_long_sum, + funding_short_sum : funding_short_sum, + received_long_sum : received_long_sum, + received_short_sum : received_short_sum, + }) + +@internal +@view +def query(opened_at: uint256) -> Period: + """ + Return the total fees due from block `opened_at` to the current block. + """ + fees_i : FeeState = self.fees_at_block(opened_at) + fees_j : FeeState = self.current_fees() + return Period({ + funding_long : self.slice(fees_i.funding_long_sum, fees_j.funding_long_sum), + funding_short : self.slice(fees_i.funding_short_sum, fees_j.funding_short_sum), + received_long : self.slice(fees_i.received_long_sum, fees_j.received_long_sum), + received_short : self.slice(fees_i.received_short_sum, fees_j.received_short_sum), + }) + +@external +@view +def calc(long: bool, collateral: uint256, opened_at: uint256) -> SumFees: + period: Period = self.query(opened_at) + P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( + self.apply(collateral, period.funding_short) ) + R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( + self.multiply(collateral, period.received_short) ) + + return SumFees({funding_paid: P_f, funding_received: R_f}) + +@internal +@view +def query2(opened_at: uint256) -> Period: + """ + Return the total fees due from block `opened_at` to the current block. + """ + fees_i : FeeState = self.fees_at_block2(opened_at) + fees_j : FeeState = self.current_fees2() + return Period({ + funding_long : self.slice(fees_i.funding_long_sum, fees_j.funding_long_sum), + funding_short : self.slice(fees_i.funding_short_sum, fees_j.funding_short_sum), + received_long : self.slice(fees_i.received_long_sum, fees_j.received_long_sum), + received_short : self.slice(fees_i.received_short_sum, fees_j.received_short_sum), + }) + +@external +@view +def calc2(long: bool, collateral: uint256, opened_at: uint256) -> SumFees: + period: Period = self.query2(opened_at) + P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( + self.apply(collateral, period.funding_short) ) + R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( + self.multiply(collateral, period.received_short) ) + + return SumFees({funding_paid: P_f, funding_received: R_f}) + +@internal +@view +def query3(opened_at: uint256) -> Period: + """ + Return the total fees due from block `opened_at` to the current block. + """ + fees_i : FeeState = self.fees_at_block3(opened_at) + fees_j : FeeState = self.current_fees3() + return Period({ + funding_long : self.slice(fees_i.funding_long_sum, fees_j.funding_long_sum), + funding_short : self.slice(fees_i.funding_short_sum, fees_j.funding_short_sum), + received_long : self.slice(fees_i.received_long_sum, fees_j.received_long_sum), + received_short : self.slice(fees_i.received_short_sum, fees_j.received_short_sum), + }) + +@external +@view +def calc3(long: bool, collateral: uint256, opened_at: uint256) -> SumFees: + period: Period = self.query3(opened_at) + P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( + self.apply(collateral, period.funding_short) ) + R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( + self.multiply(collateral, period.received_short) ) + + return SumFees({funding_paid: P_f, funding_received: R_f}) +``` +
+ +
+ Cheat.sol + +```solidity +interface CheatCodes { + // This allows us to getRecordedLogs() + struct Log { + bytes32[] topics; + bytes data; + } + + // Possible caller modes for readCallers() + enum CallerMode { + None, + Broadcast, + RecurrentBroadcast, + Prank, + RecurrentPrank + } + + enum AccountAccessKind { + Call, + DelegateCall, + CallCode, + StaticCall, + Create, + SelfDestruct, + Resume + } + + struct Wallet { + address addr; + uint256 publicKeyX; + uint256 publicKeyY; + uint256 privateKey; + } + + struct ChainInfo { + uint256 forkId; + uint256 chainId; + } + + struct AccountAccess { + ChainInfo chainInfo; + AccountAccessKind kind; + address account; + address accessor; + bool initialized; + uint256 oldBalance; + uint256 newBalance; + bytes deployedCode; + uint256 value; + bytes data; + bool reverted; + StorageAccess[] storageAccesses; + } + + struct StorageAccess { + address account; + bytes32 slot; + bool isWrite; + bytes32 previousValue; + bytes32 newValue; + bool reverted; + } + + // Derives a private key from the name, labels the account with that name, and returns the wallet + function createWallet(string calldata) external returns (Wallet memory); + + // Generates a wallet from the private key and returns the wallet + function createWallet(uint256) external returns (Wallet memory); + + // Generates a wallet from the private key, labels the account with that name, and returns the wallet + function createWallet(uint256, string calldata) external returns (Wallet memory); + + // Signs data, (Wallet, digest) => (v, r, s) + function sign(Wallet calldata, bytes32) external returns (uint8, bytes32, bytes32); + + // Get nonce for a Wallet + function getNonce(Wallet calldata) external returns (uint64); + + // Set block.timestamp + function warp(uint256) external; + + // Set block.number + function roll(uint256) external; + + // Set block.basefee + function fee(uint256) external; + + // Set block.difficulty + // Does not work from the Paris hard fork and onwards, and will revert instead. + function difficulty(uint256) external; + + // Set block.prevrandao + // Does not work before the Paris hard fork, and will revert instead. + function prevrandao(bytes32) external; + + // Set block.chainid + function chainId(uint256) external; + + // Loads a storage slot from an address + function load(address account, bytes32 slot) external returns (bytes32); + + // Stores a value to an address' storage slot + function store(address account, bytes32 slot, bytes32 value) external; + + // Signs data + function sign(uint256 privateKey, bytes32 digest) + external + returns (uint8 v, bytes32 r, bytes32 s); + + // Computes address for a given private key + function addr(uint256 privateKey) external returns (address); + + // Derive a private key from a provided mnemonic string, + // or mnemonic file path, at the derivation path m/44'/60'/0'/0/{index}. + function deriveKey(string calldata, uint32) external returns (uint256); + // Derive a private key from a provided mnemonic string, or mnemonic file path, + // at the derivation path {path}{index} + function deriveKey(string calldata, string calldata, uint32) external returns (uint256); + + // Gets the nonce of an account + function getNonce(address account) external returns (uint64); + + // Sets the nonce of an account + // The new nonce must be higher than the current nonce of the account + function setNonce(address account, uint64 nonce) external; + + // Performs a foreign function call via terminal + function ffi(string[] calldata) external returns (bytes memory); + + // Set environment variables, (name, value) + function setEnv(string calldata, string calldata) external; + + // Read environment variables, (name) => (value) + function envBool(string calldata) external returns (bool); + function envUint(string calldata) external returns (uint256); + function envInt(string calldata) external returns (int256); + function envAddress(string calldata) external returns (address); + function envBytes32(string calldata) external returns (bytes32); + function envString(string calldata) external returns (string memory); + function envBytes(string calldata) external returns (bytes memory); + + // Read environment variables as arrays, (name, delim) => (value[]) + function envBool(string calldata, string calldata) + external + returns (bool[] memory); + function envUint(string calldata, string calldata) + external + returns (uint256[] memory); + function envInt(string calldata, string calldata) + external + returns (int256[] memory); + function envAddress(string calldata, string calldata) + external + returns (address[] memory); + function envBytes32(string calldata, string calldata) + external + returns (bytes32[] memory); + function envString(string calldata, string calldata) + external + returns (string[] memory); + function envBytes(string calldata, string calldata) + external + returns (bytes[] memory); + + // Read environment variables with default value, (name, value) => (value) + function envOr(string calldata, bool) external returns (bool); + function envOr(string calldata, uint256) external returns (uint256); + function envOr(string calldata, int256) external returns (int256); + function envOr(string calldata, address) external returns (address); + function envOr(string calldata, bytes32) external returns (bytes32); + function envOr(string calldata, string calldata) external returns (string memory); + function envOr(string calldata, bytes calldata) external returns (bytes memory); + + // Read environment variables as arrays with default value, (name, value[]) => (value[]) + function envOr(string calldata, string calldata, bool[] calldata) external returns (bool[] memory); + function envOr(string calldata, string calldata, uint256[] calldata) external returns (uint256[] memory); + function envOr(string calldata, string calldata, int256[] calldata) external returns (int256[] memory); + function envOr(string calldata, string calldata, address[] calldata) external returns (address[] memory); + function envOr(string calldata, string calldata, bytes32[] calldata) external returns (bytes32[] memory); + function envOr(string calldata, string calldata, string[] calldata) external returns (string[] memory); + function envOr(string calldata, string calldata, bytes[] calldata) external returns (bytes[] memory); + + // Convert Solidity types to strings + function toString(address) external returns(string memory); + function toString(bytes calldata) external returns(string memory); + function toString(bytes32) external returns(string memory); + function toString(bool) external returns(string memory); + function toString(uint256) external returns(string memory); + function toString(int256) external returns(string memory); + + // Sets the *next* call's msg.sender to be the input address + function prank(address) external; + + // Sets all subsequent calls' msg.sender to be the input address + // until `stopPrank` is called + function startPrank(address) external; + + // Sets the *next* call's msg.sender to be the input address, + // and the tx.origin to be the second input + function prank(address, address) external; + + // Sets all subsequent calls' msg.sender to be the input address until + // `stopPrank` is called, and the tx.origin to be the second input + function startPrank(address, address) external; + + // Resets subsequent calls' msg.sender to be `address(this)` + function stopPrank() external; + + // Reads the current `msg.sender` and `tx.origin` from state and reports if there is any active caller modification + function readCallers() external returns (CallerMode callerMode, address msgSender, address txOrigin); + + // Sets an address' balance + function deal(address who, uint256 newBalance) external; + + // Sets an address' code + function etch(address who, bytes calldata code) external; + + // Marks a test as skipped. Must be called at the top of the test. + function skip(bool skip) external; + + // Expects an error on next call + function expectRevert() external; + function expectRevert(bytes calldata) external; + function expectRevert(bytes4) external; + + // Record all storage reads and writes + function record() external; + + // Gets all accessed reads and write slot from a recording session, + // for a given address + function accesses(address) + external + returns (bytes32[] memory reads, bytes32[] memory writes); + + // Record all account accesses as part of CREATE, CALL or SELFDESTRUCT opcodes in order, + // along with the context of the calls. + function startStateDiffRecording() external; + + // Returns an ordered array of all account accesses from a `startStateDiffRecording` session. + function stopAndReturnStateDiff() external returns (AccountAccess[] memory accesses); + + // Record all the transaction logs + function recordLogs() external; + + // Gets all the recorded logs + function getRecordedLogs() external returns (Log[] memory); + + // Prepare an expected log with the signature: + // (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData). + // + // Call this function, then emit an event, then call a function. + // Internally after the call, we check if logs were emitted in the expected order + // with the expected topics and data (as specified by the booleans) + // + // The second form also checks supplied address against emitting contract. + function expectEmit(bool, bool, bool, bool) external; + function expectEmit(bool, bool, bool, bool, address) external; + + // Mocks a call to an address, returning specified data. + // + // Calldata can either be strict or a partial match, e.g. if you only + // pass a Solidity selector to the expected calldata, then the entire Solidity + // function will be mocked. + function mockCall(address, bytes calldata, bytes calldata) external; + + // Reverts a call to an address, returning the specified error + // + // Calldata can either be strict or a partial match, e.g. if you only + // pass a Solidity selector to the expected calldata, then the entire Solidity + // function will be mocked. + function mockCallRevert(address where, bytes calldata data, bytes calldata retdata) external; + + // Clears all mocked and reverted mocked calls + function clearMockedCalls() external; + + // Expect a call to an address with the specified calldata. + // Calldata can either be strict or a partial match + function expectCall(address callee, bytes calldata data) external; + // Expect a call to an address with the specified + // calldata and message value. + // Calldata can either be strict or a partial match + function expectCall(address callee, uint256, bytes calldata data) external; + + // Gets the _creation_ bytecode from an artifact file. Takes in the relative path to the json file + function getCode(string calldata) external returns (bytes memory); + // Gets the _deployed_ bytecode from an artifact file. Takes in the relative path to the json file + function getDeployedCode(string calldata) external returns (bytes memory); + + // Label an address in test traces + function label(address addr, string calldata label) external; + + // Retrieve the label of an address + function getLabel(address addr) external returns (string memory); + + // When fuzzing, generate new inputs if conditional not met + function assume(bool) external; + + // Set block.coinbase (who) + function coinbase(address) external; + + // Using the address that calls the test contract or the address provided + // as the sender, has the next call (at this call depth only) create a + // transaction that can later be signed and sent onchain + function broadcast() external; + function broadcast(address) external; + + // Using the address that calls the test contract or the address provided + // as the sender, has all subsequent calls (at this call depth only) create + // transactions that can later be signed and sent onchain + function startBroadcast() external; + function startBroadcast(address) external; + function startBroadcast(uint256 privateKey) external; + + // Stops collecting onchain transactions + function stopBroadcast() external; + + // Reads the entire content of file to string, (path) => (data) + function readFile(string calldata) external returns (string memory); + // Get the path of the current project root + function projectRoot() external returns (string memory); + // Reads next line of file to string, (path) => (line) + function readLine(string calldata) external returns (string memory); + // Writes data to file, creating a file if it does not exist, and entirely replacing its contents if it does. + // (path, data) => () + function writeFile(string calldata, string calldata) external; + // Writes line to file, creating a file if it does not exist. + // (path, data) => () + function writeLine(string calldata, string calldata) external; + // Closes file for reading, resetting the offset and allowing to read it from beginning with readLine. + // (path) => () + function closeFile(string calldata) external; + // Removes file. This cheatcode will revert in the following situations, but is not limited to just these cases: + // - Path points to a directory. + // - The file doesn't exist. + // - The user lacks permissions to remove the file. + // (path) => () + function removeFile(string calldata) external; + // Returns true if the given path points to an existing entity, else returns false + // (path) => (bool) + function exists(string calldata) external returns (bool); + // Returns true if the path exists on disk and is pointing at a regular file, else returns false + // (path) => (bool) + function isFile(string calldata) external returns (bool); + // Returns true if the path exists on disk and is pointing at a directory, else returns false + // (path) => (bool) + function isDir(string calldata) external returns (bool); + + // Return the value(s) that correspond to 'key' + function parseJson(string memory json, string memory key) external returns (bytes memory); + // Return the entire json file + function parseJson(string memory json) external returns (bytes memory); + // Check if a key exists in a json string + function keyExists(string memory json, string memory key) external returns (bytes memory); + // Get list of keys in a json string + function parseJsonKeys(string memory json, string memory key) external returns (string[] memory); + + // Snapshot the current state of the evm. + // Returns the id of the snapshot that was created. + // To revert a snapshot use `revertTo` + function snapshot() external returns (uint256); + // Revert the state of the evm to a previous snapshot + // Takes the snapshot id to revert to. + // This deletes the snapshot and all snapshots taken after the given snapshot id. + function revertTo(uint256) external returns (bool); + + // Creates a new fork with the given endpoint and block, + // and returns the identifier of the fork + function createFork(string calldata, uint256) external returns (uint256); + // Creates a new fork with the given endpoint and the _latest_ block, + // and returns the identifier of the fork + function createFork(string calldata) external returns (uint256); + + // Creates _and_ also selects a new fork with the given endpoint and block, + // and returns the identifier of the fork + function createSelectFork(string calldata, uint256) + external + returns (uint256); + // Creates _and_ also selects a new fork with the given endpoint and the + // latest block and returns the identifier of the fork + function createSelectFork(string calldata) external returns (uint256); + + // Takes a fork identifier created by `createFork` and + // sets the corresponding forked state as active. + function selectFork(uint256) external; + + // Returns the currently active fork + // Reverts if no fork is currently active + function activeFork() external returns (uint256); + + // Updates the currently active fork to given block number + // This is similar to `roll` but for the currently active fork + function rollFork(uint256) external; + // Updates the given fork to given block number + function rollFork(uint256 forkId, uint256 blockNumber) external; + + // Fetches the given transaction from the active fork and executes it on the current state + function transact(bytes32) external; + // Fetches the given transaction from the given fork and executes it on the current state + function transact(uint256, bytes32) external; + + // Marks that the account(s) should use persistent storage across + // fork swaps in a multifork setup, meaning, changes made to the state + // of this account will be kept when switching forks + function makePersistent(address) external; + function makePersistent(address, address) external; + function makePersistent(address, address, address) external; + function makePersistent(address[] calldata) external; + // Revokes persistent status from the address, previously added via `makePersistent` + function revokePersistent(address) external; + function revokePersistent(address[] calldata) external; + // Returns true if the account is marked as persistent + function isPersistent(address) external returns (bool); + + /// Returns the RPC url for the given alias + function rpcUrl(string calldata) external returns (string memory); + /// Returns all rpc urls and their aliases `[alias, url][]` + function rpcUrls() external returns (string[2][] memory); +} + +``` +
+ +
+ IFee.sol + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +interface IFee { + function update() external; + function calc(bool, uint256, uint256) external returns(SumFees memory); + function calc2(bool, uint256, uint256) external returns(SumFees memory); + function calc3(bool, uint256, uint256) external returns(SumFees memory); + + struct SumFees{ + uint256 funding_paid; + uint256 funding_received; + } +} +``` +
+ +## Tool used + +Manual Review + +## Recommendation +Consider replacing each `fs. * new_terms` with an intermediate calculation using these two values. For a quadratic approximation, include + +```vyper +@internal +@pure +def o2(r: uint256, n: uint256) -> uint256: + if(n == 0): + return 0 + return r*n + ((n-1)*n*(r**2)/2)/DENOM +``` + +while for a cubic approximation, include + +```vyper +@internal +@pure +def o3(r: uint256, n: uint256) -> uint256: + if(n == 0): + return 0 + if(n == 1): + return r*n + + return r*n + ((n-1)*n*(r**2)/2)/DENOM + ((n-2)*(n-1)*n*(r**3)/6)/DENOM**2 +``` + +Refer to `Fee.vy` above for guidance on necessary adjustments to the various functions. + +The quadratic approximation will provide the largest improvement for the least added computational cost, and is the recommended compromise. As a reference, the quadratic approximation drops the current error from 28.26% to 5.62% (an 80% reduction) given a 4-year timespan and `r = 10`. The cubic approximation further decreases the error to 0.85% (a 97% reduction). + +The error can be made arbitrarily small, at the expense of increased computational costs for diminishing returns. For example, the quartic approximation drops the error down to 0.11% (a 99.6% reduction). \ No newline at end of file diff --git a/039.md b/039.md new file mode 100644 index 0000000..5e3dc20 --- /dev/null +++ b/039.md @@ -0,0 +1,31 @@ +Genuine Butter Hare + +High + +# Check overflow of array. + +## Summary +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L85 + +In insert_user_position function + +```vyper +ids: DynArray[uint256, 500] = self.USER_POSITIONS[user] +ids.append(id) +``` + +When you append new element ,it would fail if the array has already reached its maximum size of 500 elements. + + +## Recommendation + +Before appending the id, need to check ensures the DynArray hasn't reached its maximum size (500) + +```vyper +ids: DynArray[uint256, 500] = self.USER_POSITIONS[user] +if len(ids) >= 500: + return False # Array is full, cannot insert +# Append the new position ID +ids.append(id) +``` + diff --git a/041.md b/041.md new file mode 100644 index 0000000..7f0adfd --- /dev/null +++ b/041.md @@ -0,0 +1,59 @@ +Brilliant Burlap Elephant + +Medium + +# Traders can manipulate spread protection by exploiting position size omission in `api.vy::CONTEXT` + +### Summary + +The omission of position size in the [`CONTEXT` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L53-L71) will cause a bypass of spread protection for liquidity providers (LPs) as traders will open a small reverse position before their intended larger position, potentially causing financial losses for LPs. + +### Root Cause + +In `gl-sherlock/contracts/api.vy`, the `[CONTEXT` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L53-L71) does not consider the size of the new position when calculating the price with the oracle. + +Example: +- In [`api.vy:53-71`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L53-L71), the `CONTEXT` function does not include the position size in its price calculation. + +### Internal pre-conditions + +1. Trader needs to have sufficient funds to open both a reverse position and the intended larger position. +2. The pool must have enough liquidity to accommodate both positions. + +### External pre-conditions + +1. The oracle price needs to remain relatively stable during the execution of both `open()` calls. + + +### Attack Path + +1. Trader calls `open()` in [`api.vy:131-158`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L131-L159) to set a small reverse position. +2. Trader immediately calls [`open()`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L131-L159) again to set the intended larger position. + +### Impact + +The LPs suffer an increased risk of financial loss as the spread protection is bypassed, leading to potential manipulation of the trading mechanism. Traders gain an unfair advantage by effectively opening large positions without incurring the appropriate spread, which could result in significant losses for LPs over time. + +### PoC + +1. Assume the current price of ETH is $2000, and the spread for a 10 ETH position is 0.5%. +2. A trader wants to open a 10 ETH long position. +3. Instead of directly opening a 10 ETH long position (which would incur a 0.5% spread), the trader: + - Opens a 1 ETH short position: + ```python + ctx: Ctx = self.CONTEXT(ETH, USDC, 2000 * 10**6, 10 * 10**6, payload) + self.CORE.open(1, ETH, USDC, False, 2000 * 10**6, 1, ctx) + ``` + - Immediately opens an 11 ETH long position: + ```python + ctx: Ctx = self.CONTEXT(ETH, USDC, 2000 * 10**6, 10 * 10**6, payload) + self.CORE.open(1, ETH, USDC, True, 22000 * 10**6, 1, ctx) + ``` +4. The trader effectively opens a 10 ETH long position without incurring the 0.5% spread, saving $100 (0.5% of $20,000) at the expense of LPs. + + +### Mitigation + +To mitigate this issue, the `CONTEXT` function should take into account the size of the new position when calculating the price. + +Additionally, the `open()` function in `api.vy` should be modified to pass the position size to `CONTEXT`. \ No newline at end of file diff --git a/042.md b/042.md new file mode 100644 index 0000000..73dea6a --- /dev/null +++ b/042.md @@ -0,0 +1,46 @@ +Shaggy Smoke Mole + +Medium + +# Approve and transferFrom functions of ERC20Plus are subject to front-run attack + +### Summary + +This vulnerability is the same than this one found in sherlock's surge audit: https://github.com/sherlock-audit/2023-02-surge-judging/issues/154 + +`Approve` and `transferFrom` functions of LP tokens (`ERC20Plus.vy`) are subject to front-run attack because the `approve` method overwrites the current allowance regardless of whether the spender already used it or not. In case the spender spent the amonut, the `approve` function will approve a new amount. + +### Root Cause + +The `approve` method on [`ERC20Plus.vy:73`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/tokens/ERC20Plus.vy#L73-L77) overwrites the current allowance regardless of whether the spender already used it or not. It allows the spender to front-run and spend the amount before the new allowance is set. + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +The victim should want to set new allowance to a user which already has allowance. + +### Attack Path + +- Alice allows Bob to transfer N of Alice's tokens (N>0) by calling the `approve` method, passing the Bob's address and N as the method arguments +- After some time, Alice decides to change from N to M (M>0) the number of Alice's tokens Bob is allowed to transfer, so she calls the `approve` method again, this time passing the Bob's address and M as the method arguments +- Bob notices the Alice's second transaction before it was mined and quickly sends another transaction that calls the `transferFrom` method to transfer N Alice's tokens somewhere +- If the Bob's transaction will be executed before the Alice's transaction, then Bob will successfully transfer N Alice's tokens and will gain an ability to transfer another M tokens +Before Alice noticed that something went wrong, Bob calls the `transferFrom` method again, this time to transfer M Alice's tokens. +- So, an Alice's attempt to change the Bob's allowance from N to M (N>0 and M>0) made it possible for Bob to transfer N+M of Alice's tokens, while Alice never wanted to allow so many of her tokens to be transferred by Bob. + +### Impact + +It can result in sending more LP tokens when the victim approves LP tokens to any malicious account. + +### PoC + +_No response_ + +### Mitigation + +Create `increaseAllowance` and `decreaseAllowance` functions as OpenZeppelin ERC20 implementation. Please see details here: + +https://forum.openzeppelin.com/t/explain-the-practical-use-of-increaseallowance-and-decreaseallowance-functions-on-erc20/15103/4 \ No newline at end of file diff --git a/043.md b/043.md new file mode 100644 index 0000000..a616782 --- /dev/null +++ b/043.md @@ -0,0 +1,96 @@ +Rhythmic Iron Cat + +High + +# Incorrect Parameter Usage in `quote_to_base` Function Leads to Position and Profit&Loss Calculation Errors + +## Summary +The `quote_to_base` function in `math.vy` incorrectly uses the `quote` price instead of the `base` price when converting `quote` to `base`. This causes errors in position opening and profit/loss calculations in `position.vy`. + +## Vulnerability Detail +The `quote_to_base` function in the `math.vy` contract incorrectly calls the `from_amount` function by passing the `quote` amount and `quote` price as parameters. The issue arises because when converting the `quote` amount into `base` volume, the correct parameters should be the `quote` amount and the `base` price, not the `quote` price. +[gl-sherlock/contracts/math.vy:quote_to_base_L84](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/math.vy#L84) +```vyper + @external + @pure + def quote_to_base(tokens: uint256, ctx: Ctx) -> uint256: + l1 : Tokens = self.lift(Tokens({base: 0, quote: tokens}), ctx) + l2 : Tokens = self.lift(Tokens({base: 0, quote: ctx.price}), ctx) + +# @audit `quote_to_base` calls the `from_amount` function by passing the `quote` amount and `quote` price as parameters +84 vol0 : uint256 = self.from_amount(l1.quote, l2.quote, self.one(ctx)) + lowered: Tokens = self.lower(Tokens({base: vol0, quote: 0}), ctx) + return lowered.base +``` +[gl-sherlock/contracts/math.vy:from_amount_L65](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/math.vy#L65) +```vyper + @internal + @pure + def from_amount(amount: uint256, price: uint256, one1: uint256) -> uint256: + """ + Returns volume implied by price. + """ +# @audit quote amount incorrectly divided by quote price when `quote_to_base` calls the `from_amount` function +65 return (amount * one1) / price + +``` + +This error impacts several functions in the `position.vy` contract, such as the `open`, `calc_pnl_long`, and `calc_pnl_short` functions, which all call the `quote_to_base` function. The miscalculation in `quote_to_base` causes errors in position opening and profit/loss calculations, leading to potential financial losses for both the platform and its customers. +[gl-sherlock/contracts/positions.vy:open_L122](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L122) +```vyper + @external + def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + self._INTERNAL() + +122 @audit=> virtual_tokens: uint256 = self.MATH.quote_to_base(collateral, ctx) if long else ( + +``` + +[gl-sherlock/contracts/positions.vy:calc_pnl_long_L300](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L300) +```vyper + @external + @view + def calc_pnl_long(id: uint256, ctx: Ctx, remaining: uint256) -> PnL: + ... + # Accounting in quote, payout in base. +300 @audit=> payout : uint256 = self.MATH.quote_to_base(final, ctx) + ... +``` + +[gl-sherlock/contracts/positions.vy:calc_pnl_short_L340](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L340) +```vyper + + @external + @view + def calc_pnl_short(id: uint256, ctx: Ctx, remaining_as_base: uint256) -> PnL: + ... +340 @audit=> left : uint256 = self.MATH.quote_to_base(0 if loss > remaining else remaining - loss, ctx) + ... + +``` + +## Impact +- Miscalculations in position settings and profit/loss evaluations for long and short positions. +- Financial losses for the platform and customers due to incorrect volume conversions. + +## Code Snippet +[gl-sherlock/contracts/math.vy:quote_to_base_L84](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/math.vy#L84) +[gl-sherlock/contracts/math.vy:from_amount_L65](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/math.vy#L65) +[gl-sherlock/contracts/positions.vy:open_L122](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L122) +[gl-sherlock/contracts/positions.vy:calc_pnl_long_L300](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L300) +[gl-sherlock/contracts/positions.vy:calc_pnl_short_L340](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L340) + + +## Tool used + +Manual Review + +## Recommendation +The `quote_to_base` function should be corrected to pass the `quote` amount and the `base` price when calling the `from_amount` function, ensuring that the conversion is accurate. + diff --git a/045.md b/045.md new file mode 100644 index 0000000..ba123aa --- /dev/null +++ b/045.md @@ -0,0 +1,87 @@ +Rhythmic Iron Cat + +Medium + +# Data Type Mismatch in `position.vy` Contract Causes Errors in Position Status Updates + +## Summary +The `open` and `close` functions in `position.vy` incorrectly pass the `Status` enum type, instead of a specific variant, to update the `status` field in the `PositionState` struct. This causes a data type mismatch and prevents proper state updates. + +## Vulnerability Detail +The `open` and `close` functions in the `position.vy` contract pass `status.OPEN` and `status.CLOSED` as the status when updating `PositionState`. +[gl-sherlock/contracts/positions.vy:open_L130](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L130) +```vyper + @external + def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + self._INTERNAL() + + pos: PositionState = PositionState({ + id : self.next_position_id(), + pool : pool, + user : user, +130 @audit=> status : Status.OPEN, + +``` +[gl-sherlock/contracts/positions.vy:close_L376](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L376) +```vyper + @external + def close(id: uint256, ctx: Ctx) -> PositionValue: + ... + self.insert(PositionState({ + id : pos.id, + pool : pos.pool, + user : pos.user, +376@audit=> status : Status.CLOSED, +``` + +The issue is that, according to the `type.vy` contract, the `status` field in the `PositionState` struct should be of the `Status` enum variant type, not the enum type itself. The mismatch in data types leads to incorrect updates in the `PositionState`, as the `status` field cannot properly handle the wrong data type. +[gl-sherlock/contracts/positions.vy:PositionState_L142](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/types.vy#L142) +```vyper + # positions.vy + enum Status: + OPEN + CLOSED + LIQUIDATABLE + + struct PositionState: + id : uint256 + pool : uint256 + user : address +142 @audit=> status : Status + long : bool + collateral : uint256 + leverage : uint256 + interest : uint256 + entry_price: uint256 + exit_price : uint256 + opened_at : uint256 + closed_at : uint256 + + collateral_tagged: Tokens + interest_tagged : Tokens + +``` + + +## Impact +- Incorrect status updates in `PositionState` due to the data type mismatch. +- Potential errors in position tracking and management, affecting the contract's ability to accurately reflect position statuses. + +## Code Snippet +[gl-sherlock/contracts/positions.vy:open_L130](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L130) +[gl-sherlock/contracts/positions.vy:close_L376](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L376) +[gl-sherlock/contracts/positions.vy:PositionState_L142](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/types.vy#L142) + + +## Tool used + +Manual Review + +## Recommendation +The `PositionState` struct in the `type.vy` contract should be modified so that its `status` field accepts the specific variant type from the `Status` enum, rather than the `Status` enum type itself. This will ensure that the correct data type is passed when updating the status in the `position.vy` contract. diff --git a/047.md b/047.md new file mode 100644 index 0000000..5b49338 --- /dev/null +++ b/047.md @@ -0,0 +1,78 @@ +Basic Felt Canary + +Medium + +# User DOS after opening and closing 500 positions in the protocol + +medium + +# User DOS after opening and closing 500 positions in the protocol + +## Summary + +The amount of concurrent opened positions by a user is tracked and increased on each new opened position, but the same data is not properly reduced when the positions are closed by either normal close or liquidations. + +## Vulnerability Detail + +`Positions.vy` has a hard constant value of 500 to check for MAX_POSITIONS + +```vyper +MAX_POSITIONS : constant(uint256) = 500 +``` + +This value is checked on each new position created here +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L147 + +```vyper +@external +def open( + user : address, + pool : uint256, + long : bool, + collateral: uint256, + leverage : uint256, + ctx : Ctx) -> PositionState: + ... + assert Positions(self).get_nr_user_positions(user) <= MAX_POSITIONS + assert self.PARAMS.is_legal_position(ps, pos) + # position is inserted and the number tracking positions is increased for this user + self.insert_user_position(user, pos.id) + ... +``` + +In the equivalent close logic on `Positions.vy` the number of positions is not cleaned up and decreased accordingly + +## Impact + +This will lead to a user being unable to open more positions after reaching the max positions in the platform. This will lead to a DOS for any user just by normal usage of the platform. + +## Code Snippet + +Small test to reproduce the issue + +```python +def test_positions_no_not_updated(setup, core, open, close, long, positions, VEL, STX): + setup() + + tx = open(VEL, STX, True, d(10), 2, price=d(5), sender=long) + no_positions_before = positions.get_nr_user_positions(long) + print("number of positions opened after open: ", no_positions_before) + chain.mine(10_000) + + tx = close(VEL, STX, 1, price=d(5), sender=long) + assert not tx.failed + + no_positions_after = positions.get_nr_user_positions(long) + print("number of positions opened after close: ", no_positions_after) + # no of positions is not decreased after closing + assert no_positions_before == no_positions_after + +``` + +## Tool used + +Manual Review + +## Recommendation + +Add a cleanup logic to decrease the number of positions for the user after closing a position in the protocol. \ No newline at end of file diff --git a/048.md b/048.md new file mode 100644 index 0000000..41086b3 --- /dev/null +++ b/048.md @@ -0,0 +1,99 @@ +Basic Felt Canary + +High + +# Integration of BridgeUSDC may lead to some positions not able to be liquidated properly + +high + +# Integration of BridgeUSDC may lead to some positions not able to be liquidated properly + +## Summary + +Liquidations are an important part of the protocol invariants, it is crucial that they work correctly for all cases and in a timely manner to avoid bad debt. + +In the protocol there are both base and quote tokens, initially is intended to use a tokenized +BTC as the base token and a bridged USDT version as the quote token in the BOB network. + +The other major stablecoin in the BOB network for integration is USDC. + +USDC is a bridged token in the BOB network with growing supply, and as is common for the USDC implementation to contain blacklist functionality which can revert some transfers. + +Liquidations require transfers to work properly for sending remainder to the user and the liquidator, this can be a problem if the user opened one or more positions in a pool integrating USDC and later that user gets blacklisted in the USDC contract. + +The user positions will revert on liquidation unable to close the positions in a timely manner which could put the protocol at risk of accruing bad debt. + +## Vulnerability Detail + +These are the addresses for Bridged USDC in the BOB network + +Proxy +https://explorer.gobob.xyz/token/0xe75D0fB2C24A55cA1e3F96781a2bCC7bdba058F0?tab=contract +Implementation +https://explorer.gobob.xyz/address/0x27d58e4510a3963abc70bce554aeac60846998ab?tab=contract + +The transfer method in the USDC contract has a check for blacklisted users that can revert the transfer. + +```solidity +function transfer(address to, uint256 value) + external + override + whenNotPaused + notBlacklisted(msg.sender) + notBlacklisted(to) <-- This is the check that can revert the transfer + returns (bool) + { + _transfer(msg.sender, to, value); + return true; + } +``` + +In the liquidation logic as part of the accounting, the fees are sent to the liquidator and any remaining balance of both base and quote token is sent back to the owner of the position. + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L343 + +If the user is blacklisted in the USDC contract the transfer will revert and the liquidation will fail. + +## Impact + +Liquidations will fail to work properly and in a timely manner if a user with several open positions is later blacklisted in the USDC contract. + +This could lead to bad debt in the protocol and a risk of insolvency since the calculations for when a position is liquidatable are based on the current state of the position and price, if there is a big price fluctuation the position will fail liquidation until the position remaining for the user reaches 0, which could have consequences for the liquidation fee also being 0 and liquidators not being incentivized to liquidate the position. + +Furthermore, the position could reach such bad state that by the time the position is able to be liquidated the invariants check may fail further reverting the liquidation. + +## Code Snippet + +```vyper +@external +def liquidate( + id : uint256, + base_token : address, + quote_token: address, + position_id: uint256, + ctx : Ctx) -> PositionValue: + ... + + # important that all these transfers never revert + if base_amt_final.fee > 0: + assert ERC20(base_token).transfer(user, base_amt_final.fee, default_return_value=True), "ERR_ERC20" + if quote_amt_final.fee > 0: + assert ERC20(quote_token).transfer(user, quote_amt_final.fee, default_return_value=True), "ERR_ERC20" + if base_amt_final.remaining > 0: + assert ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True), "ERR_ERC20" + if quote_amt_final.remaining > 0: + assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" <-- important that these transfers never revert + + self.INVARIANTS(id, base_token, quote_token) + + log Liquidate(user, ctx, pool, value) + return value +``` + +## Tool used + +Manual Review + +## Recommendation + +Consider carefully which tokens to integrate on POOLS specifically on tokens that can be upgraded or blacklisted that could affect the liquidation process. \ No newline at end of file diff --git a/049.md b/049.md new file mode 100644 index 0000000..442ed62 --- /dev/null +++ b/049.md @@ -0,0 +1,129 @@ +Basic Felt Canary + +Medium + +# Missing critical input validations when setting protocol parameters + +medium + +# Missing critical input validations when setting protocol parameters + +## Summary + +The `Params.vy` contract stores the protocol parameters that are used in critical checks like minimum or maximum collateral +for positions, minimum and maximum leverage, liquidation threshold to trigger liquidations, etc. + +All of these are set and updated via a Params struct which in Vyper is basically a tuple of 13 uint256 values, the order and positioning of these values is critical. + +All of these are set and updated atomically with a single method by the owner of the contract, the method has no input validations for any of the arguments. Furthermore, when updating one of these critical values it is required that all other values are sent as well to be overwritten or preserve. + +An incorrect update or bad positioning of a single of these values could lead to the protocol to stop working as expected or trigger liquidations incorrectly, losing funds for users in the process. + +## Vulnerability Detail + +The definition of the Parameters struct is found here + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/types.vy#L49 + +```vyper +# params.vy +struct Parameters: + # Fees are stored as a numerator, with the denominator defined in math.vy. + # + # Min fee example for 5 second blocks: + # 1 year = 6324480 blocks + # 10% / year ~ 0.000_0016% / block + # in math.vy representation: 16 + # + # Max fee example for 5 second blocks: + # 8h = 5760 blocks + # 4% / 8h ~ 0.000_7% / block + # in math.vy representation: 7_000 + # + MIN_FEE : uint256 + MAX_FEE : uint256 + + # Fraction of collateral (e.g. 1000). + PROTOCOL_FEE : uint256 + + # Fraction of remaining collateral (e.g. 2) + LIQUIDATION_FEE : uint256 + + # Depend on coin decimals. + MIN_LONG_COLLATERAL : uint256 + MAX_LONG_COLLATERAL : uint256 + MIN_SHORT_COLLATERAL : uint256 + MAX_SHORT_COLLATERAL : uint256 + + # E.g. 1 and 10. + MIN_LONG_LEVERAGE : uint256 + MAX_LONG_LEVERAGE : uint256 + MIN_SHORT_LEVERAGE : uint256 + MAX_SHORT_LEVERAGE : uint256 + + # C.f. is_liquidatable, e.g. 1. + LIQUIDATION_THRESHOLD : uint256 +``` + +The comments indicate what are reasonable values for these parameters, but the contract does not enforce these values. + +The method to update these parameters is found here +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L25 + +```vyper +@external +def set_params(new_params: Parameters): + assert msg.sender == self.DEPLOYER, ERR_PERMISSIONS + self.PARAMS = new_params +``` + +The method does not check if the new_params are within reasonable ranges or values that are immediately harmful to the operation of the protocol. + +For example `MIN_LONG_COLLATERAL` is not checked that is higher than 0 or lower than `MAX_LONG_COLLATERAL`. + +This is also the case for all the other MIN and MAX value pairs that need a relationship between them to be lower and higher respectively. + +This is important because the params are used in every single operation for opening, closing and liquidating positions. + +The other critical parameter is `LIQUIDATION_THRESHOLD` which is used to trigger liquidations, if this value is set too high, liquidations will be triggered too soon and users will lose funds. This can happen immediately after a parameters update, and all healthy positions will become liquidated assuming bot automation for liquidations. + +Furthermore, the `LIQUIDATION_THRESHOLD` can be set to 0 which means that no liquidations will ever be triggered because of the way it impacts this code check: + +```vyper +@external +@view +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + """ + A position becomes liquidatable when its current value is less than + a configurable fraction of the initial collateral, scaled by + leverage. + """ + # Assume liquidation bots are able to check and liquidate positions + # every N seconds. + # We would like to avoid the situation where a position's value goes + # negative (due to price fluctuations and fee obligations) during + # this period. + # Roughly, the most a positions value can change is + # leverage * asset price variance + fees + # If N is small, this expression will be ~the price variance. + # E.g. if N = 60 and we expect a maximal price movement of 1%/minute + # we could set LIQUIDATION_THRESHOLD to 1 (* some constant to be on the + # safe side). + percent : uint256 = self.PARAMS.LIQUIDATION_THRESHOLD * position.leverage <-- this should never be 0 or a very high value + required: uint256 = (position.collateral * percent) / 100 + return not (pnl.remaining > required) +``` + +## Impact + +The likelihood of this issue is not high because it requires the owner of the contract to incorrectly set these values, but the impact is critical because it can lead to the protocol to stop working as expected or trigger liquidations incorrectly, losing funds for users in the process. + +The fact that the method to update these parameters is coupled for all critical values and that updating one value requires to update all of them, plus the lack of input validations for these values compounds the risk of this issue. + +## Tool used + +Manual Review + +## Recommendation + +Add granular methods to set individual critical parameters with input validations for the values that are set, and add a check that the values are within reasonable ranges and correct relationships between them are maintained. diff --git a/052.md b/052.md new file mode 100644 index 0000000..6391224 --- /dev/null +++ b/052.md @@ -0,0 +1,232 @@ +Brilliant Burlap Elephant + +Medium + +# LPs will withdraw more value than deposited during pegged token de-peg events + +### Summary +The [`CONTEXT` function in `gl-sherlock/contracts/api.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/api.vy#L52-L71) uses the `/USD` price for valuation, assuming a 1:1 peg between the quote token and USD. This assumption can fail during de-peg events, leading to incorrect valuations and potential exploitation. + +### Root Cause +The `CONTEXT` function calls the `price` function from the `oracle` contract to get the price of the quote token. This price is adjusted based on the `quote_decimals`, implying it is using the `/USD` price for valuation. + +#### Detailed Breakdown + +1. **`CONTEXT` Function in `api.vy`**: + The `CONTEXT` function calls the [`price` function from the `oracle` contract](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L57-L77) to get the price of the quote token. + +```python +def CONTEXT( + base_token : address, + quote_token: address, + desired : uint256, + slippage : uint256, + payload : Bytes[224] +) -> Ctx: + base_decimals : uint256 = convert(ERC20Plus(base_token).decimals(), uint256) + quote_decimals: uint256 = convert(ERC20Plus(quote_token).decimals(), uint256) + # this will revert on error + price : uint256 = self.ORACLE.price(quote_decimals, + desired, + slippage, + payload) + return Ctx({ + price : price, + base_decimals : base_decimals, + quote_decimals: quote_decimals, + }) +``` + + +2. **`price` Function in `oracle.vy`**: + The `price` function in [`oracle.vy` uses the `extract_price` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L83-L102) to get the price from the oracle. + +```python + +######################################################################## +TIMESTAMP: public(uint256) + +@internal +def extract_price( + quote_decimals: uint256, + payload : Bytes[224] +) -> uint256: + price: uint256 = 0 + ts : uint256 = 0 + (price, ts) = self.EXTRACTOR.extractPrice(self.FEED_ID, payload) + + # Redstone allows prices ~10 seconds old, discourage replay attacks + assert ts >= self.TIMESTAMP, "ERR_ORACLE" + self.TIMESTAMP = ts + + # price is quote per unit base, convert to same precision as quote + pd : uint256 = self.DECIMALS + qd : uint256 = quote_decimals + s : bool = pd >= qd + n : uint256 = pd - qd if s else qd - pd + m : uint256 = 10 ** n + p : uint256 = price / m if s else price * m + return p + +######################################################################## +PRICES: HashMap[uint256, uint256] + +@internal +def get_or_set_block_price(current: uint256) -> uint256: + """ + The first transaction in each block will set the price for that block. + """ + block_price: uint256 = self.PRICES[block.number] + if block_price == 0: + self.PRICES[block.number] = current + return current + else: + return block_price + +######################################################################## +@internal +@pure +def check_slippage(current: uint256, desired: uint256, slippage: uint256) -> bool: + if current > desired: return (current - desired) <= slippage + else : return (desired - current) <= slippage + +@internal +@pure +def check_price(price: uint256) -> bool: + return price > 0 + +# eof + +``` + + +3. **`extract_price` Function in `oracle.vy`**: + The `extract_price` function adjusts the price based on the `quote_decimals`, which implies it is using the `/USD` price for valuation. + +```83:102:gl-sherlock/contracts/oracle.vy +def extract_price( + quote_decimals: uint256, + payload : Bytes[224] +) -> uint256: + price: uint256 = 0 + ts : uint256 = 0 + (price, ts) = self.EXTRACTOR.extractPrice(self.FEED_ID, payload) + + # Redstone allows prices ~10 seconds old, discourage replay attacks + assert ts >= self.TIMESTAMP, "ERR_ORACLE" + self.TIMESTAMP = ts + + # price is quote per unit base, convert to same precision as quote + pd : uint256 = self.DECIMALS + qd : uint256 = quote_decimals + s : bool = pd >= qd + n : uint256 = pd - qd if s else qd - pd + m : uint256 = 10 ** n + p : uint256 = price / m if s else price * m + return p +``` + + +### Impact +During a de-peg event, LPs can withdraw more value than they deposited, causing significant losses to the protocol. + +### Attack Path +1. **Deposit**: Attacker deposits 1 BTC and 50,000 USDT when 1 BTC = 50,000 USD. +2. **De-peg Event**: The pegged token (USDT) de-pegs to 0.70 USD. +3. **Withdraw**: Attacker withdraws their funds, exploiting the incorrect assumption that 1 USDT = 1 USD. + +### Proof of Concept (PoC) +1. **Deposit**: + +```python +@external +def mint( + base_token : address, #ERC20 + quote_token : address, #ERC20 + lp_token : address, #ERC20Plus + base_amt : uint256, + quote_amt : uint256, + desired : uint256, + slippage : uint256, + payload : Bytes[224] +) -> uint256: + """ + @notice Provide liquidity to the pool + @param base_token Token representing the base coin of the pool (e.g. BTC) + @param quote_token Token representing the quote coin of the pool (e.g. USDT) + @param lp_token Token representing shares of the pool's liquidity + @param base_amt Number of base tokens to provide + @param quote_amt Number of quote tokens to provide + @param desired Price to provide liquidity at (unit price using onchain + representation for quote_token, e.g. 1.50$ would be + 1500000 for USDT with 6 decimals) + @param slippage Acceptable deviaton of oracle price from desired price + (same units as desired e.g. to allow 5 cents of slippage, + send 50000). + @param payload Signed Redstone oracle payload + """ + ctx: Ctx = self.CONTEXT(base_token, quote_token, desired, slippage, payload) + return self.CORE.mint(1, base_token, quote_token, lp_token, base_amt, quote_amt, ctx) +``` + + +2. **De-peg Event**: The pegged token de-pegs to 0.70 USD (external event). + +3. **Withdraw**: + +```python +def burn( + base_token : address, + quote_token : address, + lp_token : address, + lp_amt : uint256, + desired : uint256, + slippage : uint256, + payload : Bytes[224] +) -> Tokens: + """ + @notice Withdraw liquidity from the pool + @param base_token Token representing the base coin of the pool (e.g. BTC) + @param quote_token Token representing the quote coin of the pool (e.g. USDT) + @param lp_token Token representing shares of the pool's liquidity + @param lp_amt Number of LP tokens to burn + @param desired Price to provide liquidity at (unit price using onchain + representation for quote_token, e.g. 1.50$ would be + 1500000 for USDT with 6 decimals) + @param slippage Acceptable deviaton of oracle price from desired price + (same units as desired e.g. to allow 5 cents of slippage, + send 50000). + @param payload Signed Redstone oracle payload + """ + ctx: Ctx = self.CONTEXT(base_token, quote_token, desired, slippage, payload) + return self.CORE.burn(1, base_token, quote_token, lp_token, lp_amt, ctx) +``` + + +4. **Incorrect Price Calculation**: + +```python +def CONTEXT( + base_token : address, + quote_token: address, + desired : uint256, + slippage : uint256, + payload : Bytes[224] +) -> Ctx: + base_decimals : uint256 = convert(ERC20Plus(base_token).decimals(), uint256) + quote_decimals: uint256 = convert(ERC20Plus(quote_token).decimals(), uint256) + # this will revert on error + price : uint256 = self.ORACLE.price(quote_decimals, + desired, + slippage, + payload) + return Ctx({ + price : price, + base_decimals : base_decimals, + quote_decimals: quote_decimals, + }) +``` + + +### Mitigation +To mitigate this issue, the protocol should use the `/` price directly if available, or derive it from the `/USD` and `/USD` prices. This ensures accurate valuations even if the quote token de-pegs from USD. diff --git a/053.md b/053.md new file mode 100644 index 0000000..573c9c9 --- /dev/null +++ b/053.md @@ -0,0 +1,58 @@ +Brilliant Burlap Elephant + +Medium + +# Attacker will receive incorrect token distribution causing loss to liquidity providers + +### Summary + +A negative value calculation in the [`balanced` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L127-L158) will cause incorrect token distribution for liquidity providers as an attacker can manipulate the `burn_value` to be close to `state.base_excess_as_quote` or `state.quote_excess_as_quote`. + + +### Root Cause + +In [`gl-sherlock/contracts/math.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L127-L158) the calculation of `left` can result in a negative value, which is then used in further calculations leading to incorrect token distribution. + +- In `gl-sherlock/contracts/math.vy`: + - [`balanced` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L143-L144) : `left` is calculated as `burn_value - state.base_excess_as_quote` or `burn_value - quote1`, which can be negative. + + +### Internal pre-conditions + +1. The `burn_value` needs to be close to `state.base_excess_as_quote` or `state.quote_excess_as_quote`. +2. The pool needs to be in an imbalanced state (either `have_more_base` is true or false). + + +### External pre-conditions + +None. + +### Attack Path + +1. Attacker calls [`burn` function in `core.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L191-L226) with a carefully chosen `lp_amt` that results in a `burn_value` close to `state.base_excess_as_quote` or `state.quote_excess_as_quote`. +2. The [`calc_burn` function in `pools.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L211-L224) calls the `balanced` function in `math.vy`. +3. The `balanced` function calculates a negative value for `left`. +4. The negative `left` value is used in further calculations, resulting in incorrect token distribution. + + +### Impact + +The liquidity providers suffer an approximate loss proportional to the difference between the correct token distribution and the incorrect one returned by the `balanced` function. This could range from minimal to significant depending on the pool's state and the burn amount. + +### PoC + +1. Consider a scenario where `state.base_excess_as_quote = 100` and `burn_value = 99`. +2. In the `balanced` function: + ```python + left : uint256 = burn_value - state.base_excess_as_quote + left = 99 - 100 = -1 + ``` +3. This negative value is then used in further calculations: + ```python + quote: uint256 = left / 2 + ``` +4. Due to the use of `uint256`, this will result in an extremely large value for `quote`, leading to incorrect token distribution. + +### Mitigation + +To fix this issue, ensure that `left` is never negative \ No newline at end of file diff --git a/054.md b/054.md new file mode 100644 index 0000000..85f7f09 --- /dev/null +++ b/054.md @@ -0,0 +1,97 @@ +Brilliant Burlap Elephant + +Medium + +# Liquidator will receive excessive fees at the expense of the fee receiver + +### Summary + +The liquidation function fails to distribute fees to the fee receiver, causing a loss of protocol fees as the liquidator will receive the entire liquidation fee. + + +### Root Cause + +In [`gl-sherlock/contracts/core.vy:308-349`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L307-L349) the `liquidate` function transfers the entire liquidation fee to the liquidator without allocating a portion to the fee receiver. + + +### Internal pre-conditions + +1. A position needs to become liquidatable. +2. The `COLLECTOR` address must be set to a non-zero address. + +### External pre-conditions + +None. + +### Attack Path + +1. A position becomes liquidatable due to market conditions. +2. Liquidator calls the `liquidate` function. +3. The function calculates the liquidation fees using `self.PARAMS.liquidation_fees()`. +4. The entire fee is transferred to the liquidator without allocating a portion to the fee receiver ( [COLLECTOR](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L82)) . + + +### Impact + +The fee receiver (protocol/COLLECTOR) suffers a loss of the protocol's share of liquidation fees. The liquidator gains this additional amount that should have gone to the protocol. + + +### PoC + +1. A position becomes liquidatable. +2. Liquidator calls the `liquidate` function: + + +```python +def liquidate( + id : uint256, + base_token : address, + quote_token: address, + position_id: uint256, + ctx : Ctx) -> PositionValue: + position_id: uint256, + self._INTERNAL() + + # identical to close() + user : address = tx.origin #liquidator + pool : PoolState = self.POOLS.lookup(id) + position: PositionState = self.POSITIONS.lookup(position_id) + pool : PoolState = self.POOLS.lookup(id) + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token , ERR_PRECONDITIONS + assert id == position.pool , ERR_PRECONDITIONS + assert self.POSITIONS.is_liquidatable(position_id, ctx), ERR_PRECONDITIONS + assert id == position.pool , ERR_PRECONDITIONS + value : PositionValue = self.POSITIONS.close(position_id, ctx) + base_amt : uint256 = self.MATH.eval(0, value.deltas.base_transfer) + quote_amt: uint256 = self.MATH.eval(0, value.deltas.quote_transfer) + self.POOLS.close(id, value.deltas) + self.FEES.update(id) + self.POOLS.close(id, value.deltas) + base_amt_final : Fee = self.PARAMS.liquidation_fees(base_amt) + quote_amt_final: Fee = self.PARAMS.liquidation_fees(quote_amt) + base_amt_final : Fee = self.PARAMS.liquidation_fees(base_amt) + # liquidator gets liquidation fee, user gets whatever is left + if base_amt_final.fee > 0: + assert ERC20(base_token).transfer(user, base_amt_final.fee, default_return_value=True), "ERR_ERC20" + if quote_amt_final.fee > 0: + assert ERC20(quote_token).transfer(user, quote_amt_final.fee, default_return_value=True), "ERR_ERC20" + if base_amt_final.remaining > 0: + assert ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True), "ERR_ERC20" + if quote_amt_final.remaining > 0: + assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" + if quote_amt_final.remaining > 0: + self.INVARIANTS(id, base_token, quote_token) + + log Liquidate(user, ctx, pool, value) + return value +``` + + +3. The entire `base_amt_final.fee` and `quote_amt_final.fee` are transferred to the liquidator (user). +4. The fee receiver (`self.COLLECTOR`) does not receive any portion of the liquidation fees. + + +### Mitigation + +To fix this issue, It's needed to split the liquidation fee between the liquidator and the fee receiver/ OPERATOR. \ No newline at end of file diff --git a/056.md b/056.md new file mode 100644 index 0000000..31a9ed0 --- /dev/null +++ b/056.md @@ -0,0 +1,190 @@ +Basic Felt Canary + +High + +# Unbalanced deposits with more base tokens than quote tokens can be back-run at the expense of LPs + +high + +# unbalanced deposits with more base tokens than quote tokens can be back-run at the expense of LPs + +## Summary + +The protocol allows for deposits of base tokens and quote tokens with any ratio of base tokens to quote tokens. If an LP deposits a larger amount of base tokens than quote tokens, the way the system calculates the amounts of tokens to return at burn allows for a bot to deposit a large amount of quote tokens to back-run the unbalanced deposit and swap base tokens with zero fees and zero slippage at the expense of LPs. + +The bot just needs another market to profit from a no fee/zero slippage opportunity for an arbitrage where they can sell the acquired base tokens at a profit. + +This can be done by anybody monitoring for unbalanced deposits, price differences in this system versus other markets to profit from this opportunity. + +## Vulnerability Detail + +When a user burns LP tokens the function `calc_burn` in the `Pools.vy` contract calculates the ratio of base to quote tokens to return to LP based on the state of the pools. + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/pools.vy#L219 + +Within this function, the `balanced` function in the `Math.vy` contract calculates the amount of base and quote tokens to return to the LP based on the ratio of base tokens to quote tokens in the pool. + +This function tries to keep the pools as balanced as possible based on the current state, but after +an unbalanced deposit of more base tokens than quote tokens, the function will skew the amount of base tokens to return to the LP to be higher than the amount of quote tokens to return to the LP. + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/math.vy#L140 + +```vyper +@external +@view +def balanced(state: Value, burn_value: uint256, ctx: Ctx) -> Tokens: + """ + Given the current state of the pool reserves, returns a mix of tokens + of total value burn_value which improves pool balance (we consider a + pool balanced when te value of base reserves equals quote reserves). + Note that if we have an imbalanced pool (which is not necessarily + a bad thing), this means that LPs mostly get back the tokens they + put in. + The preconditions (burn_value <= reserves) for this must be + checked at the call site! + """ + if state.have_more_base: + if state.base_excess_as_quote >= burn_value: + ##### this line here returns all excess base tokens and 0 quote tokens allowing the swap + return Tokens({base: Math(self).quote_to_base(burn_value, ctx), quote: 0}) + else: + base1: uint256 = state.base_excess_as_base + left : uint256 = burn_value - state.base_excess_as_quote + quote: uint256 = left / 2 + base2: uint256 = Math(self).quote_to_base(quote, ctx) + base : uint256 = base1 + base2 + return Tokens({base: base, quote: quote}) + else: + if state.quote_excess_as_quote >= burn_value: + return Tokens({base: 0, quote: burn_value}) + else: + quote1: uint256 = state.quote_excess_as_quote + left : uint256 = burn_value - quote1 + quote2: uint256 = left / 2 + base : uint256 = Math(self).quote_to_base(quote2, ctx) + quote : uint256 = quote1 + quote2 + return Tokens({base: base, quote: quote}) +``` + +This calculation enables the bot to deposit a large amount of quote tokens and burn the LP after to essentially receive all the "excess" base tokens in the pool at the expense of LPs who deposited more base tokens than quote tokens essentially making a zero fee, zero slippage swap of base tokens for quote tokens. + +## Impact + +In this scenario the LP that makes an unbalanced deposit with more base tokens than quote tokens is unknowingly selling their base tokens for quote tokens at the spot price which may not be favorable to them in several cases, for example if the price of base token is experience volatility. + +This could lead them to experience a higher level of impermanent loss than they would have if they had deposited an adjusted ratio of base tokens to quote tokens. + +## Proof of Concept + +The following test added to `test_core3.py` showcases the scenario, VEL is the base token and STX is the quote token. Assuming VEL is higher priced 10x than STX. + +```python +def test_backrun_swap_base_tokens( + setup, + core, + mint, + burn, + swapbot, # add this account to conftest and mint and approve 1_000_000 STX for swapbot + lp_provider, + lp_provider2, + lp_provider3, + VEL, + STX, + LP, +): + setup() + # assumes no opened positions for simplicity + # this can be pulled off with any unlocked reserves right after unbalanced deposits + print("\nstarting balances of swapbot for VEL STX tokens") + # swapbot starts with 1_000_000 STX and 0 VEL + print("balance of lp_provider VEL", VEL.balanceOf(swapbot)) + print("balance of lp provider STX", STX.balanceOf(swapbot)) + + print("\nbalances of lp_provider1 to start for VEL STX tokens") + print("balance of lp_provider1 VEL", VEL.balanceOf(lp_provider)) + print("balance of lp_provider1 STX", STX.balanceOf(lp_provider)) + + print("\nbalances of lp_provider2 to start for VEL STX tokens") + print("balance of lp_provider2 VEL", VEL.balanceOf(lp_provider2)) + print("balance of lp_provider2 STX", STX.balanceOf(lp_provider2)) + + print("\nbalances of lp_provider3 to start for VEL STX tokens") + print("balance of lp_provider3 VEL", VEL.balanceOf(lp_provider3)) + print("balance of lp provider3 STX", STX.balanceOf(lp_provider3)) + + # Mint for lp_provider1 + tx = mint(VEL, STX, LP, d(10_000), d(100_000), price=d(10), sender=lp_provider) + logs = core.Mint.from_receipt(tx) + amt = logs[0].lp_amt + logger.info(amt) + + # Mint for lp_provider2 + tx = mint(VEL, STX, LP, d(10_000), d(100_000), price=d(10), sender=lp_provider2) + logs = core.Mint.from_receipt(tx) + amt = logs[0].lp_amt + logger.info(amt) + + # Mint for lp_provider3 using imbalance deposit to create opportunity for swapbot + tx = mint(VEL, STX, LP, d(100_000), d(100_000), price=d(10), sender=lp_provider3) + logs = core.Mint.from_receipt(tx) + amt = logs[0].lp_amt + logger.info(amt) + + # swapbot starts mint and burn after the unbalanced deposit with only quote tokens + tx = mint(VEL, STX, LP, d(0), d(1_000_000), price=d(10), sender=swapbot) + logs = core.Mint.from_receipt(tx) + amt = logs[0].lp_amt + logger.info(amt) + print("\ncurrent balances after mint swapbot") + print("balance of swapbot VEL mint", VEL.balanceOf(swapbot)) + print("balance of swapbot STX mint", STX.balanceOf(swapbot)) + + print("balance of core VEL mint", VEL.balanceOf(core)) + print("balance of core STX mint", STX.balanceOf(core)) + + lp_balance = LP.balanceOf(swapbot) + tx = burn(VEL, STX, LP, lp_balance, price=d(10), sender=swapbot) + assert not tx.failed, "swapbot burn" + logger.info(tx.decode_logs(core.Burn)) + print("\nfinal balances after burn swapbot") + print("balance of swapbot VEL burn", VEL.balanceOf(swapbot)) + print("balance of lp swapbot STX burn", STX.balanceOf(swapbot)) + # final balances after burn swapbot + # balance of swapbot VEL = 45000000000 + # balance of lp swapbot STX = 550000000000 + + lp_balance = LP.balanceOf(lp_provider3) + tx = burn(VEL, STX, LP, lp_balance, price=d(10), sender=lp_provider3) + assert not tx.failed, "lp_provider3 burn" + logger.info(tx.decode_logs(core.Burn)) + print("\nfinal balances of lp_provider3") + print("balance of lp_provider3 VEL after", VEL.balanceOf(lp_provider3)) + print("balance of lp_provider3 STX after", STX.balanceOf(lp_provider3)) + # balance of lp_provider3 VEL after 55000000000 receives less VEL for equal STX + # balance of lp_provider3 STX after 550000000000 + + ## every other provider receives their exact ratio of deposit back unaffected + lp_balance = LP.balanceOf(lp_provider) + tx = burn(VEL, STX, LP, lp_balance, price=d(10), sender=lp_provider) + assert not tx.failed, "lp_provider burn" + logger.info(tx.decode_logs(core.Burn)) + print("\nfinal balances of lp_provider") + print("balance of lp_provider VEL after", VEL.balanceOf(lp_provider)) + print("balance of lp_provider STX after", STX.balanceOf(lp_provider)) + + lp_balance = LP.balanceOf(lp_provider2) + tx = burn(VEL, STX, LP, lp_balance, price=d(10), sender=lp_provider2) + assert not tx.failed, "lpprovider2 burn" + logger.info(tx.decode_logs(core.Burn)) + print("\nfinal balances of lp_provider2") + print("balance of lp_provider2 VEL after", VEL.balanceOf(lp_provider2)) + print("balance of lp_provider2 STX after", STX.balanceOf(lp_provider2)) +``` + +## Tool used + +Manual Review and Testing + +## Recommendation + +When LP is attempting to deposit a highly unbalanced amount of base tokens to quote tokens, the system may used a similar logic to the `balanced` function in the `Math.vy` contract to calculate the amount of base and quote tokens to return to the LP based on the ratio of base tokens to quote tokens in the pool to keep the pools as balanced as possible based on the current state. diff --git a/058.md b/058.md new file mode 100644 index 0000000..989c346 --- /dev/null +++ b/058.md @@ -0,0 +1,36 @@ +Happy Cinnamon Cheetah + +Medium + +# A healthy position will be liquidate able. + +## Summary +1. The check for the equality does not pertain. +2. There should be a correct check for the equality. + +## Vulnerability Detail +1. `is_liquidatable` function will return 1 if `not pnl.remaining > required` and will not properly balance for whether `pnl.remaining == required`. + +## Impact +1. When `pnl.remaining == required`, a healthy position can be liquidated. + +## Code Snippet +1. [poc-liquidate](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L308) + +## Tool used + +Manual Review + +## Recommendation + +1. Faulty verification + +```vyper +return not (pnl.remaining > required) +``` + +2. Should be + +```vyper +return not (pnl.remaining >= required) # or return pnl.remaining < required +``` \ No newline at end of file diff --git a/059.md b/059.md new file mode 100644 index 0000000..cff2b5d --- /dev/null +++ b/059.md @@ -0,0 +1,133 @@ +Damaged Fern Bird + +High + +# params.vy: dynamic_fees miscalculates by returning a percentage instead of a fee amount + +### Summary + +The function [`dynamic_fees`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L33-L55) only calculates the percentage fee, but the fees.vy contract interprets this value as the actual fee amount. + +### Root Cause + +The [`dynamic_fees`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L33-L55) function calculates the fee by first calculating the ratio of tokens in used and total tokens: +```vyper +# long_utilization = (pool.base_interest/ (pool.base_reserves/ 100)) +long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) +``` + +It than scales is based on the parameter `MAX_FEE`. So the value of `borrowing_long` is a value between 100 and 10. This is based on the sponsors suggested starting parameters (`MIN_FEE = 10`, `MAX_FEE = 100`): +```vyper +# borrowing_long = (self.PARAMS.MAX_FEE * long_utilization) / 100 +# afterwards this is checked to be between MIN_FEE(10) and MAX_FEE(100) +borrowing_long : uint256 = self.check_fee(self.scale(self.PARAMS.MAX_FEE, long_utilization)) +``` + + +### Internal pre-conditions + +N/A + +### External pre-conditions + +N/A + +### Attack Path + +N/A + +### Impact + +Users pay significantly lower fees than expected, resulting in liquidity providers earning far less than intended. + +### PoC + +In this example, the pool contains 100,000 base tokens, and 50,000 tokens are being utilized. `MAX_FEE` is set to 100, as suggested by the sponsor for the launch parameters. We first scale the token amounts to 18 decimals: +- `pool.base_reserves` = 100,000 * 10**18 +- `pool.base_interest` = 50,000 * 10**18 +- `self.PARAMS.MAX_FEE` = 100 + +Lets walk through the `dynamic_fees` calculation: +```vyper +def dynamic_fees(pool: PoolState) -> DynFees: + [ ... ] + # long_utilization = (50,000 * 10**18) / ((100,000 * 10**18) / 100) = 50 + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + [ ... ] + # self.scale = (100 * 50) / 100 = 50 + # self.check_fee(50) = 50 + borrowing_long : uint256 = self.check_fee(self.scale(self.PARAMS.MAX_FEE, long_utilization)) +``` + +In this case the `borrowing_long` fee would be returned as 50. Given the size of the loan, this amount is too low to be reasonable. The result must be scaled to reflect the actual amount of tokens being utilized (`pool.base_interest`), as the value of 50 is only a relative figure scaled to 2 decimal places. + +The correct fee should be calculated by multiplying the result (50) by the number of utilized base tokens: +```text + (50,000 * 10**18) / 50 = 1,000 * 10 ** 18 +``` + +Since this represents the fee for an entire year, we must divide the result by the total number of blocks in a year. The BOB chain has a block time of 2 seconds, meaning there are: +```text + (365 * 24 * 60 * 60) / 2 = 15,768,000 +``` + +Thus, the result should be divided as follows: +```text + (1,000 * 10 ** 18) / 15,768,000= 1,000 * 10 ** 18 = 63,419,583,967,529 +``` + +The correct calculation would return 63,419,583,967,529 as the fee for this example, ensuring the fees are appropriately scaled for the actual amount of utilized tokens and distributed fairly across blocks. + + + + + +### Mitigation + +The contract should convert the percentage-based fee into an actual token amount to align with the expected behavior inside the `fees.vy` contract. This ensures that the fee is accurately calculated based on the number of tokens utilized. Below is an example of the necessary code change (diff): + +```diff +--- a/params.vy.orig ++++ b/params.vy +@@ -39,14 +39,20 @@ def dynamic_fees(pool: PoolState) -> DynFees: + """ + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) +- borrowing_long : uint256 = self.check_fee( ++ borrowing_long_fee : uint256 = self.check_fee( + self.scale(self.PARAMS.MAX_FEE, long_utilization)) +- borrowing_short : uint256 = self.check_fee( ++ borrowing_short_fee : uint256 = self.check_fee( + self.scale(self.PARAMS.MAX_FEE, short_utilization)) +- funding_long : uint256 = self.funding_fee( ++ funding_long_fee : uint256 = self.funding_fee( + borrowing_long, long_utilization, short_utilization) +- funding_short : uint256 = self.funding_fee( ++ funding_short_fee : uint256 = self.funding_fee( + borrowing_short, short_utilization, long_utilization) ++ ++ borrowing_long : uint256 = self.convert_fee_to_amount(pool.base_interest, borrowing_long_fee) ++ borrowing_short : uint256 = self.convert_fee_to_amount(pool.quote_interest, borrowing_short_fee) ++ funding_long : uint256 = self.convert_fee_to_amount(pool.base_interest, funding_long_fee) ++ funding_short : uint256 = self.convert_fee_to_amount(pool.quote_interest, funding_short_fee) ++ + return DynFees({ + borrowing_long : borrowing_long, + borrowing_short: borrowing_short, +@@ -54,6 +60,11 @@ def dynamic_fees(pool: PoolState) -> DynFees: + funding_short : funding_short, + }) + ++@internal ++@pure ++def convert_fee_to_amount(total_amount: uint256, percentage: uint256) -> uint256: ++ return ((total_amount * 100) / (percentage * 15_768_000)) + 1 ++ + @internal + @pure + def utilization(reserves: uint256, interest: uint256) -> uint256: +``` + +Notice: This change adds 1 at the end of the conversion to prevent edge cases where the calculated fee is zero, which would prevent the position from being closed. + +It is also advisable to replace the existing precision magic number (`100`) with a named constant for better readability and maintainability. Furthermore, updating the precision to support more decimals (e.g., using `1e18`, which is common for high-precision calculations in Ethereum-based contracts) would help to return a more accurate result. diff --git a/060.md b/060.md new file mode 100644 index 0000000..1c455be --- /dev/null +++ b/060.md @@ -0,0 +1,28 @@ +Proper Vermilion Mule + +Medium + +# Actual funding can be lower than intended due to precision loss in `utilization` + +### Summary + +Because `params#utilization` is not multiplied by a precision factor, computed utilization can be up to 100% lower than the actual one (i.e. 0 instead of ~1), which in turn would make funding fees up to 100% lower than intended. + +### Root Cause + +Absense of precision factor in `params#utilization` (and absence of division by the same factor in `params#scale`). + +### Impact + +Shorts/longs receive up to 100% less funding fees from longs/shorts than they should. + +### PoC + +base_reserves = 101e18 +base_interest = 1e18 +utilization = 1e18 / (101e18 / 100) = 1e18 / 1.01e18 = 0 + +### Mitigation + +`utilization` should be multiplied by 1e18 (or another suitable precision factor). +`scale` should be divided by the same value. \ No newline at end of file diff --git a/062.md b/062.md new file mode 100644 index 0000000..0257b2f --- /dev/null +++ b/062.md @@ -0,0 +1,32 @@ +Joyful Punch Fly + +Medium + +# eval1 can revert + +## Summary +there is no check on eval1 whether arg is zero ao not. +## Vulnerability Detail +def eval1(n: uint256, instr: Instr) -> uint256: + op : OP = instr.op + arg: uint256 = instr.arg + res: uint256 = 0 + if op == OP.ADD_ : res = n + arg + elif op == OP.SUB_ : res = n - arg + elif op == OP.MUL_ : res = n * arg +@>> elif op == OP.DIV_ : res = n / arg + else : raise "unknown_op" + return res + +## Impact +if arg ==0 then we will get revert. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L199 + +## Tool used + +Manual Review + +## Recommendation + if arg == 0: + raise ZeroDivisionError("Division by zero") \ No newline at end of file diff --git a/063.md b/063.md new file mode 100644 index 0000000..5f18fda --- /dev/null +++ b/063.md @@ -0,0 +1,124 @@ +Brilliant Burlap Elephant + +Medium + +# Lack of Enforcement of Token-Specific Deviation Thresholds in Slippage Check + +### Summary + +The current implementation of the `price` function in the oracle contract allows user-provided slippage values without enforcing token-specific deviation thresholds. This can lead to incorrect pricing in various operations, potentially causing financial losses for users or the protocol itself. + + +### Root Cause + +In [`gl-sherlock/contracts/oracle.vy`, the `check_slippage` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L120-L124) uses the `slippage` value provided by the user when calling the [`price` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L57-L77) through other function. The protocol does not enforce token-specific deviation thresholds, which can lead to issues: + +```python +File: oracle.vy +120: @internal +121: @pure +122: def check_slippage(current: uint256, desired: uint256, slippage: uint256) -> bool: +123: if current > desired: return (current - desired) <= slippage +124: else : return (desired - current) <= slippage + +``` + +For more information on Redstone oracle price feeds and deviation thresholds for BOB chain, check here: +https://docs.redstone.finance/docs/get-started/price-feeds + +### Internal pre-conditions + +1. The protocol supports multiple tokens with different deviation thresholds (e.g., BTC/USD and ETH/USD with 0.5%, USDC/USD and USDT/USD with 0.2%). +2. The `slippage` parameter in the `price` function is set by the user and may be higher than some tokens' deviation thresholds. + +### External pre-conditions + +1. The price of a token with a lower deviation threshold (e.g., USDC, USDT) changes by an amount greater than its threshold but less than the user-provided slippage value. + +### Attack Path + +1. An attacker observes that the protocol does not enforce token-specific deviation thresholds. +2. The attacker waits for or observes the price of a token with a lower deviation threshold (e.g., USDC) to change by an amount greater than its threshold but less than the user-provided slippage value. +3. The attacker calls a function that uses the `CONTEXT` function in `api.vy`, which in turn calls the `price` function in the oracle contract with the observed price: + +```python +File: api.vy +52: @internal +53: def CONTEXT( +54: base_token : address, +55: quote_token: address, +56: desired : uint256, +57: slippage : uint256, +58: payload : Bytes[224] +59: ) -> Ctx: +60: base_decimals : uint256 = convert(ERC20Plus(base_token).decimals(), uint256) +61: quote_decimals: uint256 = convert(ERC20Plus(quote_token).decimals(), uint256) +62: # this will revert on error +63: price : uint256 = self.ORACLE.price(quote_decimals, +64: desired, +65: slippage, +66: payload) +67: return Ctx({ +68: price : price, +69: base_decimals : base_decimals, +70: quote_decimals: quote_decimals, +71: }) + +``` + +4. The [`check_slippage` function](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L120-L124) in the oracle contract allows the price to pass: + +```python:gl-sherlock/contracts/oracle.vy +File: oracle.vy +120: @internal +121: @pure +122: def check_slippage(current: uint256, desired: uint256, slippage: uint256) -> bool: +123: if current > desired: return (current - desired) <= slippage +124: else : return (desired - current) <= slippage + +``` + +5. The price is accepted and used in the protocol, potentially affecting various operations like minting, burning, opening positions, etc. + + +### Impact + +The protocol suffers from potential price discrepancies for tokens with lower deviation thresholds. This can lead to incorrect pricing in various operations, potentially causing financial losses for users or the protocol itself. + + +### PoC + +1. Assume a user provides a slippage value of 0.5% for all tokens. +2. USDC has a deviation threshold of 0.2% according to the Redstone oracle. +3. The current USDC price is $1.00. +4. The USDC price changes to $1.004 (0.4% change) due to market conditions. +5. A function (e.g., `mint`) in `api.vy` that uses the `CONTEXT` function is called: + +```python +self.CONTEXT(base_token, quote_token, 1004000, 5000, payload) +``` + +6. The `price` function in `oracle.vy` is called with these parameters: + +```python +self.ORACLE.price(quote_decimals, 1004000, 5000, payload) +``` + +7. The `check_slippage` function allows this price: + +```python +File: oracle.vy +120: @internal +121: @pure +122: def check_slippage(current: uint256, desired: uint256, slippage: uint256) -> bool: +123: if current > desired: return (current - desired) <= slippage +124: else : return (desired - current) <= slippage + +``` + +8. The price is accepted and used in the protocol, potentially affecting various operations. + + +### Mitigation + +To mitigate this issue, implement token-specific deviation thresholds in the oracle contract. This can be done by maintaining a mapping of tokens to their respective deviation thresholds and using these thresholds in the `check_slippage` function. \ No newline at end of file diff --git a/065.md b/065.md new file mode 100644 index 0000000..1c88043 --- /dev/null +++ b/065.md @@ -0,0 +1,89 @@ +Brilliant Burlap Elephant + +Medium + +# Lack of Effective Slippage Control in `mint` and `burn` Functions + + +### Summary +The `mint` and `burn` functions in the `pools.vy` contract do not effectively utilize the slippage parameters passed through the `ctx` object. This omission can lead to significant losses for liquidity providers if exploited by malicious actors. + +### Root Cause +The slippage parameter is passed through various functions but is not used to enforce slippage control in the `mint` and `burn` functions of the `pools.vy` contract. + +### Detailed Analysis + +1. **`api.vy`**: The `mint` function creates a `Ctx` object using the `CONTEXT` function, which includes the slippage parameter. + +2. **`core.vy`**: The `mint` function in `core.vy` receives the `ctx` parameter and passes it to the `calc_mint` function in `pools.vy`. + +3. **`pools.vy`**: The `calc_mint` function in `pools.vy` uses the `ctx` parameter but does not effectively explicitly check for slippage. + +4. **`math.vy`**: The `value` function in `math.vy` is used within [`calc_mint`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L161-L172) but does not enforce slippage control effectively. + + +5. **`oracle.vy`**: The `check_slippage` function is defined but is not utilized in `pools.vy`. + + +```120:124:gl-sherlock/contracts/oracle.vy +@internal +@pure +def check_slippage(current: uint256, desired: uint256, slippage: uint256) -> bool: + if current > desired: return (current - desired) <= slippage + else : return (desired - current) <= slippage +``` + + +### Impact +The lack of slippage control in the `mint` and `burn` functions can lead to significant losses for liquidity providers if the price deviates beyond their acceptable slippage tolerance. + +### Mitigation +To mitigate this issue, slippage control checks should be added to the `mint` and `burn` functions in the `pools.vy` contract. + +#### Updated `mint` Function in `pools.vy` +```python:gl-sherlock/contracts/pools.vy +@external +def mint(id: uint256, base_amt: uint256, quote_amt: uint256, ctx: Ctx) -> PoolState: + self._INTERNAL() + ps: PoolState = Pools(self).lookup(id) + lp_amt: uint256 = self.calc_mint(id, base_amt, quote_amt, ERC20(ps.lp_token).totalSupply(), ctx) + current_price: uint256 = self.MATH.value(Pools(self).total_reserves(id), ctx).total_as_quote + assert self.ORACLE.check_slippage(current_price, ctx.desired, ctx.slippage), "Slippage tolerance exceeded" + return self.insert(PoolState({ + id : ps.id, + symbol : ps.symbol, + base_token : ps.base_token, + quote_token : ps.quote_token, + lp_token : ps.lp_token, + base_reserves : ps.base_reserves + base_amt, + quote_reserves : ps.quote_reserves + quote_amt, + base_interest : ps.base_interest, + quote_interest : ps.quote_interest, + base_collateral : ps.base_collateral, + quote_collateral : ps.quote_collateral, + })) +``` + +#### Updated `burn` Function in `pools.vy` +```python:gl-sherlock/contracts/pools.vy +@external +def burn(id: uint256, lp_amt: uint256, ctx: Ctx) -> PoolState: + self._INTERNAL() + ps: PoolState = Pools(self).lookup(id) + amts: Tokens = self.calc_burn(id, lp_amt, ERC20(ps.lp_token).totalSupply(), ctx) + current_price: uint256 = self.MATH.value(Pools(self).total_reserves(id), ctx).total_as_quote + assert self.ORACLE.check_slippage(current_price, ctx.desired, ctx.slippage), "Slippage tolerance exceeded" + return self.insert(PoolState({ + id : ps.id, + symbol : ps.symbol, + base_token : ps.base_token, + quote_token : ps.quote_token, + lp_token : ps.lp_token, + base_reserves : ps.base_reserves - amts.base, + quote_reserves : ps.quote_reserves - amts.quote, + base_interest : ps.base_interest, + quote_interest : ps.quote_interest, + base_collateral : ps.base_collateral, + quote_collateral : ps.quote_collateral, + })) +``` diff --git a/066.md b/066.md new file mode 100644 index 0000000..17959d4 --- /dev/null +++ b/066.md @@ -0,0 +1,977 @@ +Hot Purple Buffalo + +High + +# Fee Precision Loss Disrupts Liquidations and Causes Loss of Funds + +## Summary +Borrowing and funding fees of both longs/shorts suffer from two distinct sources of precision loss. The level of precision loss is large enough to consistently occur at a significant level, and can even result in total omission of fee payment for periods of time. This error is especially disruptive given the sensitive nature of funding fee calculations both in determining liquidations (a core functionality), as well as payments received by LPs and funding recipients (representing a significant loss). + +## Vulnerability Detail +The first of the aforementioned sources of precision loss is relating to the `DENOM` parameter defined and used in `apply` of [`math.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L163-L172): + +```vyper +DENOM: constant(uint256) = 1_000_000_000 + +def apply(x: uint256, numerator: uint256) -> Fee: + fee : uint256 = (x * numerator) / DENOM +... + return Fee({x: x, fee: fee_, remaining: remaining}) +``` + +This function is in turn referenced (to extract the `fee` parameter in particular) in several locations throughout [`fees.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/fees.vy#L265), namely in determining the funding and borrowing payments made by positions open for a duration of time: + +```vyper + paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms) +... + paid_short_term : uint256 = self.apply(fs.short_collateral, fs.funding_short * new_terms) +... + P_b : uint256 = self.apply(collateral, period.borrowing_long) if long else ( + self.apply(collateral, period.borrowing_short) ) + P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( + self.apply(collateral, period.funding_short) ) +``` + +The comments for `DENOM` specify that it's a "magic number which depends on the smallest fee one wants to support and the blocktime." In fact, given its current value of $10^{-9}$, the smallest representable fee per block is $10^{-7}$%. Given the average blocktimes of 2.0 sec on the [BOB chain](https://explorer.gobob.xyz/), there are 15_778_800 blocks in a standard calendar year. Combined with the fee per block, this translates to an annual fee of 1.578%. + +However, this is also the interval size for annualized fees under the current system. As a result, any fee falling below the next interval will be rounded down. For example, given an annualized funding rate in the neighborhood of 15%, there is potential for a nearly 10% error in the interest rate if rounding occurs just before the next interval. This error is magnified the smaller the funding rates become. An annual fee of 3.1% would round down to 1.578%, representing an error of nearly 50%. And any annualized fees below 1.578% will not be recorded, representing a 100% error. + +The second source of precision loss combines with the aforementioned error, to both increase the severity and frequency of error. It's related to how percentages are handled in `params.vy`, particularly when the long/short utilization is calculated to determine funding & borrow rates. The [utilization](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L59-L63) is shown below: + +```vyper +def utilization(reserves: uint256, interest: uint256) -> uint256: + return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) +``` + +This function is in turn used to calculate borrowing (and funding rates, following a slightly different approach that similarly combines the use of `utilization` and `scale`), in `[dynamic_fees](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L33-L55)` of `params.vy`: + +```vyper +def dynamic_fees(pool: PoolState) -> DynFees: + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) + borrowing_long : uint256 = self.check_fee( + self.scale(self.PARAMS.MAX_FEE, long_utilization)) + borrowing_short : uint256 = self.check_fee( + self.scale(self.PARAMS.MAX_FEE, short_utilization)) +... +def scale(fee: uint256, utilization: uint256) -> uint256: + return (fee * utilization) / 100 +``` + +Note that `interest` and `reserves` maintain the same precision. Therefore, the output of `utilization` will have just 2 digits of precision, resulting from the division of `reserves` by `100`. However, this approach can similarly lead to fee rates losing a full percentage point in their absolute value. Since the utilization is used by `dynamic_fees` to calculate the funding / borrow rates, when combined with the formerly described source of precision loss the error is greatly amplified. + +Consider a scenario when the long open interest is 199_999 * $10^{18}$ and the reserves are 10_000_000 * $10^{18}$. Under the current `utilization` functionality, the result would be a 1.9999% utilization rounded down to 1%. Further assuming the value of `max_fee = 65` (this represents a 100% annual rate and 0.19% 8-hour rate), the long borrow rate would round down to 0%. Had the 1.9999% utilization rate not been rounded down 1%, the result would have been `r = 1.3`. In turn, the precision loss in `DENOM` would have effectively rounded down to `r = 1`, resulting in a 2.051% borrow rate rounded down to 1.578%. + +In other words, the precision loss in `DENOM` alone would have resulted in a 23% error in this case. But when combined with the precision loss in percentage points represented in `utilization`, a 100% error resulted. While the utilization and resulting interest rates will typically not be low enough to produce such drastic errors, this hopefully illustrates the synergistic combined impact of both sources of precision loss. Even at higher, more representative values for these rates (such as `r = 10`), errors in fee calculations exceeding 10% will consistently occur. + +## Impact +All fees in the system will consistently be underpaid by a significant margin, across all pools and positions. Additionally trust/confidence in the system will be eroded as fee application will be unpredictable, with sharp discontinuities in rates even given moderate changes in pool utilization. Finally, positions will be subsidized at the expense of LPs, since the underpayment of fees will make liquidations less likely and take longer to occur. As a result, LPs and funding recipients will have lesser incentive to provide liquidity, as they are consistently being underpaid while taking on a greater counterparty risk. + +As an example, consider the scenario where the long open interest is 1_099_999 * $10^{18}$ and the reserves are 10_000_000 * $10^{18}$. Under the current `utilization` functionality, the result would be a 10.9999% utilization rounded down to 10%. Assuming `max_fee = 65` (100% annually, 0.19% 8-hour), the long borrow rate would be `r = 6.5` rounded down to `r = 6`. A 9.5% annual rate results, whereas the accurate result if neither precision loss occurred is `r = 7.15` or 11.3% annually. The resulting error in the borrow rate is 16%. + +Assuming a long collateral of 100_000 * $10^{18}$, LPs would earn 9_500 * $10^{18}$, when they should earn 11_300 * $10^{18}$, a shortfall of 1_800 * $10^{18}$ from longs alone. Additional borrow fee shortfalls would occur for shorts, in addition to shortfalls in funding payments received. + +Liquidation from borrow rates along should have taken 106 months based on the expected result of 11.3% per annum. However, under the 9.5% annual rate it would take 127 months to liquidate a position. This represents a 20% delay in liquidation time from borrow rates alone, not including the further delay caused by potential underpaid funding rates. + +When PnL is further taken into account, these delays could mark the difference between a period of volatility wiping out a position. As a result, these losing positions could run for far longer than should otherwise occur and could even turn into winners. Not only are LP funds locked for longer as a result, they are at a greater risk of losing capital to their counterparty. On top of this, they are also not paid their rightful share of profits, losing out on funds to take on an unfavorably elevated risk. + +Thus, not only do consistent, material losses (significant fee underpayment) occur but a critical, core functionality malfunctions (liquidations are delayed). + +## Code Snippet +In the included PoC, three distinct tests demonstrate the individual sources of precision loss, as well as their combined effect. Similar scenarios were demonstrated as discussed above, for example interest = 199_999 * $10^{18} with reserves = 10_000_000 * $10^{18}$ with a max fee of 65. + +The smart contracts were stripped to isolate the relevant logic, and [foundry](https://github.com/0xKitsune/Foundry-Vyper) was used for testing. To run the test, clone the repo and place `Denom.vy` in vyper_contracts, and place `Denom.t.sol`, `Cheat.sol`, and `IDenom.sol` under src/test. + +
+Denom.vy + +```vyper + +struct DynFees: + funding_long : uint256 + funding_short : uint256 + +struct PoolState: + base_collateral : uint256 + quote_collateral : uint256 + +struct FeeState: + t1 : uint256 + funding_long : uint256 + funding_short : uint256 + long_collateral : uint256 + short_collateral : uint256 + funding_long_sum : uint256 + funding_short_sum : uint256 + received_long_sum : uint256 + received_short_sum : uint256 + +struct SumFees: + funding_paid : uint256 + funding_received: uint256 + +struct Period: + funding_long : uint256 + funding_short : uint256 + received_long : uint256 + received_short : uint256 + +#starting point hardcoded +@external +def __init__(): + self.FEE_STORE = FeeState({ + t1 : block.number, + funding_long : 1, + funding_short : 0, + long_collateral : 10_000_000_000_000_000_000_000_000, + short_collateral : 10_000_000_000_000_000_000_000_000, + funding_long_sum : 0, + funding_short_sum : 0, + received_long_sum : 0, + received_short_sum : 0, + }) + + self.FEE_STORE_AT[block.number] = self.FEE_STORE + + self.FEE_STORE2 = FeeState({ + t1 : block.number, + funding_long : 1_999, + funding_short : 0, + long_collateral : 10_000_000_000_000_000_000_000_000, + short_collateral : 10_000_000_000_000_000_000_000_000, + funding_long_sum : 0, + funding_short_sum : 0, + received_long_sum : 0, + received_short_sum : 0, + }) + self.FEE_STORE_AT2[block.number] = self.FEE_STORE2 + +# hardcoded funding rates for the scenario where funding is positive +@internal +@view +def dynamic_fees() -> DynFees: + return DynFees({ + funding_long : 10, + funding_short : 0, + }) + +# #hardcoded pool to have 1e24 of quote and base collateral +@internal +@view +def lookup() -> PoolState: + return PoolState({ + base_collateral : 10_000_000_000_000_000_000_000_000, + quote_collateral : 10_000_000_000_000_000_000_000_000, + }) + + +FEE_STORE : FeeState +FEE_STORE_AT : HashMap[uint256, FeeState] + +FEE_STORE2 : FeeState +FEE_STORE_AT2 : HashMap[uint256, FeeState] + +@internal +@view +def lookupFees() -> FeeState: + return self.FEE_STORE + +@internal +@view +def lookupFees2() -> FeeState: + return self.FEE_STORE2 + +@internal +@view +def fees_at_block(height: uint256) -> FeeState: + return self.FEE_STORE_AT[height] + +@internal +@view +def fees_at_block2(height: uint256) -> FeeState: + return self.FEE_STORE_AT2[height] + +@external +def update(): + fs: FeeState = self.current_fees() + fs2: FeeState = self.current_fees2() + + self.FEE_STORE_AT[block.number] = fs + self.FEE_STORE = fs + + self.FEE_STORE_AT2[block.number] = fs2 + self.FEE_STORE2 = fs2 + +#math +ZEROS: constant(uint256) = 1000000000000000000000000000 +DENOM: constant(uint256) = 1_000_000_000 +DENOM2: constant(uint256) = 1_000_000_000_000 + +@internal +@pure +def extend(X: uint256, x_m: uint256, m: uint256) -> uint256: + return X + (m*x_m) + +@internal +@pure +def apply(x: uint256, numerator: uint256) -> uint256: + """ + Fees are represented as numerator only, with the denominator defined + here. This computes x*fee capped at x. + """ + fee : uint256 = (x * numerator) / DENOM + fee_ : uint256 = fee if fee <= x else x + return fee_ + +@internal +@pure +def apply2(x: uint256, numerator: uint256) -> uint256: + """ + Fees are represented as numerator only, with the denominator defined + here. This computes x*fee capped at x. + """ + fee : uint256 = (x * numerator) / DENOM2 + fee_ : uint256 = fee if fee <= x else x + return fee_ + +@internal +@pure +def divide(paid: uint256, collateral: uint256) -> uint256: + if collateral == 0: return 0 + else : return (paid * ZEROS) / collateral + +@internal +@pure +def multiply(ci: uint256, terms: uint256) -> uint256: + return (ci * terms) / ZEROS + +@internal +@pure +def slice(y_i: uint256, y_j: uint256) -> uint256: + return y_j - y_i + +@external +@pure +def utilization(reserves: uint256, interest: uint256) -> uint256: + """ + Reserve utilization in percent (rounded down). @audit this is actually rounded up... + """ + return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) + +@external +@pure +def utilization2(reserves: uint256, interest: uint256) -> uint256: + """ + Reserve utilization in percent (rounded down). @audit this is actually rounded up... + """ + return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100_000)) + +@external +@pure +def scale(fee: uint256, utilization: uint256) -> uint256: + return (fee * utilization) / 100 + +@external +@pure +def scale2(fee: uint256, utilization: uint256) -> uint256: + return (fee * utilization) / 100_000 + +@internal +@view +def current_fees() -> FeeState: + """ + Update incremental fee state, called whenever the pool state changes. + """ + # prev/last updated state + fs : FeeState = self.lookupFees() + # current state + ps : PoolState = self.lookup() + new_fees : DynFees = self.dynamic_fees() + # number of blocks elapsed + new_terms: uint256 = block.number - fs.t1 + + funding_long_sum : uint256 = self.extend(fs.funding_long_sum, fs.funding_long, new_terms) + funding_short_sum : uint256 = self.extend(fs.funding_short_sum, fs.funding_short, new_terms) + + paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms) + received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) + + paid_short_term : uint256 = self.apply(fs.short_collateral, fs.funding_short * new_terms) + received_long_term : uint256 = self.divide(paid_short_term, fs.long_collateral) + + received_long_sum : uint256 = self.extend(fs.received_long_sum, received_long_term, 1) + received_short_sum : uint256 = self.extend(fs.received_short_sum, received_short_term, 1) + + if new_terms == 0: + return FeeState({ + t1 : fs.t1, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : fs.funding_long_sum, + funding_short_sum : fs.funding_short_sum, + received_long_sum : fs.received_long_sum, + received_short_sum : fs.received_short_sum, + }) + else: + return FeeState({ + t1 : block.number, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : funding_long_sum, + funding_short_sum : funding_short_sum, + received_long_sum : received_long_sum, + received_short_sum : received_short_sum, + }) + +@internal +@view +def current_fees2() -> FeeState: + """ + Update incremental fee state, called whenever the pool state changes. + """ + # prev/last updated state + fs : FeeState = self.lookupFees2() + # current state + ps : PoolState = self.lookup() + new_fees : DynFees = self.dynamic_fees() + # number of blocks elapsed + new_terms: uint256 = block.number - fs.t1 + + funding_long_sum : uint256 = self.extend(fs.funding_long_sum, fs.funding_long, new_terms) + funding_short_sum : uint256 = self.extend(fs.funding_short_sum, fs.funding_short, new_terms) + + paid_long_term : uint256 = self.apply2(fs.long_collateral, fs.funding_long * new_terms) + received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) + + paid_short_term : uint256 = self.apply2(fs.short_collateral, fs.funding_short * new_terms) + received_long_term : uint256 = self.divide(paid_short_term, fs.long_collateral) + + received_long_sum : uint256 = self.extend(fs.received_long_sum, received_long_term, 1) + received_short_sum : uint256 = self.extend(fs.received_short_sum, received_short_term, 1) + + if new_terms == 0: + return FeeState({ + t1 : fs.t1, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : fs.funding_long_sum, + funding_short_sum : fs.funding_short_sum, + received_long_sum : fs.received_long_sum, + received_short_sum : fs.received_short_sum, + }) + else: + return FeeState({ + t1 : block.number, + funding_long : new_fees.funding_long, + funding_short : new_fees.funding_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + funding_long_sum : funding_long_sum, + funding_short_sum : funding_short_sum, + received_long_sum : received_long_sum, + received_short_sum : received_short_sum, + }) + +@internal +@view +def query(opened_at: uint256) -> Period: + """ + Return the total fees due from block `opened_at` to the current block. + """ + fees_i : FeeState = self.fees_at_block(opened_at) + fees_j : FeeState = self.current_fees() + return Period({ + funding_long : self.slice(fees_i.funding_long_sum, fees_j.funding_long_sum), + funding_short : self.slice(fees_i.funding_short_sum, fees_j.funding_short_sum), + received_long : self.slice(fees_i.received_long_sum, fees_j.received_long_sum), + received_short : self.slice(fees_i.received_short_sum, fees_j.received_short_sum), + }) + +@external +@view +def calc(long: bool, collateral: uint256, opened_at: uint256) -> SumFees: + period: Period = self.query(opened_at) + P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( + self.apply(collateral, period.funding_short) ) + R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( + self.multiply(collateral, period.received_short) ) + + return SumFees({funding_paid: P_f, funding_received: R_f}) + +@internal +@view +def query2(opened_at: uint256) -> Period: + """ + Return the total fees due from block `opened_at` to the current block. + """ + fees_i : FeeState = self.fees_at_block2(opened_at) + fees_j : FeeState = self.current_fees2() + return Period({ + funding_long : self.slice(fees_i.funding_long_sum, fees_j.funding_long_sum), + funding_short : self.slice(fees_i.funding_short_sum, fees_j.funding_short_sum), + received_long : self.slice(fees_i.received_long_sum, fees_j.received_long_sum), + received_short : self.slice(fees_i.received_short_sum, fees_j.received_short_sum), + }) + +@external +@view +def calc2(long: bool, collateral: uint256, opened_at: uint256) -> SumFees: + period: Period = self.query2(opened_at) + P_f : uint256 = self.apply2(collateral, period.funding_long) if long else ( + self.apply2(collateral, period.funding_short) ) + R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( + self.multiply(collateral, period.received_short) ) + + return SumFees({funding_paid: P_f, funding_received: R_f}) +``` + +
+ +
+Denom.t.sol + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {CheatCodes} from "./Cheat.sol"; + +import "../../lib/ds-test/test.sol"; +import "../../lib/utils/Console.sol"; +import "../../lib/utils/VyperDeployer.sol"; + +import "../IDenom.sol"; + +contract DenomTest is DSTest { + ///@notice create a new instance of VyperDeployer + VyperDeployer vyperDeployer = new VyperDeployer(); + CheatCodes vm = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + IDenom denom; + uint256 constant YEAR = (60*60*24*(365) + 60*60*24 / 4); //includes leap year + + function setUp() public { + vm.roll(1); + ///@notice deploy a new instance of ISimplestore by passing in the address of the deployed Vyper contract + denom = IDenom(vyperDeployer.deployContract("Denom")); + } + + function testDenom() public { + uint256 blocksInYr = (YEAR) / 2; + + vm.roll(blocksInYr); + + denom.update(); + + // pools were initialized at block 0 + denom.calc(true, 1e25, 0); + denom.calc2(true, 1e25, 0); + } + + function testRounding() public { + uint max_fee = 100; // set max 8-hour rate to 0.288% (157.8% annually) + + uint256 reserves = 10_000_000 ether; + uint256 interest1 = 1_000_000 ether; + uint256 interest2 = 1_099_999 ether; + + uint256 util1 = denom.utilization(reserves, interest1); + uint256 util2 = denom.utilization(reserves, interest2); + + assertEq(util1, util2); // current calculation yields same utilization for both interests + + // borrow rate + uint fee1 = denom.scale(max_fee, util1); + uint fee2 = denom.scale(max_fee, util2); + + assertEq(fee1, fee2); // results in the same fee also. fee2 should be ~1% higher + } + + function testCombined() public { + // now let's see what would happen if we raised the precision of both fees and percents + uint max_fee = 65; + uint max_fee2 = 65_000; // 3 extra digits of precision lowers error by 3 orders of magnitude + + uint256 reserves = 10_000_000 ether; + uint256 interest = 199_999 ether; // interest & reserves same in both, only differ in precision. + + uint256 util1 = denom.utilization(reserves, interest); + uint256 util2 = denom.utilization2(reserves, interest); // 3 extra digits of precision here also + + // borrow rate + uint fee1 = denom.scale(max_fee, util1); + uint fee2 = denom.scale2(max_fee2, util2); + + assertEq(fee1 * 1_000, fee2 - 999); // fee 1 is 1.000, fee 2 is 1.999 (~50% error) + } +} + +``` + +
+ +
+Cheat.sol +```solidity +interface CheatCodes { + // This allows us to getRecordedLogs() + struct Log { + bytes32[] topics; + bytes data; + } + + // Possible caller modes for readCallers() + enum CallerMode { + None, + Broadcast, + RecurrentBroadcast, + Prank, + RecurrentPrank + } + + enum AccountAccessKind { + Call, + DelegateCall, + CallCode, + StaticCall, + Create, + SelfDestruct, + Resume + } + + struct Wallet { + address addr; + uint256 publicKeyX; + uint256 publicKeyY; + uint256 privateKey; + } + + struct ChainInfo { + uint256 forkId; + uint256 chainId; + } + + struct AccountAccess { + ChainInfo chainInfo; + AccountAccessKind kind; + address account; + address accessor; + bool initialized; + uint256 oldBalance; + uint256 newBalance; + bytes deployedCode; + uint256 value; + bytes data; + bool reverted; + StorageAccess[] storageAccesses; + } + + struct StorageAccess { + address account; + bytes32 slot; + bool isWrite; + bytes32 previousValue; + bytes32 newValue; + bool reverted; + } + + // Derives a private key from the name, labels the account with that name, and returns the wallet + function createWallet(string calldata) external returns (Wallet memory); + + // Generates a wallet from the private key and returns the wallet + function createWallet(uint256) external returns (Wallet memory); + + // Generates a wallet from the private key, labels the account with that name, and returns the wallet + function createWallet(uint256, string calldata) external returns (Wallet memory); + + // Signs data, (Wallet, digest) => (v, r, s) + function sign(Wallet calldata, bytes32) external returns (uint8, bytes32, bytes32); + + // Get nonce for a Wallet + function getNonce(Wallet calldata) external returns (uint64); + + // Set block.timestamp + function warp(uint256) external; + + // Set block.number + function roll(uint256) external; + + // Set block.basefee + function fee(uint256) external; + + // Set block.difficulty + // Does not work from the Paris hard fork and onwards, and will revert instead. + function difficulty(uint256) external; + + // Set block.prevrandao + // Does not work before the Paris hard fork, and will revert instead. + function prevrandao(bytes32) external; + + // Set block.chainid + function chainId(uint256) external; + + // Loads a storage slot from an address + function load(address account, bytes32 slot) external returns (bytes32); + + // Stores a value to an address' storage slot + function store(address account, bytes32 slot, bytes32 value) external; + + // Signs data + function sign(uint256 privateKey, bytes32 digest) + external + returns (uint8 v, bytes32 r, bytes32 s); + + // Computes address for a given private key + function addr(uint256 privateKey) external returns (address); + + // Derive a private key from a provided mnemonic string, + // or mnemonic file path, at the derivation path m/44'/60'/0'/0/{index}. + function deriveKey(string calldata, uint32) external returns (uint256); + // Derive a private key from a provided mnemonic string, or mnemonic file path, + // at the derivation path {path}{index} + function deriveKey(string calldata, string calldata, uint32) external returns (uint256); + + // Gets the nonce of an account + function getNonce(address account) external returns (uint64); + + // Sets the nonce of an account + // The new nonce must be higher than the current nonce of the account + function setNonce(address account, uint64 nonce) external; + + // Performs a foreign function call via terminal + function ffi(string[] calldata) external returns (bytes memory); + + // Set environment variables, (name, value) + function setEnv(string calldata, string calldata) external; + + // Read environment variables, (name) => (value) + function envBool(string calldata) external returns (bool); + function envUint(string calldata) external returns (uint256); + function envInt(string calldata) external returns (int256); + function envAddress(string calldata) external returns (address); + function envBytes32(string calldata) external returns (bytes32); + function envString(string calldata) external returns (string memory); + function envBytes(string calldata) external returns (bytes memory); + + // Read environment variables as arrays, (name, delim) => (value[]) + function envBool(string calldata, string calldata) + external + returns (bool[] memory); + function envUint(string calldata, string calldata) + external + returns (uint256[] memory); + function envInt(string calldata, string calldata) + external + returns (int256[] memory); + function envAddress(string calldata, string calldata) + external + returns (address[] memory); + function envBytes32(string calldata, string calldata) + external + returns (bytes32[] memory); + function envString(string calldata, string calldata) + external + returns (string[] memory); + function envBytes(string calldata, string calldata) + external + returns (bytes[] memory); + + // Read environment variables with default value, (name, value) => (value) + function envOr(string calldata, bool) external returns (bool); + function envOr(string calldata, uint256) external returns (uint256); + function envOr(string calldata, int256) external returns (int256); + function envOr(string calldata, address) external returns (address); + function envOr(string calldata, bytes32) external returns (bytes32); + function envOr(string calldata, string calldata) external returns (string memory); + function envOr(string calldata, bytes calldata) external returns (bytes memory); + + // Read environment variables as arrays with default value, (name, value[]) => (value[]) + function envOr(string calldata, string calldata, bool[] calldata) external returns (bool[] memory); + function envOr(string calldata, string calldata, uint256[] calldata) external returns (uint256[] memory); + function envOr(string calldata, string calldata, int256[] calldata) external returns (int256[] memory); + function envOr(string calldata, string calldata, address[] calldata) external returns (address[] memory); + function envOr(string calldata, string calldata, bytes32[] calldata) external returns (bytes32[] memory); + function envOr(string calldata, string calldata, string[] calldata) external returns (string[] memory); + function envOr(string calldata, string calldata, bytes[] calldata) external returns (bytes[] memory); + + // Convert Solidity types to strings + function toString(address) external returns(string memory); + function toString(bytes calldata) external returns(string memory); + function toString(bytes32) external returns(string memory); + function toString(bool) external returns(string memory); + function toString(uint256) external returns(string memory); + function toString(int256) external returns(string memory); + + // Sets the *next* call's msg.sender to be the input address + function prank(address) external; + + // Sets all subsequent calls' msg.sender to be the input address + // until `stopPrank` is called + function startPrank(address) external; + + // Sets the *next* call's msg.sender to be the input address, + // and the tx.origin to be the second input + function prank(address, address) external; + + // Sets all subsequent calls' msg.sender to be the input address until + // `stopPrank` is called, and the tx.origin to be the second input + function startPrank(address, address) external; + + // Resets subsequent calls' msg.sender to be `address(this)` + function stopPrank() external; + + // Reads the current `msg.sender` and `tx.origin` from state and reports if there is any active caller modification + function readCallers() external returns (CallerMode callerMode, address msgSender, address txOrigin); + + // Sets an address' balance + function deal(address who, uint256 newBalance) external; + + // Sets an address' code + function etch(address who, bytes calldata code) external; + + // Marks a test as skipped. Must be called at the top of the test. + function skip(bool skip) external; + + // Expects an error on next call + function expectRevert() external; + function expectRevert(bytes calldata) external; + function expectRevert(bytes4) external; + + // Record all storage reads and writes + function record() external; + + // Gets all accessed reads and write slot from a recording session, + // for a given address + function accesses(address) + external + returns (bytes32[] memory reads, bytes32[] memory writes); + + // Record all account accesses as part of CREATE, CALL or SELFDESTRUCT opcodes in order, + // along with the context of the calls. + function startStateDiffRecording() external; + + // Returns an ordered array of all account accesses from a `startStateDiffRecording` session. + function stopAndReturnStateDiff() external returns (AccountAccess[] memory accesses); + + // Record all the transaction logs + function recordLogs() external; + + // Gets all the recorded logs + function getRecordedLogs() external returns (Log[] memory); + + // Prepare an expected log with the signature: + // (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData). + // + // Call this function, then emit an event, then call a function. + // Internally after the call, we check if logs were emitted in the expected order + // with the expected topics and data (as specified by the booleans) + // + // The second form also checks supplied address against emitting contract. + function expectEmit(bool, bool, bool, bool) external; + function expectEmit(bool, bool, bool, bool, address) external; + + // Mocks a call to an address, returning specified data. + // + // Calldata can either be strict or a partial match, e.g. if you only + // pass a Solidity selector to the expected calldata, then the entire Solidity + // function will be mocked. + function mockCall(address, bytes calldata, bytes calldata) external; + + // Reverts a call to an address, returning the specified error + // + // Calldata can either be strict or a partial match, e.g. if you only + // pass a Solidity selector to the expected calldata, then the entire Solidity + // function will be mocked. + function mockCallRevert(address where, bytes calldata data, bytes calldata retdata) external; + + // Clears all mocked and reverted mocked calls + function clearMockedCalls() external; + + // Expect a call to an address with the specified calldata. + // Calldata can either be strict or a partial match + function expectCall(address callee, bytes calldata data) external; + // Expect a call to an address with the specified + // calldata and message value. + // Calldata can either be strict or a partial match + function expectCall(address callee, uint256, bytes calldata data) external; + + // Gets the _creation_ bytecode from an artifact file. Takes in the relative path to the json file + function getCode(string calldata) external returns (bytes memory); + // Gets the _deployed_ bytecode from an artifact file. Takes in the relative path to the json file + function getDeployedCode(string calldata) external returns (bytes memory); + + // Label an address in test traces + function label(address addr, string calldata label) external; + + // Retrieve the label of an address + function getLabel(address addr) external returns (string memory); + + // When fuzzing, generate new inputs if conditional not met + function assume(bool) external; + + // Set block.coinbase (who) + function coinbase(address) external; + + // Using the address that calls the test contract or the address provided + // as the sender, has the next call (at this call depth only) create a + // transaction that can later be signed and sent onchain + function broadcast() external; + function broadcast(address) external; + + // Using the address that calls the test contract or the address provided + // as the sender, has all subsequent calls (at this call depth only) create + // transactions that can later be signed and sent onchain + function startBroadcast() external; + function startBroadcast(address) external; + function startBroadcast(uint256 privateKey) external; + + // Stops collecting onchain transactions + function stopBroadcast() external; + + // Reads the entire content of file to string, (path) => (data) + function readFile(string calldata) external returns (string memory); + // Get the path of the current project root + function projectRoot() external returns (string memory); + // Reads next line of file to string, (path) => (line) + function readLine(string calldata) external returns (string memory); + // Writes data to file, creating a file if it does not exist, and entirely replacing its contents if it does. + // (path, data) => () + function writeFile(string calldata, string calldata) external; + // Writes line to file, creating a file if it does not exist. + // (path, data) => () + function writeLine(string calldata, string calldata) external; + // Closes file for reading, resetting the offset and allowing to read it from beginning with readLine. + // (path) => () + function closeFile(string calldata) external; + // Removes file. This cheatcode will revert in the following situations, but is not limited to just these cases: + // - Path points to a directory. + // - The file doesn't exist. + // - The user lacks permissions to remove the file. + // (path) => () + function removeFile(string calldata) external; + // Returns true if the given path points to an existing entity, else returns false + // (path) => (bool) + function exists(string calldata) external returns (bool); + // Returns true if the path exists on disk and is pointing at a regular file, else returns false + // (path) => (bool) + function isFile(string calldata) external returns (bool); + // Returns true if the path exists on disk and is pointing at a directory, else returns false + // (path) => (bool) + function isDir(string calldata) external returns (bool); + + // Return the value(s) that correspond to 'key' + function parseJson(string memory json, string memory key) external returns (bytes memory); + // Return the entire json file + function parseJson(string memory json) external returns (bytes memory); + // Check if a key exists in a json string + function keyExists(string memory json, string memory key) external returns (bytes memory); + // Get list of keys in a json string + function parseJsonKeys(string memory json, string memory key) external returns (string[] memory); + + // Snapshot the current state of the evm. + // Returns the id of the snapshot that was created. + // To revert a snapshot use `revertTo` + function snapshot() external returns (uint256); + // Revert the state of the evm to a previous snapshot + // Takes the snapshot id to revert to. + // This deletes the snapshot and all snapshots taken after the given snapshot id. + function revertTo(uint256) external returns (bool); + + // Creates a new fork with the given endpoint and block, + // and returns the identifier of the fork + function createFork(string calldata, uint256) external returns (uint256); + // Creates a new fork with the given endpoint and the _latest_ block, + // and returns the identifier of the fork + function createFork(string calldata) external returns (uint256); + + // Creates _and_ also selects a new fork with the given endpoint and block, + // and returns the identifier of the fork + function createSelectFork(string calldata, uint256) + external + returns (uint256); + // Creates _and_ also selects a new fork with the given endpoint and the + // latest block and returns the identifier of the fork + function createSelectFork(string calldata) external returns (uint256); + + // Takes a fork identifier created by `createFork` and + // sets the corresponding forked state as active. + function selectFork(uint256) external; + + // Returns the currently active fork + // Reverts if no fork is currently active + function activeFork() external returns (uint256); + + // Updates the currently active fork to given block number + // This is similar to `roll` but for the currently active fork + function rollFork(uint256) external; + // Updates the given fork to given block number + function rollFork(uint256 forkId, uint256 blockNumber) external; + + // Fetches the given transaction from the active fork and executes it on the current state + function transact(bytes32) external; + // Fetches the given transaction from the given fork and executes it on the current state + function transact(uint256, bytes32) external; + + // Marks that the account(s) should use persistent storage across + // fork swaps in a multifork setup, meaning, changes made to the state + // of this account will be kept when switching forks + function makePersistent(address) external; + function makePersistent(address, address) external; + function makePersistent(address, address, address) external; + function makePersistent(address[] calldata) external; + // Revokes persistent status from the address, previously added via `makePersistent` + function revokePersistent(address) external; + function revokePersistent(address[] calldata) external; + // Returns true if the account is marked as persistent + function isPersistent(address) external returns (bool); + + /// Returns the RPC url for the given alias + function rpcUrl(string calldata) external returns (string memory); + /// Returns all rpc urls and their aliases `[alias, url][]` + function rpcUrls() external returns (string[2][] memory); +} +``` + +
+ +
+IDenom.sol + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +interface IDenom { + function update() external; + function calc(bool, uint256, uint256) external returns(SumFees memory); + function calc2(bool, uint256, uint256) external returns(SumFees memory); + + function utilization(uint256, uint256) external returns(uint256); + function utilization2(uint256, uint256) external returns(uint256); + function scale(uint256, uint256) external returns(uint256); + function scale2(uint256, uint256) external returns(uint256); + + struct SumFees{ + uint256 funding_paid; + uint256 funding_received; + } +} +``` + +
+ +## Tool used + +Manual Review + +## Recommendation +Consider increasing the precision of `DENOM` by atleast 3 digits, i.e. `DENOM: constant(uint256) = 1_000_000_000_000` instead of `1_000_000_000`. Consider increasing the precision of percentages by 3 digits, i.e. divide / multiply by 100_000 instead of 100. + +Each added digit of precision decreases the precision loss by an order of magnitude. In other words the 1% and 1.5% absolute errors in precision would shrink to 0.01% and 0.015% when using three extra digits of precision. + +Consult `Denom.vy` for further guidance on necessary adjustments to make to the various functions to account for these updated values. \ No newline at end of file diff --git a/068.md b/068.md new file mode 100644 index 0000000..0331ca1 --- /dev/null +++ b/068.md @@ -0,0 +1,1030 @@ +Hot Purple Buffalo + +High + +# Inequitable Fee Application Penalizes Lower Leverage Positions and LPs + +## Summary +Funding rates are [typically applied](https://www.investopedia.com/what-are-perpetual-futures-7494870) to the [notional value](https://www.investopedia.com/terms/n/notionalvalue.asp) of a perpetual futures contract. However, the application of all fees in the protocol only takes into account the collateral value of the position. This is despite the `interest` of a pool determining both locked `reserves` and the values of dynamic rates. + +Positions with high leverage not only lock more `reserves`, but also increase the funding/borrowing rates more than positions with low leverage. These increased rates apply equally to all positions, penalizing low leverage positions which have to pay disproportionately more fees relative to their `interest` contribution, and face elevated liquidation risk as a result. + +The protocol, and LPs are also penalized by these higher leverage positions, since they will earn less fees while locking the same amount of `reserves`. While the risk of liquidation is higher for these positions, the lower notional fees establish added perverse incentives for taking on high leverage than would otherwise be the case. As a result, positions with low leverage will pay far greater fees and LPs (as well as the protocol) earn less fees than they otherwise would for locking up the same amount of capital. + +## Vulnerability Detail +When applying borrowing and funding fees to a position, the `calc` function of [`fees.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/fees.vy#L265-L270) is utilized: + +```vyper +def calc(id: uint256, long: bool, collateral: uint256, opened_at: uint256) -> SumFees: + period: Period = self.query(id, opened_at) + P_b : uint256 = self.apply(collateral, period.borrowing_long) if long else ( + self.apply(collateral, period.borrowing_short) ) + P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( + self.apply(collateral, period.funding_short) ) +... +``` + +which queries the globally accrued interest during the time the position was active, and applies it to the `collateral` value using the `apply` function of [`math.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L167): + +```vyper +def apply(x: uint256, numerator: uint256) -> Fee: + fee : uint256 = (x * numerator) / DENOM +... +``` + +Finally, when opening a position for the first time (in [`core.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L244)) a static fee is also applied directly to the collateral value (from [`params.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L93): + +```vyper +def open( +... + cf : Fee = self.PARAMS.static_fees(collateral0) + collateral : uint256 = cf.remaining +... + +def static_fees(collateral: uint256) -> Fee: + fee : uint256 = collateral / self.PARAMS.PROTOCOL_FEE + remaining: uint256 = collateral - fee + return Fee({x: collateral, fee: fee, remaining: remaining}) +``` + +In effect, all fees are applied directly to the collateral value of positions and ignore the value of the leverage, or corresponding open-interest. As such, a position with higher `collateral` will always pay more fees than one with less `collateral`, even if the former has a lower `interest`. + +Positions which take on a higher `leverage` and corresponding`interest` value contribute disproportionately to the [funding and borrowing rates for their side](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L33-L55), by pushing up the `utilization`: + +```vyper +def dynamic_fees(pool: PoolState) -> DynFees: + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) + borrowing_long : uint256 = self.check_fee( + self.scale(self.PARAMS.MAX_FEE, long_utilization)) + borrowing_short : uint256 = self.check_fee( + self.scale(self.PARAMS.MAX_FEE, short_utilization)) + funding_long : uint256 = self.funding_fee( + borrowing_long, long_utilization, short_utilization) + funding_short : uint256 = self.funding_fee( + borrowing_short, short_utilization, long_utilization) +... + +def utilization(reserves: uint256, interest: uint256) -> uint256: + return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) + }) +``` + +Note that the fee calculations are also utilized in determining whether a given position is liquidatable in [`positions.vy`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L352): + +```vyper +def is_liquidatable(id: uint256, ctx: Ctx) -> bool: + v: PositionValue = Positions(self).value(id, ctx) + return self.PARAMS.is_liquidatable(v.position, v.pnl) + +def value(id: uint256, ctx: Ctx) -> PositionValue: + pos : PositionState = Positions(self).lookup(id) + fees : FeesPaid = Positions(self).calc_fees(id) + pnl : PnL = Positions(self).calc_pnl(id, ctx, fees.remaining) # collateral less fees used here +... + return PositionValue({position: pos, fees: fees, pnl: pnl, deltas: deltas}) + +def is_liquidatable(position: PositionState, pnl: PnL) -> bool: + percent : uint256 = self.PARAMS.LIQUIDATION_THRESHOLD * position.leverage + required: uint256 = (position.collateral * percent) / 100 + return not (pnl.remaining > required) +``` + +where the collateral + PnL - (dynamic) fees is compared with some fraction of the original collateral balance. All positions on the same side of a pool face the same dynamic rates, regardless of their level of leverage. + +## Impact +Positions with higher than average leverage pay less fees as a fraction of their notional value. Since the same global rates are assessed on all positions of the same side of a pool, high leverage positions are effectively subsidized by lower leverage ones. High leverage positions will also take significantly longer to liquidate than they otherwise would if their contribution to their pool's `interest` was taken into account. + +They also drive up the dynamic rates more, and lock a greater quantity of global reserves than those having less leverage. Since they pay less notional fees than positions with lower leverage, perverse incentives further encourage taking out greater leverage than normal. + +As a result, lower leverage positions will pay more fees than they otherwise would. They pay a far greater share of the global funding and borrowing fees relative to the amount of `interest` that LPs will potentially need to pay out for their winning positions. They also face increased risk of liquidation since the elevated fees increases the likelihood of a wipeout, as well as reducing the amount of time until absolute fee liquidation. + +The high leverage positions on one side of a pool will liquidate from fees at the same rate as lower leverage positions, despite providing far less collateral. If the leverage were taken into account, these positions would liquidate multiple times faster due to fees alone. These positions have high open-interest and contribute disproportionately to the locked reserves. As a result, LPs face greater counterparty risk, especially during trending market conditions where PnL liquidations are less likely. Additionally, they receive less fees which further reduces the risk-reward of providing liquidity. + +As a demonstration, consider the following pool with `base_reserves = quote_reserves = 1e7 * 1e18`. It's assumed to have `quote_interest = 4e5 * 1e18` and `base_collateral = 2e5 * 1e18`. For simplicity, it has only two open long positions with the same collateral (but differing leverage): +1. `quote_collateral = 1e5 * 1e18`, but `base_interest = 1e6 * 1e18` (10x leverage) +2. `quote_collateral = 1e5 * 1e18`, but `base_interest = 2e5 * 1e18` (2x leverage) + +Further, assume [`PARAMS.MAX_FEE = 300`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L43). Both positions were opened in the same block, and both closed exactly 1 calendar year later. Pool conditions are assumed to have remained the same. + +The combined `quote_collateral = 2e5 * 1e18`, and `base_interest = 12e5 * 1e8`. Using the reserves from earlier, this results in a long utilization of 12%, which corresponds to a borrowing rate of `r = 36`, using the `PARAMS.MAX_FEE = 300` from earlier. The short utilization is calculated to be 4%, thus the imbalance in funding rates is 8% for longs, resulting in a funding rate of `r = 2` using the same `PARAMS.MAX_FEE = 300`. + +Given the [2 second blocktime](https://explorer.gobob.xyz/) on BOB chain, these positions were closed after 15_768_000 blocks, in which time they each accrued an 56.76% borrow fee and 3.15% funding fee - a total fee of 59.9%. Since both positions had `quote_collateral = 1e5 * 1e18`, their quote collateral was discounted by the same amount before calculating PnL: `0.599e5 * 1e18`. + +Note that both positions paid the same percentage and nominal fee rates. However the first position took up 10% of the base reserves, while the second took up only 2% **yet they paid the exact same fees.** + + To further emphasize this point, let's look at two scenarios where each position has the same leverage, and the total `base_interest` is the same. +- Keep position 1. For position 2, `quote_collateral = 2e4 * 1e18` and `base_interest = 2e5 * 1e18` +- Keep position 2. For position 1, `quote_collateral = 5e5 * 1e18` and `base_interest = 1e6 * 1e18` + +The same borrow / funding rates from before apply since the overall `reserves` and `interest` didn't change. Position 2 now pays a nominal fee of just `0.1198e5 * 1e18`, which is a fifth of that paid before. Position 1 now pays a nominal fee of `2.995e5 * 1e18`, which is five times what was paid before. + +In either case, the same amount of reserves were used. However, when the total pool leverage is 10x, the total fee subtracted from longs was `0.7189e5 * 1e18` tokens, and when it was 2x, the total fee was `3.594e5 * 1e18` tokens. **Five times more fee tokens were paid when the total pool leverage was 5x higher, despite the same level of reserves (counterparty risk) in either case.** + +Significant loss of funds occurs due to inflated fees by lower-leveraged positions, and decreased fee receipts from the protocol and LPs. It will also disrupt the normal liquidation process, as lower-leveraged positions will liquidate at a faster clip than they otherwise would. LP funds are locked for a longer duration than they would be if the fee was assessed fairly on the notional value of these high leverage positions. + +## Code Snippet +For the PoC, `r = 10`, `long_collateral = short_collateral = 10^7 * 10^18` and `n = 6.3 * 10^7` blocks (4 years), as outlined above. + +The smart contracts were stripped to isolate the relevant logic, and [foundry](https://github.com/0xKitsune/Foundry-Vyper) was used for testing. To run the test, clone the repo and place `Leverage.vy` in `vyper_contracts`, and place `Leverage.t.sol`, `Cheat.sol`, and `ILeverage.sol` under `src/test`. +
+Leverage.vy + +```vyper + +struct PositionState: + collateral : uint256 + interest : uint256 + +struct DynFees: + borrowing_long : uint256 + borrowing_short: uint256 + funding_long : uint256 + funding_short : uint256 + +struct PoolState: + base_reserves : uint256 + quote_reserves : uint256 + base_interest : uint256 + quote_interest : uint256 + base_collateral : uint256 + quote_collateral : uint256 + +# need to edit all old stuff returning ps +# struct PoolState: +# base_collateral : uint256 +# quote_collateral : uint256 + +#added borrowing long & borrowing short, update it elsewhere +struct FeeState: + t1 : uint256 + funding_long : uint256 + borrowing_long : uint256 + funding_short : uint256 + borrowing_short : uint256 + long_collateral : uint256 + short_collateral : uint256 + borrowing_long_sum : uint256 + borrowing_short_sum : uint256 + funding_long_sum : uint256 + funding_short_sum : uint256 + received_long_sum : uint256 + received_short_sum : uint256 + +struct SumFees: + total: uint256 + funding_paid : uint256 + funding_received: uint256 + borrowing_paid: uint256 + +struct Period: + borrowing_long : uint256 + borrowing_short: uint256 + funding_long : uint256 + funding_short : uint256 + received_long : uint256 + received_short : uint256 + +MAX_FEE: uint256 +MIN_FEE: uint256 + +FEE_STORE: FeeState +FEE_STORE_AT: HashMap[uint256, FeeState] + +POOL_STORE: PoolState + +POSITION_STORE: PositionState +POSITION_STORE2: PositionState + +#starting point hardcoded +@external +def __init__(_qc: uint256, _bi: uint256, _qc2: uint256, _bi2: uint256): + self.MAX_FEE = 0 + self.MAX_FEE = 300 + + self.POOL_STORE = PoolState({ + base_reserves : 10_000_000_000_000_000_000_000_000, + quote_reserves : 10_000_000_000_000_000_000_000_000, + base_interest : _bi + _bi2, + quote_interest : 400_000_000_000_000_000_000_000, + base_collateral : 200_000_000_000_000_000_000_000, + quote_collateral : _qc + _qc2, + }) + + starting_fees: DynFees = self.dynamic_fees() + + self.FEE_STORE = FeeState({ + t1 : 1, + funding_long : starting_fees.funding_long, + borrowing_long : starting_fees.borrowing_long, + funding_short : starting_fees.funding_short, + borrowing_short : starting_fees.borrowing_short, + long_collateral : _qc + _qc2, + short_collateral : 200_000_000_000_000_000_000_000, + borrowing_long_sum : 0, + borrowing_short_sum : 0, + funding_long_sum : 0, + funding_short_sum : 0, + received_long_sum : 0, + received_short_sum : 0, + }) + + self.FEE_STORE_AT[1] = self.FEE_STORE + + self.POSITION_STORE = PositionState({ + collateral: _qc, + interest: _bi, + }) + + self.POSITION_STORE2 = PositionState({ + collateral: _qc2, + interest: _bi2, + }) + +@internal +@view +def dynamic_fees() -> DynFees: + pool: PoolState = self.POOL_STORE + + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) + borrowing_long : uint256 = self.check_fee( + self.scale(self.MAX_FEE, long_utilization)) + borrowing_short : uint256 = self.check_fee( + self.scale(self.MAX_FEE, short_utilization)) + funding_long : uint256 = self.funding_fee( + borrowing_long, long_utilization, short_utilization) + funding_short : uint256 = self.funding_fee( + borrowing_short, short_utilization, long_utilization) + return DynFees({ + borrowing_long : borrowing_long, + borrowing_short: borrowing_short, + funding_long : funding_long, + funding_short : funding_short, + }) + +@internal +@pure +def utilization(reserves: uint256, interest: uint256) -> uint256: + return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) + +@internal +@pure +def scale(fee: uint256, utilization: uint256) -> uint256: + return (fee * utilization) / 100 + +@internal +@view +def check_fee(fee: uint256) -> uint256: + if self.MIN_FEE <= fee and fee <= self.MAX_FEE: return fee + elif fee < self.MIN_FEE : return self.MIN_FEE + else : return self.MAX_FEE + +@internal +@pure +def imbalance(n: uint256, m: uint256) -> uint256: + return n - m if n >= m else 0 + +@internal +@view +def funding_fee(base_fee: uint256, col1: uint256, col2: uint256) -> uint256: + imb: uint256 = self.imbalance(col1, col2) + if imb == 0: return 0 + else : return self.check_fee(self.scale(base_fee, imb)) + +# #hardcoded pool to have 1e24 of quote and base collateral +@internal +@view +def lookup() -> PoolState: + return self.POOL_STORE + +@internal +@view +def lookupFees() -> FeeState: + return self.FEE_STORE + +@internal +@view +def fees_at_block(height: uint256) -> FeeState: + return self.FEE_STORE_AT[height] + +@external +def update(): + fs: FeeState = self.current_fees() + + self.FEE_STORE = fs + + +#math +ZEROS: constant(uint256) = 1000000000000000000000000000 +DENOM: constant(uint256) = 1_000_000_000 + +@internal +@pure +def extend(X: uint256, x_m: uint256, m: uint256) -> uint256: + return X + (m*x_m) + +@internal +@pure +def apply(x: uint256, numerator: uint256) -> uint256: + """ + Fees are represented as numerator only, with the denominator defined + here. This computes x*fee capped at x. + """ + fee : uint256 = (x * numerator) / DENOM + fee_ : uint256 = fee if fee <= x else x + return fee_ + +@internal +@pure +def divide(paid: uint256, collateral: uint256) -> uint256: + if collateral == 0: return 0 + else : return (paid * ZEROS) / collateral + +@internal +@pure +def multiply(ci: uint256, terms: uint256) -> uint256: + return (ci * terms) / ZEROS + +@internal +@pure +def slice(y_i: uint256, y_j: uint256) -> uint256: + return y_j - y_i + +@internal +@view +def current_fees() -> FeeState: + """ + Update incremental fee state, called whenever the pool state changes. + """ + # prev/last updated state + fs : FeeState = self.lookupFees() + # current state + ps : PoolState = self.lookup() + new_fees : DynFees = self.dynamic_fees() + # number of blocks elapsed + new_terms: uint256 = block.number - fs.t1 + + borrowing_long_sum : uint256 = self.extend(fs.borrowing_long_sum, fs.borrowing_long, new_terms) + borrowing_short_sum : uint256 = self.extend(fs.borrowing_short_sum, fs.borrowing_short, new_terms) + funding_long_sum : uint256 = self.extend(fs.funding_long_sum, fs.funding_long, new_terms) + funding_short_sum : uint256 = self.extend(fs.funding_short_sum, fs.funding_short, new_terms) + + paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms) + received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) + + paid_short_term : uint256 = self.apply(fs.short_collateral, fs.funding_short * new_terms) + received_long_term : uint256 = self.divide(paid_short_term, fs.long_collateral) + + received_long_sum : uint256 = self.extend(fs.received_long_sum, received_long_term, 1) + received_short_sum : uint256 = self.extend(fs.received_short_sum, received_short_term, 1) + + if new_terms == 0: + return FeeState({ + t1 : fs.t1, + funding_long : new_fees.funding_long, + borrowing_long : new_fees.borrowing_long, + funding_short : new_fees.funding_short, + borrowing_short : new_fees.borrowing_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + borrowing_long_sum : fs.borrowing_long_sum, + borrowing_short_sum : fs.borrowing_short_sum, + funding_long_sum : fs.funding_long_sum, + funding_short_sum : fs.funding_short_sum, + received_long_sum : fs.received_long_sum, + received_short_sum : fs.received_short_sum, + }) + else: + return FeeState({ + t1 : block.number, + funding_long : new_fees.funding_long, + borrowing_long : new_fees.borrowing_long, + funding_short : new_fees.funding_short, + borrowing_short : new_fees.borrowing_short, + long_collateral : ps.quote_collateral, + short_collateral : ps.base_collateral, + borrowing_long_sum : borrowing_long_sum, + borrowing_short_sum : borrowing_short_sum, + funding_long_sum : funding_long_sum, + funding_short_sum : funding_short_sum, + received_long_sum : received_long_sum, + received_short_sum : received_short_sum, + }) + +@internal +@view +def query(opened_at: uint256) -> Period: + """ + Return the total fees due from block `opened_at` to the current block. + """ + fees_i : FeeState = self.fees_at_block(opened_at) + fees_j : FeeState = self.current_fees() + return Period({ + borrowing_long : self.slice(fees_i.borrowing_long_sum, fees_j.borrowing_long_sum), + borrowing_short : self.slice(fees_i.borrowing_short_sum, fees_j.borrowing_short_sum), + funding_long : self.slice(fees_i.funding_long_sum, fees_j.funding_long_sum), + funding_short : self.slice(fees_i.funding_short_sum, fees_j.funding_short_sum), + received_long : self.slice(fees_i.received_long_sum, fees_j.received_long_sum), + received_short : self.slice(fees_i.received_short_sum, fees_j.received_short_sum), + }) + +@external +@view +def calc() -> SumFees: + long: bool = True + collateral: uint256 = self.POSITION_STORE.collateral + opened_at: uint256 = 1 + + period: Period = self.query(opened_at) + P_b : uint256 = self.apply(collateral, period.borrowing_long) if long else ( + self.apply(collateral, period.borrowing_short) ) + P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( + self.apply(collateral, period.funding_short) ) + R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( + self.multiply(collateral, period.received_short) ) + + return SumFees({total: P_f + P_b - R_f, funding_paid: P_f, funding_received: R_f, borrowing_paid: P_b}) + + +@external +@view +def calc2() -> SumFees: + long: bool = True + collateral: uint256 = self.POSITION_STORE2.collateral + opened_at: uint256 = 1 + + period: Period = self.query(opened_at) + P_b : uint256 = self.apply(collateral, period.borrowing_long) if long else ( + self.apply(collateral, period.borrowing_short) ) + P_f : uint256 = self.apply(collateral, period.funding_long) if long else ( + self.apply(collateral, period.funding_short) ) + R_f : uint256 = self.multiply(collateral, period.received_long) if long else ( + self.multiply(collateral, period.received_short) ) + + return SumFees({total: P_f + P_b - R_f, funding_paid: P_f, funding_received: R_f, borrowing_paid: P_b}) + +``` + +
+ +
+Leverage.t.sol + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {CheatCodes} from "./Cheat.sol"; + +import "../../lib/ds-test/test.sol"; +import "../../lib/utils/Console.sol"; +import "../../lib/utils/VyperDeployer.sol"; + +import "../ILeverage.sol"; + +contract LeverageTest is DSTest { + ///@notice create a new instance of VyperDeployer + VyperDeployer vyperDeployer = new VyperDeployer(); + CheatCodes vm = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + ILeverage lev; + uint256 constant YEAR = 60 * 60 * 24 * 365; + uint256 blocksInYr = (YEAR) / 2; + + function setUp() public { + vm.roll(1); + } + + function testDiffLev() public { + // quote collateral & base interest of positions as laid out in scenario 1 + uint256 qc = 100_000 ether; + uint256 bi = 1_000_000 ether; + uint256 qc2 = 100_000 ether; + uint256 bi2 = 200_000 ether; + + lev = ILeverage(vyperDeployer.deployContract("Leverage", + abi.encode(qc, bi, qc2, bi2))); + + vm.roll(blocksInYr); + + lev.update(); + + // pools were initialized at block 0 + lev.calc(); + lev.calc2(); + } + + function testBoth10x() public { + // quote collateral & base interest of positions as laid out in scenario 1 + uint256 qc = 100_000 ether; + uint256 bi = 1_000_000 ether; + uint256 qc2 = 20_000 ether; + uint256 bi2 = 200_000 ether; + + lev = ILeverage(vyperDeployer.deployContract("Leverage", + abi.encode(qc, bi, qc2, bi2))); + + vm.roll(blocksInYr); + + lev.update(); + + // pools were initialized at block 0 + lev.calc(); + lev.calc2(); + } + + function testBoth2x() public { + // quote collateral & base interest of positions as laid out in scenario 1 + uint256 qc = 500_000 ether; + uint256 bi = 1_000_000 ether; + uint256 qc2 = 100_000 ether; + uint256 bi2 = 200_000 ether; + + lev = ILeverage(vyperDeployer.deployContract("Leverage", + abi.encode(qc, bi, qc2, bi2))); + + vm.roll(blocksInYr); + + lev.update(); + + // pools were initialized at block 0 + lev.calc(); + lev.calc2(); + } +} + +``` + +
+ +
+Cheat.sol + +```solidity +interface CheatCodes { + // This allows us to getRecordedLogs() + struct Log { + bytes32[] topics; + bytes data; + } + + // Possible caller modes for readCallers() + enum CallerMode { + None, + Broadcast, + RecurrentBroadcast, + Prank, + RecurrentPrank + } + + enum AccountAccessKind { + Call, + DelegateCall, + CallCode, + StaticCall, + Create, + SelfDestruct, + Resume + } + + struct Wallet { + address addr; + uint256 publicKeyX; + uint256 publicKeyY; + uint256 privateKey; + } + + struct ChainInfo { + uint256 forkId; + uint256 chainId; + } + + struct AccountAccess { + ChainInfo chainInfo; + AccountAccessKind kind; + address account; + address accessor; + bool initialized; + uint256 oldBalance; + uint256 newBalance; + bytes deployedCode; + uint256 value; + bytes data; + bool reverted; + StorageAccess[] storageAccesses; + } + + struct StorageAccess { + address account; + bytes32 slot; + bool isWrite; + bytes32 previousValue; + bytes32 newValue; + bool reverted; + } + + // Derives a private key from the name, labels the account with that name, and returns the wallet + function createWallet(string calldata) external returns (Wallet memory); + + // Generates a wallet from the private key and returns the wallet + function createWallet(uint256) external returns (Wallet memory); + + // Generates a wallet from the private key, labels the account with that name, and returns the wallet + function createWallet(uint256, string calldata) external returns (Wallet memory); + + // Signs data, (Wallet, digest) => (v, r, s) + function sign(Wallet calldata, bytes32) external returns (uint8, bytes32, bytes32); + + // Get nonce for a Wallet + function getNonce(Wallet calldata) external returns (uint64); + + // Set block.timestamp + function warp(uint256) external; + + // Set block.number + function roll(uint256) external; + + // Set block.basefee + function fee(uint256) external; + + // Set block.difficulty + // Does not work from the Paris hard fork and onwards, and will revert instead. + function difficulty(uint256) external; + + // Set block.prevrandao + // Does not work before the Paris hard fork, and will revert instead. + function prevrandao(bytes32) external; + + // Set block.chainid + function chainId(uint256) external; + + // Loads a storage slot from an address + function load(address account, bytes32 slot) external returns (bytes32); + + // Stores a value to an address' storage slot + function store(address account, bytes32 slot, bytes32 value) external; + + // Signs data + function sign(uint256 privateKey, bytes32 digest) + external + returns (uint8 v, bytes32 r, bytes32 s); + + // Computes address for a given private key + function addr(uint256 privateKey) external returns (address); + + // Derive a private key from a provided mnemonic string, + // or mnemonic file path, at the derivation path m/44'/60'/0'/0/{index}. + function deriveKey(string calldata, uint32) external returns (uint256); + // Derive a private key from a provided mnemonic string, or mnemonic file path, + // at the derivation path {path}{index} + function deriveKey(string calldata, string calldata, uint32) external returns (uint256); + + // Gets the nonce of an account + function getNonce(address account) external returns (uint64); + + // Sets the nonce of an account + // The new nonce must be higher than the current nonce of the account + function setNonce(address account, uint64 nonce) external; + + // Performs a foreign function call via terminal + function ffi(string[] calldata) external returns (bytes memory); + + // Set environment variables, (name, value) + function setEnv(string calldata, string calldata) external; + + // Read environment variables, (name) => (value) + function envBool(string calldata) external returns (bool); + function envUint(string calldata) external returns (uint256); + function envInt(string calldata) external returns (int256); + function envAddress(string calldata) external returns (address); + function envBytes32(string calldata) external returns (bytes32); + function envString(string calldata) external returns (string memory); + function envBytes(string calldata) external returns (bytes memory); + + // Read environment variables as arrays, (name, delim) => (value[]) + function envBool(string calldata, string calldata) + external + returns (bool[] memory); + function envUint(string calldata, string calldata) + external + returns (uint256[] memory); + function envInt(string calldata, string calldata) + external + returns (int256[] memory); + function envAddress(string calldata, string calldata) + external + returns (address[] memory); + function envBytes32(string calldata, string calldata) + external + returns (bytes32[] memory); + function envString(string calldata, string calldata) + external + returns (string[] memory); + function envBytes(string calldata, string calldata) + external + returns (bytes[] memory); + + // Read environment variables with default value, (name, value) => (value) + function envOr(string calldata, bool) external returns (bool); + function envOr(string calldata, uint256) external returns (uint256); + function envOr(string calldata, int256) external returns (int256); + function envOr(string calldata, address) external returns (address); + function envOr(string calldata, bytes32) external returns (bytes32); + function envOr(string calldata, string calldata) external returns (string memory); + function envOr(string calldata, bytes calldata) external returns (bytes memory); + + // Read environment variables as arrays with default value, (name, value[]) => (value[]) + function envOr(string calldata, string calldata, bool[] calldata) external returns (bool[] memory); + function envOr(string calldata, string calldata, uint256[] calldata) external returns (uint256[] memory); + function envOr(string calldata, string calldata, int256[] calldata) external returns (int256[] memory); + function envOr(string calldata, string calldata, address[] calldata) external returns (address[] memory); + function envOr(string calldata, string calldata, bytes32[] calldata) external returns (bytes32[] memory); + function envOr(string calldata, string calldata, string[] calldata) external returns (string[] memory); + function envOr(string calldata, string calldata, bytes[] calldata) external returns (bytes[] memory); + + // Convert Solidity types to strings + function toString(address) external returns(string memory); + function toString(bytes calldata) external returns(string memory); + function toString(bytes32) external returns(string memory); + function toString(bool) external returns(string memory); + function toString(uint256) external returns(string memory); + function toString(int256) external returns(string memory); + + // Sets the *next* call's msg.sender to be the input address + function prank(address) external; + + // Sets all subsequent calls' msg.sender to be the input address + // until `stopPrank` is called + function startPrank(address) external; + + // Sets the *next* call's msg.sender to be the input address, + // and the tx.origin to be the second input + function prank(address, address) external; + + // Sets all subsequent calls' msg.sender to be the input address until + // `stopPrank` is called, and the tx.origin to be the second input + function startPrank(address, address) external; + + // Resets subsequent calls' msg.sender to be `address(this)` + function stopPrank() external; + + // Reads the current `msg.sender` and `tx.origin` from state and reports if there is any active caller modification + function readCallers() external returns (CallerMode callerMode, address msgSender, address txOrigin); + + // Sets an address' balance + function deal(address who, uint256 newBalance) external; + + // Sets an address' code + function etch(address who, bytes calldata code) external; + + // Marks a test as skipped. Must be called at the top of the test. + function skip(bool skip) external; + + // Expects an error on next call + function expectRevert() external; + function expectRevert(bytes calldata) external; + function expectRevert(bytes4) external; + + // Record all storage reads and writes + function record() external; + + // Gets all accessed reads and write slot from a recording session, + // for a given address + function accesses(address) + external + returns (bytes32[] memory reads, bytes32[] memory writes); + + // Record all account accesses as part of CREATE, CALL or SELFDESTRUCT opcodes in order, + // along with the context of the calls. + function startStateDiffRecording() external; + + // Returns an ordered array of all account accesses from a `startStateDiffRecording` session. + function stopAndReturnStateDiff() external returns (AccountAccess[] memory accesses); + + // Record all the transaction logs + function recordLogs() external; + + // Gets all the recorded logs + function getRecordedLogs() external returns (Log[] memory); + + // Prepare an expected log with the signature: + // (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData). + // + // Call this function, then emit an event, then call a function. + // Internally after the call, we check if logs were emitted in the expected order + // with the expected topics and data (as specified by the booleans) + // + // The second form also checks supplied address against emitting contract. + function expectEmit(bool, bool, bool, bool) external; + function expectEmit(bool, bool, bool, bool, address) external; + + // Mocks a call to an address, returning specified data. + // + // Calldata can either be strict or a partial match, e.g. if you only + // pass a Solidity selector to the expected calldata, then the entire Solidity + // function will be mocked. + function mockCall(address, bytes calldata, bytes calldata) external; + + // Reverts a call to an address, returning the specified error + // + // Calldata can either be strict or a partial match, e.g. if you only + // pass a Solidity selector to the expected calldata, then the entire Solidity + // function will be mocked. + function mockCallRevert(address where, bytes calldata data, bytes calldata retdata) external; + + // Clears all mocked and reverted mocked calls + function clearMockedCalls() external; + + // Expect a call to an address with the specified calldata. + // Calldata can either be strict or a partial match + function expectCall(address callee, bytes calldata data) external; + // Expect a call to an address with the specified + // calldata and message value. + // Calldata can either be strict or a partial match + function expectCall(address callee, uint256, bytes calldata data) external; + + // Gets the _creation_ bytecode from an artifact file. Takes in the relative path to the json file + function getCode(string calldata) external returns (bytes memory); + // Gets the _deployed_ bytecode from an artifact file. Takes in the relative path to the json file + function getDeployedCode(string calldata) external returns (bytes memory); + + // Label an address in test traces + function label(address addr, string calldata label) external; + + // Retrieve the label of an address + function getLabel(address addr) external returns (string memory); + + // When fuzzing, generate new inputs if conditional not met + function assume(bool) external; + + // Set block.coinbase (who) + function coinbase(address) external; + + // Using the address that calls the test contract or the address provided + // as the sender, has the next call (at this call depth only) create a + // transaction that can later be signed and sent onchain + function broadcast() external; + function broadcast(address) external; + + // Using the address that calls the test contract or the address provided + // as the sender, has all subsequent calls (at this call depth only) create + // transactions that can later be signed and sent onchain + function startBroadcast() external; + function startBroadcast(address) external; + function startBroadcast(uint256 privateKey) external; + + // Stops collecting onchain transactions + function stopBroadcast() external; + + // Reads the entire content of file to string, (path) => (data) + function readFile(string calldata) external returns (string memory); + // Get the path of the current project root + function projectRoot() external returns (string memory); + // Reads next line of file to string, (path) => (line) + function readLine(string calldata) external returns (string memory); + // Writes data to file, creating a file if it does not exist, and entirely replacing its contents if it does. + // (path, data) => () + function writeFile(string calldata, string calldata) external; + // Writes line to file, creating a file if it does not exist. + // (path, data) => () + function writeLine(string calldata, string calldata) external; + // Closes file for reading, resetting the offset and allowing to read it from beginning with readLine. + // (path) => () + function closeFile(string calldata) external; + // Removes file. This cheatcode will revert in the following situations, but is not limited to just these cases: + // - Path points to a directory. + // - The file doesn't exist. + // - The user lacks permissions to remove the file. + // (path) => () + function removeFile(string calldata) external; + // Returns true if the given path points to an existing entity, else returns false + // (path) => (bool) + function exists(string calldata) external returns (bool); + // Returns true if the path exists on disk and is pointing at a regular file, else returns false + // (path) => (bool) + function isFile(string calldata) external returns (bool); + // Returns true if the path exists on disk and is pointing at a directory, else returns false + // (path) => (bool) + function isDir(string calldata) external returns (bool); + + // Return the value(s) that correspond to 'key' + function parseJson(string memory json, string memory key) external returns (bytes memory); + // Return the entire json file + function parseJson(string memory json) external returns (bytes memory); + // Check if a key exists in a json string + function keyExists(string memory json, string memory key) external returns (bytes memory); + // Get list of keys in a json string + function parseJsonKeys(string memory json, string memory key) external returns (string[] memory); + + // Snapshot the current state of the evm. + // Returns the id of the snapshot that was created. + // To revert a snapshot use `revertTo` + function snapshot() external returns (uint256); + // Revert the state of the evm to a previous snapshot + // Takes the snapshot id to revert to. + // This deletes the snapshot and all snapshots taken after the given snapshot id. + function revertTo(uint256) external returns (bool); + + // Creates a new fork with the given endpoint and block, + // and returns the identifier of the fork + function createFork(string calldata, uint256) external returns (uint256); + // Creates a new fork with the given endpoint and the _latest_ block, + // and returns the identifier of the fork + function createFork(string calldata) external returns (uint256); + + // Creates _and_ also selects a new fork with the given endpoint and block, + // and returns the identifier of the fork + function createSelectFork(string calldata, uint256) + external + returns (uint256); + // Creates _and_ also selects a new fork with the given endpoint and the + // latest block and returns the identifier of the fork + function createSelectFork(string calldata) external returns (uint256); + + // Takes a fork identifier created by `createFork` and + // sets the corresponding forked state as active. + function selectFork(uint256) external; + + // Returns the currently active fork + // Reverts if no fork is currently active + function activeFork() external returns (uint256); + + // Updates the currently active fork to given block number + // This is similar to `roll` but for the currently active fork + function rollFork(uint256) external; + // Updates the given fork to given block number + function rollFork(uint256 forkId, uint256 blockNumber) external; + + // Fetches the given transaction from the active fork and executes it on the current state + function transact(bytes32) external; + // Fetches the given transaction from the given fork and executes it on the current state + function transact(uint256, bytes32) external; + + // Marks that the account(s) should use persistent storage across + // fork swaps in a multifork setup, meaning, changes made to the state + // of this account will be kept when switching forks + function makePersistent(address) external; + function makePersistent(address, address) external; + function makePersistent(address, address, address) external; + function makePersistent(address[] calldata) external; + // Revokes persistent status from the address, previously added via `makePersistent` + function revokePersistent(address) external; + function revokePersistent(address[] calldata) external; + // Returns true if the account is marked as persistent + function isPersistent(address) external returns (bool); + + /// Returns the RPC url for the given alias + function rpcUrl(string calldata) external returns (string memory); + /// Returns all rpc urls and their aliases `[alias, url][]` + function rpcUrls() external returns (string[2][] memory); +} + +``` + +
+ +
+ILeverage.sol + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +interface ILeverage { + function update() external; + function calc() external returns(SumFees memory); + function calc2() external returns(SumFees memory); + + struct SumFees{ + uint256 total; + uint256 funding_paid; + uint256 funding_received; + uint256 borrowing_paid; + } + + struct FeeState{ + uint256 t1; + uint256 funding_long; + uint256 borrowing_long; + uint256 funding_short; + uint256 borrowing_short; + uint256 long_collateral; + uint256 short_collateral; + uint256 borrowing_long_sum; + uint256 borrowing_short_sum; + uint256 funding_long_sum; + uint256 funding_short_sum; + uint256 received_long_sum; + uint256 received_short_sum; + } +} +``` + +
+ +## Tool used + +Manual Review + +## Recommendation +Consider applying all static and dynamic fees on `collateral * leverage`. The accounting for each fee should be consistently updated to use the notional value if this change is implemented. \ No newline at end of file diff --git a/069.md b/069.md new file mode 100644 index 0000000..076b19a --- /dev/null +++ b/069.md @@ -0,0 +1,206 @@ +Magnificent Pewter Stork + +High + +# malicious users changes price in favor of themself + +### Summary + +malicious users can use old payloads to change price in favor of them self + +### Root Cause +Oracle::price uses RedstoneExtractor.sol contract to get a new price and when users want to open or close a position, a new payload will be fetched for them and then that is attached to their transaction, more ever in Oracle contract price will be changed per block and generate a new block in BOB network take 2 seconds, and RedstoneExractor.sol uses default validateTimestamp function which every payload is valid for 3 minutes, hence 90 blocks will be mined in 3 minutes, its mean for example if a payload is fetched in block 1 that is valid in block 90 because DEFAULT_MAX_DATA_TIMESTAMP_DELAY_SECONDS is 3 minutes by default + +```solidity +contract RedstoneExtractor is PrimaryProdDataServiceConsumerBase { + function extractPrice(bytes32 feedId, bytes calldata) + public view returns(uint256, uint256) + { + bytes32[] memory dataFeedIds = new bytes32[](1); + dataFeedIds[0] = feedId; + (uint256[] memory values, uint256 timestamp) = + getOracleNumericValuesAndTimestampFromTxMsg(dataFeedIds); + @>>>> validateTimestamp(timestamp); //!!! + return (values[0], timestamp); + } +} +abstract contract RedstoneConsumerBase{ + ... +function validateTimestamp(uint256 receivedTimestampMilliseconds) public view virtual { + RedstoneDefaultsLib.validateTimestamp(receivedTimestampMilliseconds); +} + ... +} + +library RedstoneDefaultsLib { +@>>> uint256 constant DEFAULT_MAX_DATA_TIMESTAMP_DELAY_SECONDS = 3 minutes; + uint256 constant DEFAULT_MAX_DATA_TIMESTAMP_AHEAD_SECONDS = 1 minutes; + + error TimestampFromTooLongFuture(uint256 receivedTimestampSeconds, uint256 blockTimestamp); + error TimestampIsTooOld(uint256 receivedTimestampSeconds, uint256 blockTimestamp); + + function validateTimestamp(uint256 receivedTimestampMilliseconds) internal view { + // Getting data timestamp from future seems quite unlikely + // But we've already spent too much time with different cases + // Where block.timestamp was less than dataPackage.timestamp. + // Some blockchains may case this problem as well. + // That's why we add MAX_BLOCK_TIMESTAMP_DELAY + // and allow data "from future" but with a small delay + uint256 receivedTimestampSeconds = receivedTimestampMilliseconds / 1000; + + if (block.timestamp < receivedTimestampSeconds) { + if ((receivedTimestampSeconds - block.timestamp) > DEFAULT_MAX_DATA_TIMESTAMP_AHEAD_SECONDS) { + revert TimestampFromTooLongFuture(receivedTimestampSeconds, block.timestamp); + } +@>>> } else if ((block.timestamp - receivedTimestampSeconds) > DEFAULT_MAX_DATA_TIMESTAMP_DELAY_SECONDS) { + revert TimestampIsTooOld(receivedTimestampSeconds, block.timestamp); + } + } + + function aggregateValues(uint256[] memory values) internal pure returns (uint256) { + return NumericArrayLib.pickMedian(values); + } +} +``` +### Attack Path + +1- attacker get a payload when BTC price 50,000 in block 1 +2- price goes up to 55,000 in block 20[40 sec later] +3- attacker calls `Api::open` and pass old payload to that[old payload is valid becuase block.timestamp - receivedTimestampSeconds = 40 sec < 3 min ] + + +### Impact + +- loss of funds for LPs +- positions can be liquidated with pass lower or higher price[loss of funds for users] + +### Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/RedstoneExtractor.sol#L13 + + + +### PoC +Clone [minimal foundry repo](https://github.com/redstone-finance/minimal-foundry-repo) and paste this test in `tests/counter.t.sol` also u need `lib/vendor/data-services/PrimaryProdDataServiceConsumerBase.sol` which u can find that in velar contest repo +**Coded PoC:** + + +
+ +```solidity + // SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import 'forge-std/console2.sol'; +import { PrimaryProdDataServiceConsumerBase } from "lib/vendor/data-services/PrimaryProdDataServiceConsumerBase.sol"; + + +contract OracleTest is Test { + RedstoneExtractor extractor; + + function setUp() public { + extractor = new RedstoneExtractor(); + } + + function testOracleData() public { + //GMT: Monday, September 2, 2024 1:02:07 AM, Price 121e8 + bytes memory redstonePayload1 = bytes(hex"425443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002d13759000191b040de9800000020000001123069eb25b7ced79ecb8a1a0f1f7b20ac757c476dd23e1e8fff7decf7a86b60626746cd986247d6913010053871bf71dc69482e2599add546220186b8ad3d1f1c0001000000000002ed57011e0000"); + //GMT: Monday, September 2, 2024 1:03:01 AM, Price 123e8 + bytes memory redstonePayload2 = bytes(hex"425443000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002dd231b000191b041b18800000020000001527595c9bc4e519c33e7ed918dfcfceed9a7137dfc5e781feaf5f64f7a586c7e1cdf0e7e99aec8d41a2044403adf491256d42fb9bf35616cca1f84701b88289d1c0001000000000002ed57011e0000"); + + //GMT: Monday, September 2, 2024 1:04:01 AM + vm.warp(1725239041); + + bytes memory encodedFunction1 = abi.encodeWithSignature( + "extractPrice(bytes32,bytes)", + bytes32("BTC"),redstonePayload1 + ); + + bytes memory encodedFunction2 = abi.encodeWithSignature( + "extractPrice(bytes32,bytes)", + bytes32("BTC"),redstonePayload2 + ); + bytes memory encodedFunctionWithRedstonePayload1 = abi.encodePacked( + encodedFunction1, + redstonePayload1 + ); + + bytes memory encodedFunctionWithRedstonePayload2 = abi.encodePacked( + encodedFunction2, + redstonePayload2 + ); + + + + + (bool success, bytes memory result) = address(extractor).call( + encodedFunctionWithRedstonePayload1 + ); + assertEq(success, true); + (uint price,) = abi.decode(result,(uint256, uint256)); + assertEq(price, 121e8); + + // Securely getting oracle value + (success, result) = address(extractor).call( + encodedFunctionWithRedstonePayload2 + ); + assertEq(success, true); + (price,) = abi.decode(result,(uint256, uint256)); + assertEq(price, 123e8); + + (success, result) = address(extractor).call( + encodedFunctionWithRedstonePayload1 + ); + + (price,) = abi.decode(result,(uint256, uint256)); + assertEq(price, 121e8); + + + assertEq(success, true); + + + } +} + + +contract RedstoneExtractor is PrimaryProdDataServiceConsumerBase { + function extractPrice(bytes32 feedId, bytes calldata) + public view returns(uint256, uint256) + { + bytes32[] memory dataFeedIds = new bytes32[](1); + dataFeedIds[0] = feedId; + (uint256[] memory values, uint256 timestamp) = + getOracleNumericValuesAndTimestampFromTxMsg(dataFeedIds); + validateTimestamp(timestamp); //!!! + return (values[0], timestamp); + } +} +``` +
+ +### Mitigation + +```diff + contract RedstoneExtractor is PrimaryProdDataServiceConsumerBase { ++ ++ uint lastUpdate; ++ uint lastPrice; + function extractPrice(bytes32 feedId, bytes calldata) + public view returns(uint256, uint256) + { ++ ++ if(block.timestamp - lastUpdate > 3 minutes) { + bytes32[] memory dataFeedIds = new bytes32[](1); + dataFeedIds[0] = feedId; + (uint256[] memory values, uint256 timestamp) = + getOracleNumericValuesAndTimestampFromTxMsg(dataFeedIds); + validateTimestamp(timestamp); //!!! +- return (values[0], timestamp); ++ lastUpdate = block.timestamp; ++ lastPrice = values[0]; ++ } ++ return (lastPrice, lastUpdate); + } + } + +``` diff --git a/071.md b/071.md new file mode 100644 index 0000000..66a4f25 --- /dev/null +++ b/071.md @@ -0,0 +1,95 @@ +Huge Taupe Starling + +High + +# A USDC blacklisted address can lock an amount of quote tokens in the pool + +## Summary +A USDC blacklisted address can lock an amount of quote tokens in the pool by opening a short position. Since this position can't be closed or liquidated, and the protocol always maintains the amount needed to close this position, an equivalent amount of quote tokens will remain locked up indefinitely. + +## Vulnerability Detail +As described in the comments [here](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L198-L200), the protocol will always hold a sufficient number of reserve tokens to pay out any open positions, which are locked at all times: +```python +# Burning LP tokens is not always possible, since pools are fully-backed and +# a sufficient number of reserve tokens to pay out any open positions is locked +# at all times (all positions are guaranteed to close eventually due to fees). +``` + +Given that the quote tokens in the pools are stablecoins like USDT or USDC, and the sponsor has confirmed that the protocol will use these tokens as quotes for their pools, if a USDC blacklisted address opens a short position on a BTC/USDC pool, this position will not close. When the protocol attempts to transfer the quote token to the user, the transaction will revert. Consequently, the protocol will always hold a reserve of quote tokens, which are LP funds, to ensure this position eventually closes, locking these funds in the contract. This situation can occur in two scenarios: +1. A blacklisted USDC address maliciously opens a short position to lock up LP funds. +2. A user who opened a short position before being blacklisted by USDC cannot close the position. + +The USDC contract on BOB, which has a blacklisting mechanism, can be found [here](https://explorer.gobob.xyz/address/0x27D58e4510a3963Abc70BCe554aeAC60846998ab?tab=contract). + +## Impact + +LP funds will be locked up in the contract. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L198-L200 + +## Tool used + +Manual Review + +## Recommendation +1. Allow the user to define an address for the receiver in the case where the user is blacklisted after opening a short position. +2. Use a try/catch block while transferring quote tokens to the user during liquidation. +```diff +@external +def liquidate( + id : uint256, + base_token : address, + quote_token: address, + position_id: uint256, + ctx : Ctx) -> PositionValue: + + self._INTERNAL() + + # identical to close() + user : address = tx.origin #liquidator + pool : PoolState = self.POOLS.lookup(id) + position: PositionState = self.POSITIONS.lookup(position_id) + + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token , ERR_PRECONDITIONS + assert id == position.pool , ERR_PRECONDITIONS + assert self.POSITIONS.is_liquidatable(position_id, ctx), ERR_PRECONDITIONS + + value : PositionValue = self.POSITIONS.close(position_id, ctx) + base_amt : uint256 = self.MATH.eval(0, value.deltas.base_transfer) + quote_amt: uint256 = self.MATH.eval(0, value.deltas.quote_transfer) + self.POOLS.close(id, value.deltas) + self.FEES.update(id) + + base_amt_final : Fee = self.PARAMS.liquidation_fees(base_amt) + quote_amt_final: Fee = self.PARAMS.liquidation_fees(quote_amt) + + # liquidator gets liquidation fee, user gets whatever is left + if base_amt_final.fee > 0: + assert ERC20(base_token).transfer(user, base_amt_final.fee, default_return_value=True), "ERR_ERC20" + if quote_amt_final.fee > 0: + assert ERC20(quote_token).transfer(user, quote_amt_final.fee, default_return_value=True), "ERR_ERC20" +- if base_amt_final.remaining > 0: +- assert ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True), "ERR_ERC20" +- if quote_amt_final.remaining > 0: +- assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" ++ ++ try: ++ if base_amt_final.remaining > 0: ++ ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True) ++ except Exception: ++ pass ++ ++ try: ++ if quote_amt_final.remaining > 0: ++ ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True) ++ except Exception: ++ pass + + self.INVARIANTS(id, base_token, quote_token) + + log Liquidate(user, ctx, pool, value) + return value +``` \ No newline at end of file diff --git a/072.md b/072.md new file mode 100644 index 0000000..da8f44a --- /dev/null +++ b/072.md @@ -0,0 +1,106 @@ +Magnificent Pewter Stork + +Medium + +# Funding fee will be zero because of precision loss + +### Summary + +funding fee in some cases will be zero because of precision loss + +### Root Cause +`long_utilization = base_interest / (base_reserve / 100)` +`short_utilization = quote_interest / (quote_reserve / 100)` + +`borrow_long_fee = max_fee * long_utilization` [min = 10] +`borrow_short_fee = max_fee * short_utilization` [min = 10] + +`funding_fee_long = borrow_long_fee * (long_utilization - short_utilization) / 100` + +`funding_fee_short = borrow_short_fee * (short_utilization - long_utilization) / 100` + +let's assume alice open a long position with min collateral[5e6] and leverage 2x when btc/usdt $50,000 + +long_utilization = 0.0002e8 / (1000e8 / 100) = 2e4 / 1e9 = 0.00002[round down => 0] + +short_utilization = 0 / 1e12 /100 = 0 + +borrowing_long_fee = 100 * (0) = 0 [min fee = 1] ==> 10 +borrowing_short_fee = 100 * (0) = 0 [min fee = 1] ==> 10 + +funding_fee_long = 10 * (0) = 0 +funding_fee_short = 10 * 0 = 0 + +1000 block passed + +funding_paid = 5e6 * 1000 * 0 / 1_000_000_000 = 0 +borrowing_paid = (5e6) * (1000 * 10) / 1_000_000_000 = 50 + + +** long_utilization and short_utilization are zero until **base_reserve / 100 >= base_interest** and **quote_reserve / 100 >= quote_interest** + + +### Internal pre-conditions +pool status: + "base_reserve" : 1000e8 BTC + "quote_reserve" : 1,000,000e6 USDT +```typescript +"collector" : "0xCFb56482D0A6546d17535d09f571F567189e88b3", + "symbol" : "WBTCUSDT", + "base_token" : "0x03c7054bcb39f7b2e5b2c7acb37583e32d70cfa3", + "quote_token" : "0x05d032ac25d322df992303dca074ee7392c117b9", + "base_decimals" : 8, + "quote_decimals": 6, + "blocktime_secs": 3, + "parameters" : { + "MIN_FEE" : 1, + "MAX_FEE" : 100, + "PROTOCOL_FEE" : 1000, + "LIQUIDATION_FEE" : 2, + + "MIN_LONG_COLLATERAL" : 5000000, + "MAX_LONG_COLLATERAL" : 100000000000, + "MIN_SHORT_COLLATERAL" : 10000, + "MAX_SHORT_COLLATERAL" : 200000000, + + "MIN_LONG_LEVERAGE" : 1, + "MAX_LONG_LEVERAGE" : 10, + "MIN_SHORT_LEVERAGE" : 1, + "MAX_SHORT_LEVERAGE" : 10, + + "LIQUIDATION_THRESHOLD": 5 + }, + "oracle": { + "extractor": "0x3DaF1A3ABF9dd86ee0f7Dd13a256400d01866E04", + "feed_id" : "BTC", + "decimals" : 8 + } +``` + + +### Code Snippet + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L63 + + + +### Impact + +Funding fee always is lower than what it really should be + +### PoC + +Place below test in tests/test_positions.py and run with `pytest -k test_precision_loss -s` +```python +def test_precision_loss(setup, open,VEL, STX, long, positions, pools): + setup() + open(VEL, STX, True, d(5), 10, price=d(50000), sender=long) + chain.mine(1000) + fee = positions.calc_fees(1) + assert fee.funding_paid == 0 +``` + +### Mitigation + +1-scale up long_utilzation and short_utilzation +2-set min value for long_utilzation and short_utilzation \ No newline at end of file diff --git a/073.md b/073.md new file mode 100644 index 0000000..d8a1dc0 --- /dev/null +++ b/073.md @@ -0,0 +1,47 @@ +Teeny Violet Guppy + +High + +# malicious user can become owner of any contract + +## Summary +malicious user can become owner of contract and change contract addresses and functions and steal funds. +## Vulnerability Detail +no access control check in __init__() of all contracts which allows malicious user to call the this function to set Initialized to false and call __init__2() to change contracts or variables. +## Impact +HIGH +malicious user can: + change pools contract in core such that they can place an lp_token custom contract such that the user can then burn all funds from the core contract. + changing contracts such that when users want to burn and claim funds these funds are lost. + make the contract functions in accessible to users + changing an erc20Plus contract to mint and burn funds as well as setting totalsupply to 0 + etc... + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/api.vy#L34-L47 + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/api.vy#L34-L47 + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/fees.vy#L18-L37 + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/oracle.vy#L10-L29 + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/params.vy#L8-L17 + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/pools.vy#L13-L27 + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/positions.vy#L19-L39 + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/tokens/ERC20Plus.vy#L19-L27 +## Tool used + +Manual Review + +## Recommendation + +__init__() should assert self.INITIALIZED is false + +def __init__(): + assert not self.INITIALIZED + self.DEPLOYER = msg.sender + self.INITIALIZED = False \ No newline at end of file diff --git a/075.md b/075.md new file mode 100644 index 0000000..d352eb3 --- /dev/null +++ b/075.md @@ -0,0 +1,35 @@ +Dancing Topaz Perch + +High + +# Invalid Redstone oracle payload size prevents the protocol from working properly + +## Summary +In api contract, it uses 224 bytes as maximum length for Redstone's oracle payload, but oracle price data and signatures of 3 signers exceeds 225 bytes thus reverting transactions. + +## Vulnerability Detail +In every external function of api contract, it uses 224 bytes as maximum size for Redstone oracle payload. + +However, the `RedstoneExtractor` requires oracle data from at least 3 unique signers, as implemented in `PrimaryProdDataServiceConsumerBase` contract. Each signer needs to send token price information like token identifier, price, timestamp, etc and 65 bytes of signature data. +Just with basic calculation, the oracle payload size exceeds 224 bytes. + +Here's some proof of how Redstone oracle data is used: + +- Check one of transactions from [here](https://dune.com/hatskier/redstone) that uses Redstone oracle. +- One of transaction is [this one](https://snowtrace.io/tx/0x4a3b8cb8a5287f3da1d0026ea35a968b1f93e2c24bad3e587cef450b364e69ec?chainid=43114) on Avalanche, which has 9571 bytes of data. +- Check this [Blocksec Explorer](https://app.blocksec.com/explorer/tx/avalanche/0x4a3b8cb8a5287f3da1d0026ea35a968b1f93e2c24bad3e587cef450b364e69ec?line=87), and it also shows the oracle data of 3 signers are passed. + +As shown from the proof above, the payload size of Redstone data is huge, so setting 224 bytes as upperbound reverts transactions. + +## Impact +Protocol does not work because the payload array size limit is too small. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/api.vy#L83 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/api.vy#L58 + +## Tool used +Manual Review + +## Recommendation +The upperbound size of payload array should be increased to satisfy Redstone oracle payload size. \ No newline at end of file diff --git a/077.md b/077.md new file mode 100644 index 0000000..ac26dc8 --- /dev/null +++ b/077.md @@ -0,0 +1,118 @@ +Dancing Topaz Perch + +Medium + +# Protocol incompatibility with smart contract wallets + +## Summary +Because of using `tx.origin` throughout the protocol, it limits the functionality of the protocol to EOA wallets without supporting Safe Wallets or other Smart Wallets. + +## Vulnerability Detail +In every single external function of the core contract, it uses `tx.origin` as user address, as follows: + +```vyper +@external +def mint( + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + base_amt : uint256, + quote_amt : uint256, + ctx : Ctx) -> uint256: + + self._INTERNAL() + + user : address = tx.origin + + ... +``` + +Using `tx.origin` as user address does limit functionality of the protocol because it does not support smart contract wallets. + +## Impact +Limitation of the protocol functionality of only supporting EOAs. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L166 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L202 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L241 + +## Tool used +Manual Review + +## Recommendation +Instead of using `tx.origin` as user address, the api contract should pass the user address to the core contract, for example as follows: + +```diff +# api.vy + +@external +def mint( + base_token : address, #ERC20 + quote_token : address, #ERC20 + lp_token : address, #ERC20Plus + base_amt : uint256, + quote_amt : uint256, + desired : uint256, + slippage : uint256, + payload : Bytes[224] +) -> uint256: + """ + @notice Provide liquidity to the pool + @param base_token Token representing the base coin of the pool (e.g. BTC) + @param quote_token Token representing the quote coin of the pool (e.g. USDT) + @param lp_token Token representing shares of the pool's liquidity + @param base_amt Number of base tokens to provide + @param quote_amt Number of quote tokens to provide + @param desired Price to provide liquidity at (unit price using onchain + representation for quote_token, e.g. 1.50$ would be + 1500000 for USDT with 6 decimals) + @param slippage Acceptable deviaton of oracle price from desired price + (same units as desired e.g. to allow 5 cents of slippage, + send 50000). + @param payload Signed Redstone oracle payload + """ + ctx: Ctx = self.CONTEXT(base_token, quote_token, desired, slippage, payload) +- return self.CORE.mint(1, base_token, quote_token, lp_token, base_amt, quote_amt, ctx) ++ return self.CORE.mint(msg.sender, 1, base_token, quote_token, lp_token, base_amt, quote_amt, ctx) + + +# core.vy +@external +def mint( ++ user : address, + id : uint256, + base_token : address, + quote_token : address, + lp_token : address, + base_amt : uint256, + quote_amt : uint256, + ctx : Ctx) -> uint256: + + self._INTERNAL() + +- user : address = tx.origin + total_supply: uint256 = ERC20(lp_token).totalSupply() + pool : PoolState = self.POOLS.lookup(id) + lp_amt : uint256 = self.POOLS.calc_mint(id, base_amt, quote_amt, total_supply, ctx) + + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token, ERR_PRECONDITIONS + assert pool.lp_token == lp_token , ERR_PRECONDITIONS + assert base_amt > 0 or quote_amt > 0 , ERR_PRECONDITIONS + assert lp_amt > 0 , ERR_PRECONDITIONS + + assert ERC20(base_token).transferFrom(user, self, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transferFrom(user, self, quote_amt, default_return_value=True), "ERR_ERC20" + assert ERC20Plus(lp_token).mint(user, lp_amt), "ERR_ERC20" + + self.POOLS.mint(id, base_amt, quote_amt) + self.FEES.update(id) + + self.INVARIANTS(id, base_token, quote_token) + + log Mint(user, ctx, pool, total_supply, lp_amt, base_amt, quote_amt) + + return lp_amt +``` diff --git a/079.md b/079.md new file mode 100644 index 0000000..1402db6 --- /dev/null +++ b/079.md @@ -0,0 +1,49 @@ +Dancing Topaz Perch + +Medium + +# Not decreasing oracle timestamp validation leads to DoS for protocol users + +## Summary +The protocol only allows equal or increased timestamp of oracle prices whenever an action happens in the protocol. +This validation is wrong since it will lead to DoS for users. + +## Vulnerability Detail +The protocol uses RedStone oracle, where token prices are added as a part of calldata of transactions. +In RedStone oracle, it allows prices from 3 minutes old upto 1 minute in the future, as implemented in `RedstoneDefaultsLib.sol`. + +```vyper +@internal +def extract_price( + quote_decimals: uint256, + payload : Bytes[224] +) -> uint256: + price: uint256 = 0 + ts : uint256 = 0 + (price, ts) = self.EXTRACTOR.extractPrice(self.FEED_ID, payload) + + # Redstone allows prices ~10 seconds old, discourage replay attacks + assert ts >= self.TIMESTAMP, "ERR_ORACLE" + self.TIMESTAMP = ts +``` + +In `oracle.vy`, it extracts the token price from the RedStone payload, which also includes the timestamp of which the prices were generated. +As shown in the code snippet, the protocol reverts when the timestamp extracted from the calldata is smaller than the stored timestamp, thus forcing timestamps only increase or equal to previous one. +This means that the users who execute transaction with price 1 minute old gets reverted when there is another transaction executed with price 30 seconds old. + +NOTE: The network speed of all around the world is not same, so there can be considerable delays based on the location, api availability, etc. + +By abusing this vulnerability, an attacker can regularly make transactions with newest prices which will revert all other transactions with slightly older price data like 10-20 seconds older, can be all reverted. + +## Impact +The vulnerability causes DoS for users who execute transactions with slightly older RedStone oracle data. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/oracle.vy#L91-L93 + +## Tool used +Manual Review + +## Recommendation +It's recommended to remove that non-decreasing timestamp validation. +If the protocol wants more strict oracle price validation than the RedStone does, it can just use the difference between oracle timestamp and current timestamp. diff --git a/080.md b/080.md new file mode 100644 index 0000000..b9851e0 --- /dev/null +++ b/080.md @@ -0,0 +1,35 @@ +Dancing Topaz Perch + +Medium + +# Protocol actions might revert because of tokens that revert on zero value transfer + +## Summary +Some actions in the protocol transfer tokens without checking the amount to transfer, which might lead to unexpected revert issue when either base/quote token reverts on zero value transfer. + +## Vulnerability Detail +```vyper +@external +def mint( + ... + assert ERC20(base_token).transferFrom(user, self, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transferFrom(user, self, quote_amt, default_return_value=True), "ERR_ERC20" + ... +``` +In some actions like `mint`, the protocol transfers base and quote assets from the user to the pool by calling `transferFrom`. +However, it does not check if `base_amt` or `quote_amt` is zero. Some ERC20 tokens revert when transferring zero value between accounts. + +If those tokens are integrated with Verla protocol, the protocol stops working. + +## Impact +DoS of the protocol when base or quote asset token reverts on zero value transfer. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L177-L178 +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L215-L216 + +## Tool used +Manual Review + +## Recommendation +It should check the token amount to transfer before it actually calls `transfer` or `transferFrom`. diff --git a/081.md b/081.md new file mode 100644 index 0000000..3f48731 --- /dev/null +++ b/081.md @@ -0,0 +1,60 @@ +Dancing Topaz Perch + +Medium + +# Missing `default_return_value` for some token transfers + +## Summary +`default_return_value` is missing in some transfer actions, which might lead to revert when base or quote token is an ERC20 that does not return boolean after transfer, e.g. USDT + +## Vulnerability Detail +Some ERC20 tokens including USDT does not return a boolean value in `transfer` or `transferFrom` external functions. +For this reason, `default_return_value` is used across the protocol not to revert when these tokens are integrated. + +```vyper +@external +def open( + ... + + if long: assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" + else : assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" + + # transfer protocol fees to separate contract + if long: assert ERC20(quote_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" + else : assert ERC20(base_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" + + ... +``` + +However, in `open` function, setting `default_return_value` is missing, which leads to revert when this kind of token is integrated. + +## Impact +Users can not open positions when tokens like USDT are integrated with Verla protocol. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L253-L258 + +## Tool used +Manual Review + +## Recommendation +`default_return_value` attribute has to be used in `open` function as well, shown below: + +``````diff +@external +def open( + ... + +- if long: assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" +- else : assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" ++ if long: assert ERC20(quote_token).transferFrom(user, self, collateral0, default_return_value=True), "ERR_ERC20" ++ else : assert ERC20(base_token).transferFrom(user, self, collateral0, default_return_value=True), "ERR_ERC20" + + # transfer protocol fees to separate contract +- if long: assert ERC20(quote_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" +- else : assert ERC20(base_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" ++ if long: assert ERC20(quote_token).transfer(self.COLLECTOR, fee, default_return_value=True), "ERR_ERC20" ++ else : assert ERC20(base_token).transfer(self.COLLECTOR, fee, default_return_value=True), "ERR_ERC20" + + ... +``` diff --git a/084.md b/084.md new file mode 100644 index 0000000..6fce409 --- /dev/null +++ b/084.md @@ -0,0 +1,38 @@ +Kind Banana Sloth + +High + +# Current slippage protection forces transaction to revert if price is too good. + +## Summary +Current slippage protection forces transaction to revert if price is too good. + +## Vulnerability Detail +When the user wishes to open a position, they specify a desired price and the max slippage they're willing to accept. +The current implementation is that if the delta of the desired price and the actual price is more than the max slippage, the transaction reverts + +```vyper +def check_slippage(current: uint256, desired: uint256, slippage: uint256) -> bool: + if current > desired: return (current - desired) <= slippage + else : return (desired - current) <= slippage +``` + +The problem is that this doesn't take into account the situation where the price has moved more than slippage in the right direction for the user. +For example, if the user wishes to open a long with desired price $1 and max slippage they've set is $0.10 and price drops to $0.8, current implementation would revert, although it shouldn't - user would only benefit if they could open their long position at a lower price. + +Same thing for shorts if the price has increased prior to opening the position. + +This is basically the equivalent of a swap transaction failing due to receiving more tokens than specified. + +## Impact +DoS, loss of funds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L122 + +## Tool used + +Manual Review + +## Recommendation +Depending on whether the position is long or short, check for price movements only in certain direction \ No newline at end of file diff --git a/085.md b/085.md new file mode 100644 index 0000000..615f3a0 --- /dev/null +++ b/085.md @@ -0,0 +1,36 @@ +Kind Banana Sloth + +Medium + +# First depositor could DoS the pool + +## Summary +First depositor could DoS the pool + +## Vulnerability Detail +Currently, when adding liquidity to a pool, the way LP tokens are calculated is the following: +1. If LP total supply is 0, mint LP tokens equivalent the mint value +2. If LP total supply is not 0, mint LP tokens equivalent to `mintValue * lpSupply / poolValue` + +```vyper +def f(mv: uint256, pv: uint256, ts: uint256) -> uint256: + if ts == 0: return mv + else : return (mv * ts) / pv # audit -> will revert if pv == 0 +``` + +However, this opens up a problem where the first user can deposit a dust amount in the pool which has a value of just 1 wei and if the price before the next user deposits, the pool value will round down to 0. Then any subsequent attempts to add liquidity will fail, due to division by 0. + +Unless the price goes back up, the pool will be DoS'd. + +## Impact +DoS + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L178 + +## Tool used + +Manual Review + +## Recommendation +Add a minimum liquidity requirement. \ No newline at end of file diff --git a/087.md b/087.md new file mode 100644 index 0000000..03782cf --- /dev/null +++ b/087.md @@ -0,0 +1,52 @@ +Kind Banana Sloth + +High + +# User might be unable to close a position if the remaining interest and reserves are a dust amount. + +## Summary +User might be unable to close a position if the remaining interest and reserves are a dust amount. + +## Vulnerability Detail +At the end of `close`, `fees.UPDATE` is called. + +```vyper + value : PositionValue = self.POSITIONS.close(position_id, ctx) + base_amt : uint256 = self.MATH.eval(0, value.deltas.base_transfer) + quote_amt: uint256 = self.MATH.eval(0, value.deltas.quote_transfer) + self.POOLS.close(id, value.deltas) + self.FEES.update(id) +``` + +The way the function works is that it fetches the previous fee terms, applies them for the time period since then and then calculates the new fee terms. The problem lies in the calculation of the new fee terms and precisely in the utilization calculation + +```vyper +def dynamic_fees(pool: PoolState) -> DynFees: + long_utilization : uint256 = self.utilization(pool.base_reserves, pool.base_interest) + short_utilization: uint256 = self.utilization(pool.quote_reserves, pool.quote_interest) +``` + +```vyper +def utilization(reserves: uint256, interest: uint256) -> uint256: + """ + Reserve utilization in percent (rounded down). + """ + return 0 if (reserves == 0 or interest == 0) else (interest / (reserves / 100)) +``` + +As we can see, the idea is to calculate a percentage. However, due to the division of `reserves / 100`, if `reserves < 100` , this would round down to 0 and then revert due to division by 0, which would ultimately not allow a user to close their position. + +Furthermore, if the user has the only position on one's side (whether that be short or long), LP providers can abuse that to make his position impossible to close. + +## Impact +Loss of funds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L63 + +## Tool used + +Manual Review + +## Recommendation +Instead of calculating `(interest / (reserves / 100))`, better use `(interest * 100) / reserves` \ No newline at end of file diff --git a/089.md b/089.md new file mode 100644 index 0000000..412a3f9 --- /dev/null +++ b/089.md @@ -0,0 +1,32 @@ +Kind Banana Sloth + +High + +# Whale LP providers can open positions on both sides to force users into high fees. + +## Summary +Whale LP providers can open positions on both sides to force users into high fees. + +## Vulnerability Detail +LP fees within the protocol are based on utilization percentage of the total funds in the pool. The problem is that this could easily be abused by LP providers in the following way. + +Consider a pool where the majority of the liquidity is provided by a single user. +1. Users have opened positions at a relatively low utilization ratio +2. The whale LP provider opens same size positions in both directions at 1 leverage. +3. This increases everyone's fees. Given that the LP provider holds majority of the liquidity, most of the new fees will go towards them, making them a profit. + +As long as the whale can maintain majority of the liquidity provided, attack remains profitable. If at any point they can no longer afford maintaining majority, they can simply close their positions without taking a loss, so this is basically risk-free. + + +## Impact +Loss of funds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L33 + +## Tool used + +Manual Review + +## Recommendation +Consider a different way to calculate fees \ No newline at end of file diff --git a/092.md b/092.md new file mode 100644 index 0000000..0ab5239 --- /dev/null +++ b/092.md @@ -0,0 +1,35 @@ +Kind Banana Sloth + +Medium + +# Funding rate will be lost if there's no positions on one of the sides + +## Summary +Funding rate will be lost if there's no positions on one of the sides + +## Vulnerability Detail +The way funding rate works is that the side which has larger utilization pays a funding fee to the other side's position holders. The exact amount is based on the delta of both utilization percentages. + +```vyper +@internal +@view +def funding_fee(base_fee: uint256, col1: uint256, col2: uint256) -> uint256: + imb: uint256 = self.imbalance(col1, col2) + if imb == 0: return 0 + else : return self.check_fee(self.scale(base_fee, imb)) +``` + +The problem lies in the situation where there's only position on one side. In this case said position holders will pay the funding fees, but no one will actually receive them and they'll be permanently stuck in the contract, marked as `collateral` + +## Impact +Loss of funds + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/params.vy#L84 + +## Tool used + +Manual Review + +## Recommendation +Do not calculate funding fees if either side has no position holders \ No newline at end of file diff --git a/094.md b/094.md new file mode 100644 index 0000000..73e13c4 --- /dev/null +++ b/094.md @@ -0,0 +1,31 @@ +Kind Banana Sloth + +High + +# User can sandwich their own position close to get back all of their position fees + +## Summary +User can sandwich their own position close to get back all of their position fees + +## Vulnerability Detail +Within the protocol, borrowing fees are only distributed to LP providers when the position is closed. Up until then, they remain within the position. +The problem is that in this way, fees are distributed evenly to LP providers, without taking into account the longevity of their LP provision. + +This allows a user to avoid paying fees in the following way: +1. Flashloan a large sum and add it as liquidity +2. Close their position and let the fees be distributed (with most going back to them as they've got majority in the pool) +3. WIthdraw their LP tokens +4. Pay back the flashloan + +## Impact +Users can avoid paying borrowing fees. + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L156 + +## Tool used + +Manual Review + +## Recommendation +Implement a system where fees are gradually distributed to LP providers. \ No newline at end of file diff --git a/099.md b/099.md new file mode 100644 index 0000000..e8a8952 --- /dev/null +++ b/099.md @@ -0,0 +1,71 @@ +Mammoth Blonde Walrus + +Medium + +# oracle.vy::extract_price can accept stale prices because allow returned timestamp equals to previous price response timestamp + +## Summary +oracle.vy::extract_price can accept stale prices because timestamp comparission accept a timestamp that equals the previous price response timestamp. + +## Vulnerability Detail +extract_price could accept the same price response for an arbitrary ammount of time if the new response timestamp equals the previous registered timestamp. +oracle.vy defines TIMESTAMP (initially with 0 value) variable to keep track of previous price response. + +oracle extract_price is used to retrieve price from oracle. +ts variable is assigned to latest oracle price response +```solidity +def extract_price( + quote_decimals: uint256, + payload : Bytes[224] +) -> uint256: + price: uint256 = 0 + ts : uint256 = 0 + (price, ts) = self.EXTRACTOR.extractPrice(self.FEED_ID, payload) +``` +Then this ts variable is compared to TIMESTAMP to see if the response is fresh: +```solidity + (price, ts) = self.EXTRACTOR.extractPrice(self.FEED_ID, payload) + + # Redstone allows prices ~10 seconds old, discourage replay attacks + assert ts >= self.TIMESTAMP, "ERR_ORACLE" # <@= +``` +However this comparission allows ts to equal TIMESTAMP, so a stalled price response can be utilized over and over +Eg: +Assume the latest response price timestamp is t1 (1725868056), now TIMESTAMP is updated to 1725868056 +Then a day passed t2=(1728439581) and the oracle is stalled, extractPrice is called again. +Oracle response timestamp returns t1 so the comparission performed is: +```solidity + # ts = 1725868056 + # TIMESTAMP = 1725868056 + assert ts >= self.TIMESTAMP, "ERR_ORACLE" +``` +ie +```solidity +1725868056 >= 1725868056 +``` +So the stalled oracle response is accepted + +## Impact +Comparission allows to accept stalled oracle responses + +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/oracle.vy#L83-L92 + +## Tool used + +Manual Review + +## Recommendation +Remove equal operator from comparission: +```solidity +def extract_price( + quote_decimals: uint256, + payload : Bytes[224] +) -> uint256: + price: uint256 = 0 + ts : uint256 = 0 + (price, ts) = self.EXTRACTOR.extractPrice(self.FEED_ID, payload) + + # Redstone allows prices ~10 seconds old, discourage replay attacks + assert ts > self.TIMESTAMP, "ERR_ORACLE" # <@ +``` \ No newline at end of file diff --git a/101.md b/101.md new file mode 100644 index 0000000..2f5682a --- /dev/null +++ b/101.md @@ -0,0 +1,55 @@ +Urban Jetblack Iguana + +Medium + +# Fee on transfer tokens not supported + +## Summary + +Some tokens like USDT used by the protocol take a transfer fee, but the [core::open](https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L230) function does not properly account for fee-on-transfer tokens, leading to incorrect handling of the transferred amount. This can result in inaccurate collateral and fee calculations due to the contract assuming the full amount specified in the transferFrom operation is received. + +## Vulnerability Detail + +Fee-on-transfer tokens deduct a fee during the transfer, meaning the contract receives fewer tokens than the collateral0 amount specified in the transferFrom call. The current code calculates the protocol fee and collateral using the full collateral0 before the token transfer takes place, leading to inaccurate calculations when fee-on-transfer tokens are involved. + +```vyper +cf : Fee = self.PARAMS.static_fees(collateral0) +fee : uint256 = cf.fee +collateral : uint256 = cf.remaining + +// some asserts... + +if long: + assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" +else: + assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" +``` + +This calculation assumes that the entire collateral0 amount is transferred, but in the case of fee-on-transfer tokens, the actual received amount will be less. + +## Impact + +This could result in incorrect fee and collateral calculations, potentially causing the protocol to fail in future operations or incorrectly track token balances. + +## Code Snippet + +```vyper +cf : Fee = self.PARAMS.static_fees(collateral0) +fee : uint256 = cf.fee +collateral : uint256 = cf.remaining + +// some asserts... + +if long: + assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" +else: + assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" +``` + +## Tool used + +Manual Review + +## Recommendation + +To address this vulnerability, the protocol should calculate the actual transferred amount by subtracting the balance difference between the token contract before and after the transferFrom call. This ensures that the correct amount of tokens received is used for fee and collateral calculations. \ No newline at end of file diff --git a/102.md b/102.md new file mode 100644 index 0000000..0abfd36 --- /dev/null +++ b/102.md @@ -0,0 +1,43 @@ +Fierce Pecan Chipmunk + +Medium + +# Returned redstone prices not validated which can lead to protocol operations being run on bogus prices + +## Summary + +RedstoneExtractor doesn't fully validate the returned price and just uses it as is to conduct protocol operations. + +## Vulnerability Detail + +In `extractPrice`, the `getOracleNumericValuesAndTimestampFromTxMsg` function returns the price and timestamp. As can be seen, only the timestamp is validated. The returned price is not. + +```solidity + function extractPrice(bytes32 feedId, bytes calldata) + public view returns(uint256, uint256) + { + bytes32[] memory dataFeedIds = new bytes32[](1); + dataFeedIds[0] = feedId; + (uint256[] memory values, uint256 timestamp) = + getOracleNumericValuesAndTimestampFromTxMsg(dataFeedIds); + validateTimestamp(timestamp); //!!! + return (values[0], timestamp); + } +} +``` +If the price feed is manipulated in any way or in times of serious volatility on the market, the protocol operations will be run on these prices with which malicious users can use to gain a significant advantage. For instance, during liquidations, since liquidation bots are in use, the bots can sufficiently monitor the price feed and act on the volatile price, (for example, a price of 0, which is usually a subject to filtration), liquidating the trader position which is perfectly healthy otherwise, obtaining the collateral with a substantial discount at the expense of the trader. + +## Impact + +Protocol operations will be run on volatile prices, potentially zero prices, which can lead to various unintended consequences for the users. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/RedstoneExtractor.sol#L12 + +## Tool used +Manual Code Review + +## Recommendation + +Recommend checking and ensuring that the returned price is > 0 \ No newline at end of file diff --git a/103.md b/103.md new file mode 100644 index 0000000..3169373 --- /dev/null +++ b/103.md @@ -0,0 +1,24 @@ +Colossal Rosewood Worm + +High + +# Wrong decimal conversion when checking slippage in oracle.vy + +## Summary +Wrong decimal conversion when checking slippage in `oracle.vy` + +## Vulnerability Detail +In `oracle.vy`, the price used to compare to the slippage here https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L122-L124 is gotten from the oracle (actual price) while the desired price argument is user provided. +The issue is that the decimal of price gotten from the oracle is converted to ensure same precision with the protocol https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L89-L102 , but the price obtained from the user is not converted to match the same precision with what the current price gives hence when there is a decimal difference `oracle.check_slippage()` will always revert. +This ultimately blocks all protocol functions as all protocol functions require users to put in slippage and desired price. +And this is not a user mistake as +1) It will affect all users. 2) Users are expected to just provide price not do any decimal conversion + +## Impact +Serious DOS of all protcol functions + +## Tools Used +Manual Review + +## Recommended Mitigation Steps +Convert the user provided price to the same precision as the one gotten from the oracle \ No newline at end of file diff --git a/105.md b/105.md new file mode 100644 index 0000000..c03e9bd --- /dev/null +++ b/105.md @@ -0,0 +1,24 @@ +Colossal Rosewood Worm + +High + +# Slippage isn't implmented properly on mint() and burn() functions + +## Summary +Slippage isn't implmented properly on `mint()` and `burn()` functions especially on `burn()` function. + +## Vulnerability Detail +Slippage is enforced by using price discreprancy in `oracle.vy` (current price, desired price and slippage ), so current slippage implementation helps check sudden price movement not actual token payout. But in `pool.vy` the actual amount that `core.vy` pays out the LP provider when minting and burning LP tokens is calculated using the total pool value in the form of quote tokens and current circulating LP tokens https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L163-L178 , +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L213-L228 , the issue is that current circulating LP tokens and pool reserve will be changing constantly because of minting, burning and closing positions (especially profitable ones). + +## Proof of concept and impact +Let's look at this scenario ; User wants to burn 100 Lp tokens he currently checks `pool.calc_burn()` a view function for how much USDT he will get in return (converting pool value to usdt) . Let's assign some varaibles; lp token total supply = 1000; pool reserve value in usdt = 2000. +Using the calculation in the link I pasted above (lp * pv) /ts = (100 * 2000) / 1000 = 200 usdt will be returned as the result. +Happy with the result he goes ahead to call burn in `apy.vy` to burn 100 lp tokens but before he called it a profitable trader closed their positions taking out a net 1,000 usdt value out of the pool (I used 'net' because the collateral the trader deposited is absorbed into the pool reserve ). +Then as the user that wants to burn calls `burn()` new pay out value is calculated as (100 * 1000)/ 1000 = 100. So Lp provider ends up getting 100 USDT instead of 200 and there was no slippage to ensure he will get 200 as the price of tokens do not affect the value in the pool. + +## Tools Used +Manual Review + +## Recommendation +Set a minimum amount of quote/LP tokens user is supposed to expect diff --git a/106.md b/106.md new file mode 100644 index 0000000..3bc3495 --- /dev/null +++ b/106.md @@ -0,0 +1,20 @@ +Colossal Rosewood Worm + +Medium + +# LP tokens can be burnt without total supply being reduced + +## Summary +LP tokens can be burnt without total supply being reduced + +## Vulnerability Detail and Impact + +In concept of normal ERC20 tokens, we all know that sending tokens to address(0) is considered a burning mechansim/implementation and there are many reasons protocols will want to burn tokens such as supply control etc. and from the contest readMe the project will integrate with normal ERC20 tokens so it is expected to assume that the LP token should operate normally. +But in `ERC20plus.vy` there is no implementation that reduces supply when tokens are sent to the zero address all transfers are treated the same way and this is not supposed to be so. So in cases when the protcol wants to burn token and sends to address(0) or a user sends tokens to address(0), other LP providers will expect the LP tokens to have a reduced supply but those tokens are still counted as total supply and used to influence pay out amounts when minting and burning. +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/tokens/ERC20Plus.vy#L44-L57 + +## Tool Used +Manual Review + +## Recommendation +Change total supply when tokens are transferred to address(0) or do not allow transfers to addr(0) diff --git a/107.md b/107.md new file mode 100644 index 0000000..606a767 --- /dev/null +++ b/107.md @@ -0,0 +1,90 @@ +Bent Teal Wolf + +High + +# Protocol wrongly assumes stablecoins are worth 1 USD, leading to multiple inconsistencies + +## Summary + +In Velar, traders are able to `open()` a position to long/short a volatile asset such as BTC, called `base token`. In order to open such a position, the trader must deposit collateral to back it at the same time. This collateral takes the form of a stablecoin such as USDT, called `quote token`. + +In this case, the trader has opened a position in the `USDT/BTC` pool. Each pool has **1** associated oracle responsible for providing the USD price of the `base token` (the volatile asset : BTC). + +Stablecoins are hardly pegged to 1 USD and their value can fluctuate and reach `$1.01` ; `$0.99` or even higher/lower which happened during the [Silicon Valley Bank Fallout](https://coinmarketcap.com/academy/article/explaining-the-silicon-valley-bank-fallout-and-usdc-de-peg). + +## Vulnerability details + +The issue arises because the protocol fails to verify the USD price of the `quote token` (the stablecoin). + +The `api::CONTEXT()` internal function is responsible for retrieving the USD price of the volatile `base token` before performing multiple internal calculations based on it. + + + +```python +def CONTEXT( + base_token : address, + quote_token: address, + desired : uint256, + slippage : uint256, + payload : Bytes[224] +) -> Ctx: + base_decimals : uint256 = convert(ERC20Plus(base_token).decimals(), uint256) + quote_decimals: uint256 = convert(ERC20Plus(quote_token).decimals(), uint256) + # this will revert on error + price : uint256 = self.ORACLE.price(quote_decimals, + desired, + slippage, + payload) + return Ctx({ + price : price, + base_decimals : base_decimals, + quote_decimals: quote_decimals, + }) +``` + +However, the USD price of the "stable" `quote token` is never read from any oracle. + +## Impact + +The protocol assumes stablecoins deposited by traders equal `$1.00` while it can derive from at least 1%. + +Multiple issues arise from this oversight. + +Regarding the opening of a position : +- a trader can open a bigger position than he should be allowed, in case `quote token == $0.99` because the protocol will overvalue the price of his collateral to `$1.00` +- a trader will open a smaller position than expected, in case `quote token == $1.01` because the protocol will undervalue the price of his collateral to `$1.00` + +Regarding the liquidation of a position : +- a position can be liquidated earlier than it should, in case `quote token == $1.01` because the protocol will undervalue the price of his collateral to `$1.00` +- a position can be liquidated later than it should, in case `quote token == $0.99` because the protocol will overvalue the price of his collateral to `$1.00` + +## PoC + +Let the following scenario on the `USDT/BTC` market where : +- `USDT` == `$1.00` +- `BTC` == `$50,000` +- `liquidation threshold` == `90%` : if the price of `BTC` drops to at least to `$50,000 * 0.9 == $45,000` the position is liquidatable + +> For the sake of simplicity, fees have been omitted. + +A trader opens a long position with `50,000 USDT` as margin, so his collateral is worth `$50,000`. + +Time passes and the price of `USDT` and `BTC` fluctuate like such : +- `USDT` == `$1.01` : collateral is now worth `1.01 * 50,000 == $50,500` +- `BTC` == `$45,000` + +The trader's actual position is losing but is still above the liquidation threshold because, in this case, the position is liquidatable if the price of `BTC` reaches : `$50,500 * 0.9 == $45,450`. + +However, the protocol values `1 USDT` to equal `$1.00` meaning his collateral is still worth : `50,000 USDT * $1.00 == $50,000` rather than `$50,500`. + +This means the position is liquidatable if the price of `BTC` reaches : `$50,000 * 0.9 == $45,000` (which is the case). + +Thus, the trader's position is subject to liquidation while it should not in reality. + +## Tools used + +Manual review + +## Mitigation + +Add another oracle responsible for retrieving the USD price of the stablecoin used in the pool and perform the calculations based upon it. diff --git a/108.md b/108.md new file mode 100644 index 0000000..06c0201 --- /dev/null +++ b/108.md @@ -0,0 +1,50 @@ +Atomic Licorice Seagull + +Medium + +# The oracle doesn't check for unreasonably high prices + +### Details + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/oracle.vy#L128 + +in `check_price` function in the `oracle.vy` : + +```python +@internal +@pure +def check_price(price: uint256) -> bool: + return price > 0 +``` + +The function only checks if the price is greater than zero, but it doesn't check for unreasonably high prices. This could lead to potential issues in the system if an extremely high price is reported by the oracle. + +In a decentralized finance (DeFi) system dealing with leveraged positions, it's crucial to have safeguards against extreme price movements, whether they're legitimate market events or the result of oracle manipulation. + +The impact on the project could be severe: + +- An extremely high price could trigger unnecessary liquidations for short positions. + +- Incorrect PnL Calculations: The system calculates profit and loss based on these prices. An unreasonably high price could lead to incorrect calculations, potentially allowing users to extract more value from the system than they should. + +- Extreme prices could lead to imbalanced pool states, affecting the overall health and stability of the system. + +- Malicious actors could potentially exploit this to manipulate the system's state in their favor. + + +## mitigation + +To mitigate this vulnerability, the `check_price` function should also include an upper bound check. For example: + +```python +@internal +@view +def check_price(price: uint256) -> bool: + return price > 0 and price <= self.MAX_ACCEPTABLE_PRICE +``` + +Where `MAX_ACCEPTABLE_PRICE` is a carefully chosen upper limit that allows for significant price movements but prevents unreasonable values. This limit could be dynamically adjusted based on market conditions or set by governance. + +By implementing this additional check, the system would be more robust against extreme price events or potential oracle manipulations, enhancing the overall security and stability of the protocol. + + diff --git a/111.md b/111.md new file mode 100644 index 0000000..73bfead --- /dev/null +++ b/111.md @@ -0,0 +1,64 @@ +Eager Ceramic Deer + +High + +# Reentrancy Vulnerability in Positions Contract: High-Risk Exposure to Fund Drainage + +## Summary +There is a reentrancy vulnerability in the `insert_user_position` function of the Positions contract. This vulnerability allows an attacker to drain funds from the contract by reentering the `insert_user_position` function. +## Vulnerability Detail +```python +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/positions.vy#L85 + +``` +The `insert_user_position` function is vulnerable to reentrancy because it does not have a reentrancy lock. This allows an attacker to call the function recursively, draining funds from the contract. + +An attacker can create a contract that reenters the `insert_user_position` function to drain funds. Here's a step-by-step example: + +- The attacker creates a contract that calls the open function to create a new position. +- The open function calls the` insert_user_position` function to add the new position to the `USER_POSITIONS` mapping. +- The `insert_user_position` function executes a callback function that reenters the `insert_user_position` function. +- The reentrant call to `insert_user_position` adds another position to the `USER_POSITIONS` mapping, potentially draining funds. + +## Impact +- The impact of this vulnerability is high, as it allows an attacker to drain funds from the contract. +- This could result in significant financial losses for the contract owners and users. + +## Code Snippet (POC) +```python +from ape import reverts, project +import pytest +from conftest import ctx, d + +# contract that will reenters the insert_user_position function +class ReentrancyAttack: + def __init__(self, positions, owner): + self.positions = positions + self.owner = owner + + def attack(self, user, id): + # now lets reenter the insert_user_position function + self.positions.insert_user_position(user, id, sender=self.owner) + +# lets write our test +def test_reentrancy_vulnerability(setup, positions, params, pools, open, VEL, STX, long, owner): + setup() + + deploy our contract + attack_contract = ReentrancyAttack(positions, owner) + + # Open a position to create a user position + open(VEL, STX, True, d(2), 10, price=d(5), sender=long) + + # now reenter the insert_user_position function to drain funds + with reverts("REENTRANCY"): + attack_contract.attack(long, 1) +``` + +## Tool used + +Manual Review + +## Recommendation +- We can implement a reentrancy lock by using a `boolean` variable that prevents the `insert_user_position` function from being called recursively. +- we can also use a mutex using a mapping of user addresses to `boolean` values, where each user can only have one pending operation at a time. \ No newline at end of file diff --git a/113.md b/113.md new file mode 100644 index 0000000..07309d5 --- /dev/null +++ b/113.md @@ -0,0 +1,57 @@ +Dancing Topaz Perch + +Medium + +# The protocol should consider the variance of quote tokens' price + +### Summary + +The protocol uses the `USD` price of base token and it assumes quote token is stablecoin: fixed conversion rate of `1 USD = 1 quote token`. +But the price of stablecoin can be changed. +There is a recent depeg event in March 2023, where USDC price went as low as 87 cents([Reference](https://decrypt.co/123211/usdc-stablecoin-depegs-90-cents-circle-exposure-silicon-valley-bank)). +As a result, if there is variance of quote token, all the conversion between base and quote is incorrect and this can break the protocol's design. + +### Root Cause + +In [`oracle.vy:89`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/oracle.vy#L89), it returns the `USD` price of base token regardless of quote token's price. + +### Internal pre-conditions + +None + +### External pre-conditions + +None + +### Attack Path + +None + +### Impact + +The quote token's price variance causes incorrect calculation of token amount and breaks the protocol's design. + +### PoC + +The oracle provides the `USD` price of base token and the protocol uses the `USD` price regardless of quote token's price [here](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/math.vy#L73-L86). + +```vyper +def base_to_quote(tokens: uint256, ctx: Ctx) -> uint256: + lifted : Tokens = self.lift(Tokens({base: tokens, quote: ctx.price}), ctx) + amt0 : uint256 = self.to_amount(lifted.quote, lifted.base, self.one(ctx)) + lowered: Tokens = self.lower(Tokens({base: 0, quote: amt0}), ctx) + return lowered.quote +def quote_to_base(tokens: uint256, ctx: Ctx) -> uint256: # tokens(0, 100ke6), ctx(50ke12, 18, 6) + l1 : Tokens = self.lift(Tokens({base: 0, quote: tokens}), ctx) # tokens(0,100ke18) + l2 : Tokens = self.lift(Tokens({base: 0, quote: ctx.price}), ctx) # tokens(0,50ke18) + vol0 : uint256 = self.from_amount(l1.quote, l2.quote, self.one(ctx)) # 100ke18 * 1e18 / 50ke18 = 2e18 + lowered: Tokens = self.lower(Tokens({base: vol0, quote: 0}), ctx) # 2e18 + return lowered.base +``` + +If the quote token's price is changed, these functions return incorrect value. + + +### Mitigation + +Ideally, there needs to be an additional oracle to check current Price of quote token and take it's price into the consideration. \ No newline at end of file diff --git a/114.md b/114.md new file mode 100644 index 0000000..4ad1dd0 --- /dev/null +++ b/114.md @@ -0,0 +1,48 @@ +Dancing Topaz Perch + +High + +# The `api.burn` function should have cool down period + +### Summary + +Liquidity providers can burn their liquidity tokens anytime regardless minting time. +Attackers can take fees without contributing to the pool using this vulnerability. + +### Root Cause + +There is no cool down period in the [`api.burn`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/api.vy#L104-L128) function. + +### Internal pre-conditions + +None + +### External pre-conditions + +None + +### Attack Path + +1. Bob(lp) mints 100 `lp_token` by depositing 100 in quote token and the total supply of `lp_token` is 100. +2. Alice opens the long position. +3. The following steps are performed in one transaction: + - Alice mints 1000 `lp_token` by depositing 1000 + - Alice closes the long position: she has to pay 10 fees to the pool and the pool's total reserve is `1000 + 100 + 10 = 1110`. + - Alice burns 1000 `lp_token`: Alice receives `1000 * 1110 / 1100 = 1009` + +Alice pays 10 fees while closing the position and receives additional 9 fees while burning. +Even though Alice's minted 1000 `lp_token` did not contribute to the users' positions, she received fees and this is unfair for other liquidity providers. + +This vulnerability is available for attackers to frontrun the closing position. + +### Impact + +Fees are distributed to liquidity providers unfairly. + +### PoC + +None + +### Mitigation + +It is recommended to add the cool down period in the `api.burn` function and make burning available after cool down period. diff --git a/117.md b/117.md new file mode 100644 index 0000000..2c08788 --- /dev/null +++ b/117.md @@ -0,0 +1,54 @@ +Dancing Topaz Perch + +Medium + +# If the position creator is blocklisted for USDT, the position for USDT quote token can not be liquidated + +### Summary + +When the short position is closed or liquidated, the protocol transfers the quote tokens to the position creator. +If the creator is blocklisted for USDT, the position can not be closed and liquidated. +As a result, `pos.interest` amount of tokens are locked and protocol can not use them until transferring amount is zero. + +### Root Cause + +In the [`core.vy:344`](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L344), the protocol attempts to transfer quote tokens regardless of whether the owner of the position is blocklisted. + +### Internal pre-conditions + +There is BTC/USDT (base/quote) pool. + +### External pre-conditions + +None + +### Attack Path + +1. Alice open the short position. +2. Alice is blocklisted for `USDT`. +3. Alice's position can not be closed. +4. Liquidation is reverted as long as `quote_amt_final.remaining > 0`. + +### Impact + +`pos.interest` amount of tokens are locked and protocol can not use them until `quote_amt_final.remaining = 0` and the position is liquidated. + +### PoC + +Alice can not close the position because she can not receive USDT tokens from [L299](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L299). + +```vyper +298: if quote_amt > 0: +299: assert ERC20(quote_token).transfer(user, quote_amt, default_return_value=True), "ERR_ERC20" +``` + +Liquidation is reverted as long as `quote_amt_final.remaining > 0` from [L344](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L344). + +```vyper +343: if quote_amt_final.remaining > 0: +344: assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" +``` + +### Mitigation + +In the `core.liquidate` function, it is recommended to record the amount of tokens to transfer to the owner of the position and add a function to withdraw them for position owners. \ No newline at end of file diff --git a/118.md b/118.md new file mode 100644 index 0000000..eeaf74a --- /dev/null +++ b/118.md @@ -0,0 +1,55 @@ +Dancing Topaz Perch + +Medium + +# Users can open position with leverage as 0 + +### Summary + +Users deposit collateral tokens to open long or short positions with leverage. However, they can open position with leverage as `0`. As a result, they can earn funding fees regardless of price fluctuation. + +### Root Cause + +In the [`core.open](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L230-L268) function, there is no check if leverage is `0` or not. + +### Internal pre-conditions + +1. There is BTC/USDT (base/quote) pool. +2. There are many opposite positions in the pool. + +### External pre-conditions + +None + +### Attack Path + +1. Alice opens the long position with leverage 0. +2. After some time, Alice closes the position by paying borrowing fees to the protocol and receives imbalance funding fees. +If imbalance funding fee is greater than borrowing fees, she earned without contributing to the pool. + +### Impact + +Users can earn funding fees regardless of price fluctuation by setting leverage as `0`. + +### PoC + +In the [core.open](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L230-L268) function, there is no check if leverage is `0` or not. +If `leverage = 0`, `interest = 0` from L124. +```vyper +File: gl-sherlock\contracts\positions.vy +124: interest : uint256 = virtual_tokens * leverage +``` +Then, `profit` and `loss` are `0` because `pos.interest = 0`. +```vyper +File: contracts\positions.vy +288: vtokens: uint256 = pos.interest +289: val0 : uint256 = self.MATH.base_to_quote(vtokens, ctx0) +290: val1 : uint256 = self.MATH.base_to_quote(vtokens, ctx) +291: loss : uint256 = val0 - val1 if val0 > val1 else 0 +292: profit : uint256 = val1 - val0 if val1 > val0 else 0 +``` +As a result, if imbalance funding fee is bigger than borrowing fee, user can take profit regardless of price fluctuations. + +### Mitigation + +It is recommended to check leverage is zero or not in the [core.open](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L230-L268) function. \ No newline at end of file diff --git a/119.md b/119.md new file mode 100644 index 0000000..466c6f9 --- /dev/null +++ b/119.md @@ -0,0 +1,38 @@ +Dancing Topaz Perch + +Medium + +# Attackers can create positions that have no incentives to be liquidated + +### Summary + +There is no incentives to liquidate tiny positions, which may lead to insolvency. + +### Root Cause + +In the [core.open](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L230-L268) function, there is no minimum amount to open a new position. + +### Internal pre-conditions + +1. There is BTC/USDT (base/quote) pool. + +### External pre-conditions + +Attacker should be well-funded(e.g. a competing perp dex). + +### Attack Path + +An attacker opens lots of positions with low collateral. + +### Impact + +Lots of small losses are equivalent to one large loss, which will lead to bad debt that the exchange will have to cover in order to allow others to withdraw from the PnL pool. + +### PoC + +There is no minimum position size and an attacker can open lots of positions with low collateral. +As all calculations happen at close time, closing position costs much more gas than opening the position. + +### Mitigation + +Have a minimum total open notional for positions, to ensure there's a large enough fee to overcome liquidation gas costs. \ No newline at end of file diff --git a/120.md b/120.md new file mode 100644 index 0000000..a5b8a5f --- /dev/null +++ b/120.md @@ -0,0 +1,54 @@ +Innocent Wooden Capybara + +High + +# Inability to Liquidate Positions When User Contract Deactivates Token Receiving + +### Summary + +If a user has created a smart contract that is designed to reject or revert token transfers, the liquidate function within the protocol will continuously revert when attempting to transfer any fees to the user. This causes the liquidation process to fail, leaving the unliquidated until `base_amt_final.remaining` and ` quote_amt_final.remaining` are 0. This situation can create a significant issue, especially in scenarios where liquidation is necessary to maintain the solvency and proper functioning of the protocol as withholding open positions might deny people from opening/closing positions or burning lp_tokens. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L338 +The root cause of this issue lies in the assumption that all users are externally owned accounts (EOAs) or contracts that will accept token transfers. When a user deploys a smart contract that intentionally rejects token transfers, the liquidation function is unable to complete successfully, as it attempts to transfer liquidation fees proceeds to the user. If the user’s contract reverts during this transfer, the entire liquidation process fails. + +### Internal pre-conditions + +1.The user has a position that is eligible for liquidation. +2.The protocol's liquidate function calculates the liquidation amounts (base_amt_final and quote_amt_final) and attempts to transfer these amounts to the user and liquidator. +3.The user’s address is a smart contract that contains logic to revert or reject incoming token transfers. + +### External pre-conditions + +1.The user has deployed a smart contract that deactivates receiving tokens, either by rejecting transfers outright or by including conditions that cause the transfer function to revert. + + +### Attack Path + +1. Deploy a Malicious Contract: The user deploys a smart contract designed to reject token transfers (e.g., by reverting any ERC20.transfer calls). +2. Open a Position: The user opens a position on the protocol that eventually becomes eligible for liquidation. +3. Trigger Liquidation: Once the position becomes eligible, liquidators attempts to liquidate the position by calling the liquidate function to receive their share of the fee. +Liquidation Reversion: During the liquidation process, when the protocol attempts to transfer the liquidated funds (e.g., base_amt_final.remaining and quote_amt_final.remaining) to the user’s contract, the transfer fails because the user’s contract rejects the tokens. +Continuous Reversion: The liquidate function reverts continuously due to the failure in transferring the tokens, causing the liquidation process to be incomplete. This can persist until the position's collateral is depleted or the protocol manually intervenes. + +### Impact + +1. Failure to Liquidate: The protocol is unable to liquidate positions of users who deploy contracts that reject token transfers, leading to potential under-collateralized positions remaining open. +2. Protocol Risk: If the liquidation process fails for multiple users, it could lead to an accumulation of bad debt within the protocol, undermining the financial stability of the system. +3. Liquidation Inconsistency: The protocol's liquidation process becomes inconsistent and unreliable, potentially leading to a loss of trust among users and liquidators. +4.Denial of Service: The inability to complete the liquidation process can be exploited as a denial-of-service attack on the protocol, particularly if many users adopt similar contracts. + +### PoC + +1. Deploy a contract `RejectingContract` that can activate/deactivate receiving tokens +2. The user opens a position on the protocol using the `RejectingContract` address as their user address. +3. Let the position become eligible for liquidation due to market movements or fees. +4. A liquidator or the protocol itself calls the liquidate function. +The transfer to the RejectingContract fails, causing the liquidate function to revert, demonstrating the issue. + +### Mitigation + +1.Create a mapping that records the amount of tokens owed to each user due to failed transfers. This ensures that the user's funds are safe and can be withdrawn later. +2. Modify the `liquidate` function to track any failed transfers to the user's contract due to the inability to receive tokens. Instead of reverting the entire transaction, update an internal ledger within the protocol to reflect the amount owed to the user. +3. Implement a new function that allows users to withdraw their funds from the protocol whenever they choose, using the balances_owed mapping to determine the amount they are entitled to. \ No newline at end of file diff --git a/122.md b/122.md new file mode 100644 index 0000000..e6c740f --- /dev/null +++ b/122.md @@ -0,0 +1,46 @@ +Dancing Topaz Perch + +Medium + +# Deadline check is missing, allowing outdated slippage and allow pending transaction to be executed unexpectedly + +### Summary + +Deadline check is missing while minting and burning LP tokens. This allows outdated slippage and pending transactions to be executed unexpectedly. Deadline check should be included in important functions. + +### Root Cause + +In the [`core.mint](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L155-L158) function, there is no deadline check. + +### Internal pre-conditions + +1. There is BTC/USDT (base/quote) pool. + +### External pre-conditions + +None + +### Attack Path + +1. Alice execute a transaction to add liquidity to the BTC/USDT pool. + +### Impact + +AMMs provide their users with an option to limit the execution of their pending actions, such as swaps or adding and removing liquidity. The most common solution is to include a deadline timestamp as a parameter (for example see Uniswap V2 and Uniswap V3). If such an option is not present, users can unknowingly perform bad trades. + +### PoC + +In the current implementation in [`core.mint](https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L155-L158), there is no deadline check. + +Alice wants to add liquidity 1000 USDT to the BTC/USDT pool. +BTC/USDT pool has total reserves as 100K USDT. + +The transaction is submitted to the mempool, however, Alice chose a transaction fee that is too low for miners to be interested in including her transaction in a block. The transaction stays pending in the mempool for extended periods, which could be hours, days, weeks, or even longer. + +When the average gas fee dropped far enough for Alice's transaction to become interesting again for miners to include it, her swap will be executed. In the meantime, the BTC/USDT pool's total reserves increased to 110K USDT. As a result, Alice would get much less LP tokens. + +She has unknowingly performed a bad trade due to the pending transaction she forgot about. + +### Mitigation + +It is recommended to implement additional logic to check deadline in important functions. \ No newline at end of file diff --git a/124.md b/124.md new file mode 100644 index 0000000..1056a08 --- /dev/null +++ b/124.md @@ -0,0 +1,55 @@ +Ripe Citron Chicken + +High + +# An attacker can call init again after the develper calls it and become the deployer and set variables and will be able to steal funds + +## Summary +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/api.vy#L36 +Since there is no access control in `__init()`, an attacker can take over the deployer and make the oracle or any address that is set using `__init__2` manipulated and attacker-controlled leading to stealing of funds. +## Vulnerability Detail +After a few weeks, the protocol's tvl goes to 1 million and then an attacker calls `__init__` making them the deployer, and then calls `__init__2` and changes the oracle to give a very low price for a long position and then a block later, the attacker closes the position with BTC at a very high price stealing all the funds in the pool. +```solidity + assert msg.sender == self.DEPLOYER, ERR_INVARIANTS + assert not self.INITIALIZED , ERR_INVARIANTS + self.INITIALIZED = True +``` +## Impact +LP fund loss +## Code Snippet +```solidity +def __init__(): + self.DEPLOYER = msg.sender + self.INITIALIZED = False + +@external +def __init__2( + math : address, + params : address, + pools : address, + fees : address, + positions: address, + collector: address, + api : address): + + assert msg.sender == self.DEPLOYER, ERR_INVARIANTS + assert not self.INITIALIZED, ERR_INVARIANTS + self.INITIALIZED = True + + self.MATH = Math(math) + self.PARAMS = Params(params) + self.POOLS = Pools(pools) + self.FEES = Fees(fees) + self.POSITIONS = Positions(positions) + self.COLLECTOR = collector + self.API = api +### api contract + self.ORACLE = Oracle(oracle) + self.CORE = Core(core) +``` +## Tool used + +Manual Review + +## Recommendation +Put the Initialized section of the `__init__2` in the `__init__` to make sure after the deployer is set no one else can deploy. Otherwise take out the `` self.INITIALIZED = False```, because when the contract is deployed it would be false any way. \ No newline at end of file diff --git a/126.md b/126.md new file mode 100644 index 0000000..b04e81c --- /dev/null +++ b/126.md @@ -0,0 +1,150 @@ +Magnificent Pewter Stork + +Medium + +# borrowing_paid will be zero because of precision loss + +### Summary +The apply function in math.vy computes fees with potential precision loss due to integer division. This issue arises when calculating borrowing fees, leading to zero fees being recorded for small positions, which can result in fund losses for both LPs and the protocol. +### Root Cause +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/math.vy#L167 +```vyper +def apply(x: uint256, numerator: uint256) -> Fee: + """ + Fees are represented as numerator only, with the denominator defined + here. This computes x*fee capped at x. + """ + + @>>>> fee : uint256 = (x * numerator) / DENOM + remaining: uint256 = x - fee if fee <= x else 0 + fee_ : uint256 = fee if fee <= x else x + return Fee({x: x, fee: fee_, remaining: remaining}) +``` + +### Internal pre-conditions + +```json +{ + "collector" : "0xCFb56482D0A6546d17535d09f571F567189e88b3", + "symbol" : "WBTCUSDT", + "base_token" : "0x03c7054bcb39f7b2e5b2c7acb37583e32d70cfa3", + "quote_token" : "0x05d032ac25d322df992303dca074ee7392c117b9", + "base_decimals" : 8, + "quote_decimals": 6, + "blocktime_secs": 3, + "parameters" : { + "MIN_FEE" : 10, + "MAX_FEE" : 100, + "PROTOCOL_FEE" : 1000, + "LIQUIDATION_FEE" : 2, + + "MIN_LONG_COLLATERAL" : 5000000, + "MAX_LONG_COLLATERAL" : 100000000000, + "MIN_SHORT_COLLATERAL" : 10000, + "MAX_SHORT_COLLATERAL" : 200000000, + + "MIN_LONG_LEVERAGE" : 1, + "MAX_LONG_LEVERAGE" : 10, + "MIN_SHORT_LEVERAGE" : 1, + "MAX_SHORT_LEVERAGE" : 10, + + "LIQUIDATION_THRESHOLD": 5 + }, + "oracle": { + "extractor": "0x3DaF1A3ABF9dd86ee0f7Dd13a256400d01866E04", + "feed_id" : "BTC", + "decimals" : 8 + } +} +``` +### Impact + +Precision loss in fee calculations can result in lower fees being applied, causing LPs to receive less than expected and the protocol to lose funds. + +### PoC + +**Textual PoC:** +Based sample config for BTC/USDT market provided by sponsor,min collateral for short position is 0.0001 BTC,let's assume pool has 10,000 usdt as a quote reserve and price is $50,000 and leverage 10x +`funding_fee_short = base_interest / (base_reserve / 100)` = (0.0001 * 50,000 * 10) / (5000 / 100_000e6 / 100) = 100 / 100_000e4 = 0 +`borrowing_long_fee = max_fee * long_utilization` = 100 * 0 = 0 but because of min fee that become 10 + +after 10000 block user decide to close the position +*we assume when user opens the position, borrowing_long_sum is zero at open time +borrowing_long_sum = `borrowing_long_sum + (terms * borrowing_long_fee)`` = 0 + 10000 * 10 = 1e4 +DENOM = constant(uint256) = 1_000_000_000 +borrowing_paid = short_collateral * (delta[borrowing_long_sum]) / DENOM = 0.0001 * 1e5 / 1e9 = 0.00000001 => round down => 0 + +**Coded Poc** +Consider add this test to tests/test_positions.py and run `pytest -k test_borrowing_paid_precision_loss -s` +```python +def btc(x): return x*10**8 + +@pytest.fixture +def create_token8(project, owner): + def create_token18(name): + return owner.deploy(project.ERC20, name, name, 8, btc(1000000)) + return create_token18 + +@pytest.fixture +def BTC(create_token8): return create_token8("BTC") + +def ctx2(price, b, q): + return { + 'price': price, + 'base_decimals': b, + 'quote_decimals': q, + } + +def test_borrowing_paid_precision_loss(core, BTC,mint_token, STX, lp_provider, LP, open, mint, short, positions, owner, pools, fees, oracle, api): + pools.CORE() == core + fees.CORE() == core + positions.CORE() == core + oracle.API() == api + core.API() == api + mint_token(BTC, btc(1000), lp_provider) + mint_token(BTC, btc(1000), short) + + mint_token(STX, d(100_000), lp_provider) + assert BTC.balanceOf(lp_provider) == btc(1000) + assert BTC.balanceOf(short) == btc(1000) + + assert STX.balanceOf(lp_provider) == d(100_000) + + core.fresh("BTC-STX", BTC, STX, LP, sender=owner) + BTC.approve(core.address, btc(1000), sender=lp_provider) + STX.approve(core.address, d(100_000), sender=lp_provider) + mint(BTC, STX, LP, btc(1000), d(10_000), price=d(50_000), sender=lp_provider) + + + BTC.approve(core.address, 10000, sender=short) + assert BTC.allowance(short, core.address) == 10000 + open(BTC, STX, False, 10000, 10, price=d(50_000), sender=short) + + chain.mine(10000) + + position = positions.value(1, ctx2(d(50_000), 8, 6)) + assert position.fees.borrowing_paid == 0 +``` + + + + + +### Mitigation + +```diff ++SCALING_FACTOR: constant(uint256) = 10**18 + +@external +@pure +def apply(x: uint256, numerator: uint256) -> Fee: + """ + Fees are represented as numerator only, with the denominator defined + here. This computes x*fee capped at x. + """ +- fee : uint256 = (x * numerator * ) / DENOM ++ fee : uint256 = (x * numerator * SCALING_FACTOR) / DENOM * SCALING_FACTOR + remaining: uint256 = x - fee if fee <= x else 0 + fee_ : uint256 = fee if fee <= x else x + return Fee({x: x, fee: fee_, remaining: remaining}) +``` \ No newline at end of file diff --git a/131.md b/131.md new file mode 100644 index 0000000..8c98a27 --- /dev/null +++ b/131.md @@ -0,0 +1,51 @@ +Innocent Wooden Capybara + +High + +# Denial of Service Attack on Liquidity Providers by Opening Feeless Small Positions Exploiting Low Reserve and Precision Loss + +### Summary + +An attacker can execute a Denial of Service (DoS) attack on liquidity providers by opening multiple feeless, small positions that consume the pool's reserves due to precision loss in the fee calculation. This makes the reserves insufficient for liquidity providers to burn their LP tokens. Since these small positions incur minimal or no fees and do not get liquidated due to their size, they can remain open for a prolonged period, effectively locking up the protocol's liquidity. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/pools.vy#L203 +The smart contract does not enforce a minimum position size requirement when users open positions. As a result, attackers can create very small positions that incur minimal or no fees due to precision loss in the fee calculations. This leads to numerous small positions consuming the protocol's reserves without contributing meaningful fees, preventing liquidity providers from burning their LP tokens or withdrawing liquidity. + + + +### Internal pre-conditions + +1.The protocol allows users to open positions of any size without a minimum requirement. +2.The fee calculation for opening positions does not account for very small amounts, leading to near-zero or zero fees due to precision loss. + +### External pre-conditions + +1.An attacker can interact with the protocol's smart contracts and is aware of the lack of a minimum position size requirement. +2.The protocol's reserves are finite and can be depleted by numerous small positions. + + +### Attack Path + +1.The attacker identifies that the protocol allows users to open positions without any minimum size requirement, leading to feeless small positions. +2.The attacker uses a script or automation to open a large number of very small positions (e.g., 1,000 positions with 0.0001 USDT collateral each). Due to the lack of a minimum position size, these small positions are created successfully with zero or near-zero fees, consuming the protocol's reserves. +3.The cumulative effect of these small positions depletes the protocol's reserves, making them insufficient for liquidity providers to burn LP tokens or withdraw their liquidity. +4. Liquidity providers are unable to burn their LP tokens or withdraw liquidity due to the low reserve levels caused by the attacker's small positions. + + +### Impact + +1.Denial of Service for Liquidity Providers: Liquidity providers are unable to withdraw their funds or burn their LP tokens due to insufficient reserves. +2.Protocol Instability: The protocol’s operations may be disrupted, leading to a loss of trust and reduced liquidity provision. +3.Financial Losses: Liquidity providers and users may suffer financial losses as their funds remain locked in the protocol. +4. Long-Term Lockup: The attack can persist for a long period as small positions may not be liquidated due to their size, keeping the reserves low. + +### PoC + +_No response_ + +### Mitigation + +1.Set a minimum size for positions to prevent the creation of extremely small, feeless positions. This ensures that each position has a meaningful impact on the protocol's reserves. +2.Implement a queue for pending withdrawals and don't allow opening positions with big leverage until the queue is clear \ No newline at end of file diff --git a/132.md b/132.md new file mode 100644 index 0000000..686c399 --- /dev/null +++ b/132.md @@ -0,0 +1,53 @@ +Innocent Wooden Capybara + +High + +# Delayed Liquidations Can Result in Larger Losses and Potential Dos for other parts of the protocol + +### Summary + +Liquidations in the protocol are performed by external parties (liquidators) who call the liquidate function. These liquidators are incentivized by receiving a portion of the collateral from liquidated positions. However, during periods of high network congestion, gas prices can increase significantly, and if the cost to perform a liquidation exceeds the potential reward, liquidators may delay or avoid liquidations altogether, even for liquidation bots it might become unprofitable to call the liquidate function(if a user has a gas-intensive receive function in their contract, it can significantly increase the cost of executing a liquidation). This delay allows positions that should be liquidated to remain open, accruing additional losses and potentially leading to bad debt for the protocol. Moreover, as long as these positions remain open, they occupy reserves that block liquidity providers (LPs) from burning their LP tokens or other users from opening/closing other positions. + +### Root Cause + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L308 +The root cause of this issue is the reliance on external liquidators to perform liquidations in the protocol. When gas fees are high, liquidators may choose not to execute the liquidate function if the potential reward does not cover the transaction costs, resulting in delayed liquidations or loss of money for the protocol in case of use of liquidation bots(if a user has a gas-intensive receive function in their contract, it can significantly increase the cost of executing a liquidation and might drain the liquidation bot). Meanwhile, positions that should be liquidated remain open, accumulating further losses and holding protocol reserves hostage. + + + +### Internal pre-conditions + +-The protocol relies on external liquidators to identify and execute liquidations. +-The liquidate function requires a gas fee that may vary depending on network congestion. + + +### External pre-conditions + +-The network experiences periods of high congestion, causing gas prices to spike. +-Liquidators are rational actors who consider gas costs versus rewards before performing liquidations. +- The position in question is eligible for liquidation based on the protocol’s rules (e.g., under-collateralized, significant losses). + +### Attack Path + +- During periods of high network congestion, gas prices increase significantly. +- Liquidators analyze the cost of executing the liquidate function versus the potential rewards (a portion of the liquidated collateral). If the gas costs are higher than the rewards, they choose not to perform the liquidation. +- As a result, the liquidation is delayed or not executed at all. (if executed by liquidation bot, protocol will lose fees) Positions that should be liquidated remain open, accruing additional borrowing and funding fees, leading to more losses for the protocol. + + +### Impact + +Open positions with excessive gas costs hold onto the protocol’s reserves, preventing liquidity providers from burning their LP tokens or other users from opening new positions. +The protocol’s efficiency is reduced as more positions remain open longer than intended, potentially impacting overall financial health and reducing user trust. + +### PoC + +Simulate a high gas environment or simply deploy a Gas-Consuming Contract: +User open a position and it becomes liquidatable +A liquidator attempts to liquidate the position using the liquidate function. The gas cost of the function becomes excessively high due to the gas-consuming receive function. +The liquidator observes that the gas cost is higher than the liquidation reward and decides not to execute the liquidation, leaving the position open and accruing further losses. + + +### Mitigation + +Set a maximum gas limit for any token transfers or liquidations that involve user contracts to prevent excessively high gas costs. +Adjust the liquidation rewards dynamically based on network conditions. When gas prices are high, increase the rewards to ensure that liquidators remain incentivized to perform liquidations. diff --git a/133.md b/133.md new file mode 100644 index 0000000..782851b --- /dev/null +++ b/133.md @@ -0,0 +1,99 @@ +Jumpy Metal Parakeet + +High + +# If `total_supply` is zero or very small,` bv `could become disproportionately large in `pools::calc_burn `function. + +## Summary + if the` total_supply` of LP tokens is very small, it can create a situation where the attacker receives an unbalanced or disproportionately large amount of the pool's reserves when burning LP tokens. This can occur due to the following issues: + +1. Disproportionate Reward for Small Total Supply: + + - When total_supply is very small, burning even a small number of LP tokens could entitle the attacker to a significant portion of the pool's reserves. + - For example, if total_supply is only 10 LP tokens, and the attacker burns 1 LP token, they are burning 10% of the total supply. If the pool has significant reserves, this could mean the attacker receives a large amount of tokens relative to the small LP tokens they are burning. + - This disproportionate reward is problematic because it allows the attacker to extract more value than they should be entitled to, potentially draining the pool's reserves. + +2. Potential for Pool Drain: + + - If the pool's reserves are large relative to the total_supply, and the attacker can burn LP tokens repeatedly, they could systematically drain the pool by taking advantage of the small total_supply. + - This could lead to a situation where other LP token holders are left with very few or no reserves, effectively causing a collapse of the pool. + +3. Division by Zero or Near-Zero Issues: + + - If the total_supply is extremely small or even zero, the calculation for the burn value (bv) could encounter division by zero or produce incorrect results. + - This could lead to incorrect token distributions, further exacerbating the imbalance in the pool. + +Example Scenario: + +- Initial State: Suppose the pool has 1000 base tokens and 1000 quote tokens, but due to a previous burn event or some initialization quirk, the total_supply of LP tokens is only 10. +- Attack: An attacker deposits a minimal amount of liquidity and mints 1 LP token. They then proceed to burn that 1 LP token. +- Outcome: Given that 1 LP token represents 10% of the total_supply, the attacker could withdraw 10% of the pool's reserves. This would mean receiving 100 base tokens and 100 quote tokens, which is a disproportionately large amount for burning a single LP token. +- Impact: The attacker has effectively drained 10% of the pool's reserves with minimal investment, and if repeated, this could quickly deplete the entire pool. + +NOTE:: Some initialization quirk" refers to unexpected or unintended conditions that might occur during the initial setup or early stages of the smart contract's deployment and operation. These quirks can lead to unusual or edge-case scenarios that are not typically encountered during normal operation. + +## Vulnerability Detail + + If `total_supply` is zero or very small, `bv` could become disproportionately large in `pools::calc_burn` function. + + **Initial Setup:** + + - A pool is initialized with a very small `total_supply` of LP tokens, say `1000` LP tokens. + - The pool has `1000` units of base and `1000` units of quote tokens, and the pool is well-balanced. + +**Initial Conditions:** + + - `total_supply` = `1000` LP tokens + - `total_reserves` (in quote terms) = `2000` units + - The attacker holds `100` LP tokens. + + + **Burn Operation:** + - The attacker calls the `calc_burn` function to calculate the amount of tokens they can withdraw by burning their `100` LP tokens. + - Suppose the function `calc_burn` is called as follows: + + ```python + calc_burn(id, 100, 1000, ctx) + ``` + + **Calculation of Burn Value:** + - Inside the `calc_burn` function, the `g` function computes `bv` as follows: + + ```python + @internal + @pure + def g(lp: uint256, ts: uint256, pv: uint256) -> uint256: + return (lp * pv) / ts + ``` + Plugging in the values: + + ```python + lp = 100 + ts = 1000 + pv = 2000 + ``` + + The burn value (`bv`) is computed as: + + ```python + bv = (100 * 2000) / 1000 = 200 + ``` + **Withdraw Amounts:** + - The `calc_burn` function then calculates the `unlocked_reserves`, which are `1000` base and `1000` quote tokens. + - Using the `self.MATH.balanced` function, it determines the amount of tokens that can be returned: + + ```python + amts = self.MATH.balanced(value, bv, ctx) + ``` + Given the pool is well-balanced and the attacker is burning `100` LP tokens, the function should return `200` units worth of tokens, adjusted for balance. + +**Exploitation:** + - Since the `total_supply` is very small, the ratio of LP tokens being burned is significant. When `total_supply` is small, even a small burn operation results in a larger proportion of the pool reserves being claimed compared to a larger `total_supply`. + - The attacker can exploit this by burning their LP tokens for a disproportionately large amount of reserves from the pool. + +## Impact +The core issue is that the smart contract does not properly handle cases where the total_supply of LP tokens is very small. This allows attackers to exploit the burn mechanism to extract more value from the pool than they should, leading to potential losses for other liquidity providers and destabilization of the pool. +## Code Snippet +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/pools.vy#L213 +## Recommendation + the contract should implement checks to ensure that total_supply is sufficiently large before allowing burns, or adjust the burn calculations to prevent disproportionate rewards when total_supply is small. \ No newline at end of file diff --git a/134.md b/134.md new file mode 100644 index 0000000..64635bd --- /dev/null +++ b/134.md @@ -0,0 +1,32 @@ +Wonderful Orchid Dove + +Medium + +# certain token such as USDT does not return boolean when calling transfer, then enforce a returned boolean value will revert the transaction. + +## Summary + +certain token such as USDT does not return boolean when calling transfer, then enforce a returned boolean value will revert the transaction. + +## Vulnerability Detail + +```solidity + assert ERC20(base_token).transfer(user, base_amt, default_return_value=True), "ERR_ERC20" + assert ERC20(quote_token).transfer(user, quote_amt, default_return_value=True), "ERR_ERC20" +``` + +## Impact + +USDT using as base token / quote token will not work because token transfer revert. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L215 + +## Tool used + +Manual Review + +## Recommendation + +use safeTransfer \ No newline at end of file diff --git a/135.md b/135.md new file mode 100644 index 0000000..a6ec664 --- /dev/null +++ b/135.md @@ -0,0 +1,66 @@ +Wonderful Orchid Dove + +Medium + +# Should not use of tx.origin track user address + +## Summary + +the code use tx.origin to track the user address + +## Vulnerability Detail + +example: + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L241 + +```solidity +user : address = tx.origin + pool : PoolState = self.POOLS.lookup(id) + + cf : Fee = self.PARAMS.static_fees(collateral0) + fee : uint256 = cf.fee + collateral : uint256 = cf.remaining + + assert pool.base_token == base_token , ERR_PRECONDITIONS + assert pool.quote_token == quote_token, ERR_PRECONDITIONS + assert collateral > 0 , ERR_PRECONDITIONS + assert fee > 0 , ERR_PRECONDITIONS + + if long: assert ERC20(quote_token).transferFrom(user, self, collateral0), "ERR_ERC20" + else : assert ERC20(base_token).transferFrom(user, self, collateral0), "ERR_ERC20" + + # transfer protocol fees to separate contract + if long: assert ERC20(quote_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" + else : assert ERC20(base_token).transfer(self.COLLECTOR, fee), "ERR_ERC20" +``` + +## Impact + +multisig wallet has owner alice and bob + +alice calls open position using the multisig wallet (genosis safe) + +alice calls genosis safe contract calls the velar contract, + +while alice expects the user is the safe wallet, alice is tx.origin and she open a position using her own account, not on the safe wallet. + +also using tx.origin has other security issue and break all smart contract integration. + +https://medium.com/coinmonks/smart-contract-security-tx-origin-authorization-attack-vectors-027730ae601d + +and + +https://github.com/sherlock-audit/2024-02-optimism-2024-judging/issues/194 + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L241 + +## Tool used + +Manual Review + +## Recommendation + +pass in the original msg.sender inside the core contract instead of tx.origin to track address directly. \ No newline at end of file diff --git a/136.md b/136.md new file mode 100644 index 0000000..1fb4b07 --- /dev/null +++ b/136.md @@ -0,0 +1,49 @@ +Wonderful Orchid Dove + +High + +# Blocklisted user block liquidation + +## Summary + +Blocklisted user block liquidation + +## Vulnerability Detail + +The code tries to refund after dust amount after liquidation to original user + +```solidity + # liquidator gets liquidation fee, user gets whatever is left + if base_amt_final.fee > 0: + assert ERC20(base_token).transfer(user, base_amt_final.fee, default_return_value=True), "ERR_ERC20" + if quote_amt_final.fee > 0: + assert ERC20(quote_token).transfer(user, quote_amt_final.fee, default_return_value=True), "ERR_ERC20" + if base_amt_final.remaining > 0: + assert ERC20(base_token).transfer(position.user, base_amt_final.remaining, default_return_value=True), "ERR_ERC20" + if quote_amt_final.remaining > 0: + assert ERC20(quote_token).transfer(position.user, quote_amt_final.remaining, default_return_value=True), "ERR_ERC20" +``` + +this is a issue because if position.user is blocklisted and it is common for token such as USDT that implement blocklist feature, + +then the liquidation will be blocked and leads to bad debt and loss. + +https://dune.com/phabc/usdt---banned-addresses + +this is a list of blocklisted USDT address. + +## Impact + +Liquidation always revert if the position.user address is blocklisted. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/18ef2d8dc0162aca79bd71710f08a3c18c94a36e/gl-sherlock/contracts/core.vy#L342 + +## Tool used + +Manual Review + +## Recommendation + +let user claim their leftover asset after liquidation instead of transferring the asset out during the liqudiation transaction. \ No newline at end of file diff --git a/138.md b/138.md new file mode 100644 index 0000000..491ef5c --- /dev/null +++ b/138.md @@ -0,0 +1,65 @@ +Faint Raspberry Tadpole + +Medium + +# Fees are updated after pool operations, which causes the fees are updated incorrectly. + +### Summary + +Fees are updated after pool operations (mint/burn/open/close), which causes the fees are updated incorrectly, as the operations will change the dynamic fees, but the new fees should reflect the accured fees since last operation, and should use the fees of last operation. + +### Root Cause + +1. `core.vy:mint` calls pools.mint and then update fees. +```vyper +# core.vy#mint() + self.POOLS.mint(id, base_amt, quote_amt) + self.FEES.update(id) +``` +2. `pools.vy:mint` will change the `base_reserves` and `quote_reserves`. +```vyper + base_reserves : ps.base_reserves + base_amt, + quote_reserves : ps.quote_reserves + quote_amt, +``` +```vyper + +def current_fees(id: uint256) -> FeeState: + ... + # prev/last updated state + fs : FeeState = Fees(self).lookup(id) + # current state + ps : PoolState = self.POOLS.lookup(id) + new_fees : DynFees = self.PARAMS.dynamic_fees(ps) + # number of blocks elapsed + new_terms: uint256 = block.number - fs.t1 + ... + borrowing_long_sum : uint256 = self.extend(fs.borrowing_long_sum, fs.borrowing_long, new_terms) + borrowing_short_sum : uint256 = self.extend(fs.borrowing_short_sum, fs.borrowing_short, new_terms) + funding_long_sum : uint256 = self.extend(fs.funding_long_sum, fs.funding_long, new_terms) + funding_short_sum : uint256 = self.extend(fs.funding_short_sum, fs.funding_short, new_terms) +``` +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/fees.vy#L137-L167 + +### Internal pre-conditions + +_No response_ + +### External pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +Move the fees updating after the mint/burn/open/close operations. \ No newline at end of file diff --git a/139.md b/139.md new file mode 100644 index 0000000..76874a4 --- /dev/null +++ b/139.md @@ -0,0 +1,57 @@ +Jumpy Metal Parakeet + +High + +# Excess Tokens May Get Stuck in Contract + + +## Vulnerability Detail + + +Let's say a liquidity provider (LP) wants to provide liquidity in a pool that facilitates trading between BTC (Bitcoin) and USDT (Tether). + +Step 1: Check Pool Ratio +Suppose the pool currently has 100 BTC and 200,000 USDT. The ratio of BTC to USDT in the pool is 1 BTC = 2,000 USDT. + +Step 2: Deposit Tokens +To maintain the pool's ratio, the LP needs to deposit tokens in the same proportion. Let's say the LP wants to deposit 10 BTC worth of liquidity. + +BTC Deposit: The LP needs to deposit 10 BTC. +USDT Deposit: Since the ratio is 1 BTC = 2,000 USDT, the LP needs to deposit 10 BTC * 2,000 USDT/BTC = 20,000 USDT. +so the LP receives 10% of the total LP tokens. + + +---------------------------------------------------------------------------------------------------------------------------------------------- +now imagine you provide 1 BTC and 3,000 USDT to the liquidity pool with a 1BTC = 2,000 USDT ratio, + +1. Function Call: +You'd call the mint function with `base_amt = 1BTC` and `quote_amt = 3,000 USDT`. The contract would process these inputs to provide liquidity to the pool. + +2. Pool Conditions: +The pool expects liquidity to be provided in a specific ratio (1 BTC = 2,000 USDT). However, you're providing an extra 1,000 USDT beyond this ratio. + + +3.Liquidity Minting: +Calculating LP Tokens (`calc_mint`): The contract will calculate the LP tokens to be minted using the `pools::calc_mint` function. It will calculate the total value of the pool (pv) and the value of your contribution (mv). The LP tokens minted will be proportional to your contribution relative to the total pool size. + + +4 . Minting LP Tokens: +Once the tokens have been transferred to the pool and the pool's state has been updated, the contract mints LP tokens. + +now your extra 1000USTD is stuck in the contract without refunding to the user. +## Impact + +- locked User Funds: Excess tokens could be stuck in the contract without contributing to liquidity, effectively locking user funds. +- Reduced LP Token Minting: Since the excess tokens are not properly accounted for, users may receive fewer LP tokens than expected, leading to an imbalance between liquidity provided and LP tokens minted. + +## Code Snippet + +https://github.com/sherlock-audit/2024-08-velar-artha/blob/main/gl-sherlock/contracts/core.vy#L155 + +## Recommendation + + consider the following approaches: + +- Excess Token Refund: Automatically refund any excess tokens (e.g., the extra 1,000 USDT) to the user after the appropriate liquidity is added to the pool. +- Proportional Contribution Adjustment: Adjust the contribution proportionally to match the expected token ratio and use all provided liquidity effectively. +- Flexible Liquidity Addition: Allow the contract to add the excess tokens to the pool's reserves while ensuring it does not disrupt the balance and fairness of liquidity provisioning. \ No newline at end of file diff --git a/invalid/.gitkeep b/invalid/.gitkeep new file mode 100644 index 0000000..e69de29