Platform: Code4rena
Start Date: 12/07/2022
Pot Size: $35,000 USDC
Total HM: 13
Participants: 78
Period: 3 days
Judge: 0xean
Total Solo HM: 6
Id: 135
League: ETH
Rank: 3/78
Findings: 3
Award: $3,285.36
π Selected for report: 1
π Solo Findings: 1
π Selected for report: GimelSec
2234.6752 USDC - $2,234.68
https://github.com/code-423n4/2022-07-swivel/blob/main/VaultTracker/VaultTracker.sol#L65 https://github.com/code-423n4/2022-07-swivel/blob/main/VaultTracker/VaultTracker.sol#L100 https://github.com/code-423n4/2022-07-swivel/blob/main/VaultTracker/VaultTracker.sol#L130 https://github.com/code-423n4/2022-07-swivel/blob/main/VaultTracker/VaultTracker.sol#L172 https://github.com/code-423n4/2022-07-swivel/blob/main/VaultTracker/VaultTracker.sol#L191 https://github.com/code-423n4/2022-07-swivel/blob/main/VaultTracker/VaultTracker.sol#L228
VaultTracker neglect previously accrued interest while attempting to calculate new interest. This causes nToken
holders to receive less yield than they should.
All functions within VaultTracker that calculate interest are affected, including addNotional
, removeNotional
, redeemInterest
, transferNotionalFrom
and transferNotionalFee
.
Consider the case where some user N
tries to initiate a vault at 3 specific moments where cToken
exchange rate is 5/10/20 respectively.
The corresponding market stays active and has not reached maturity. Additionally, N
selects his premium volume to make principalFilled
match the cToken
exchange rate during each call to initiateVaultFillingZcTokenInitiate
.
We recognize those exchange rates are most likely unrealistic, but we chose those for ease of demonstrating the bug. We also assume fees to be 0 for simplicity.
For the first call to Swivel.deposit
a
= 5exchangeRate
= 5Assuming no additional fees while minting cToken
, N
will receive
cToken` for his 5 underlying tokens.
For the matching call to VaultTracker.addNotional
a
= 5vlt.notional
= 0exchangeRate
= 5Since this is the first time adding nToken
to the vault, there is no need to consider any accumulated interests, and we can assign a
directly to vlt.notional
.
The result will be
vlt.notional
= 5vlt.redeemable
= 0cToken
held by Swivel
= 1For the second call to `Swivel.deposit, we have
a
= 10exchangeRate
= 10The matching call to VaultTracker.addNotional
has
a
= 10vlt.notional
= 5vlt.redeemable
= 0exchangeRate
= 10vlt.exchangeRate
= 5The yield
is derived from ((exchangeRate * 1e26) / vlt.exchangeRate) - 1e26
= ((10 * 1e26) / 5) - 1e26
= 1e26
Applying this to vlt.notional
, we get interest
= 1e26 * 5 / 1e26
= 5
This results in
vlt.notional
= 5+10 = 15vlt.redeemable
= 0+5 = 5cToken
held by Swivel
= 1+1 = 2Now comes the last call to Swivel.deposit
, where
a
= 20exchangeRate
= 20VaultTracker.addNotional
has
a
= 20vlt.notional
= 15vlt.redeemable
= 5exchangeRate
= 20vlt.exchangeRate
= 10yield
= ((20 * 1e26) / 10) - 1e26
= 1e26
interest
= 1e26 * 15 / 1e26
= 15
So we finally end up with
vlt.notional
= 15+20 = 35vlt.redeemable
= 5+15 = 20cToken
held by Swivel
= 2+1 = 3Swivel{ ... function deposit(uint8 p, address u, address c, uint256 a) internal returns (bool) { ... if (p == uint8(Protocols.Compound)) { // TODO is Rari a drop in here? return ICompound(c).mint(a) == 0; } ... } ... } VaultTracker{ ... function addNotional(address o, uint256 a) external authorized(admin) returns (bool) { uint256 exchangeRate = Compounding.exchangeRate(protocol, cTokenAddr); Vault memory vlt = vaults[o]; if (vlt.notional > 0) { uint256 yield; // if market has matured, calculate marginal interest between the maturity rate and previous position exchange rate // otherwise, calculate marginal exchange rate between current and previous exchange rate. if (maturityRate > 0) { // Calculate marginal interest yield = ((maturityRate * 1e26) / vlt.exchangeRate) - 1e26; } else { yield = ((exchangeRate * 1e26) / vlt.exchangeRate) - 1e26; } uint256 interest = (yield * vlt.notional) / 1e26; // add interest and amount to position, reset cToken exchange rate vlt.redeemable += interest; vlt.notional += a; } else { vlt.notional = a; } vlt.exchangeRate = exchangeRate; vaults[o] = vlt; return true; } ... }
Now take a step back and think about the actual value of 3 cToken
when exchangeRate = 20
, it should be pretty obvious that the value tracked by VaultTracker
= 35 + 20 = 55 is lesser than actual value of cToken
held by Swivel
= 20*3 = 60.
This is due to VaultTracker
neglecting that previously accrued interest should also be considered while calculating new interest.
Manual Review
For all interest calculations, use vlt.notional + vlt.redeemable
instead of just vlt.notional
as yield
base
#0 - JTraversa
2022-07-15T21:59:26Z
I believe that this would be valid if the redeemable
was not redeemable by the user at any point in time.
While interest accrues, it accrues to the redeemable
balance which is withdrawn at any time.
That said, in most cases, the math required to store and do the additional calculation marginal interest on the redeemable
balance is largely a UX consideration? Should users be required to redeem their redeemable
to earn interest or should it be compounded naturally though bloat tx costs?
Should we force additional costs on a per transaction basis (that likely costs more than the interest itself for the vast majority of users), or should we assume that once significant enough redeemable
is accrued to earn reasonable further interest, it will be redeemed by the user.
(e.g. with example being 400% interest, and assuming an (optimistic) 5% APY is possible, the example would take ~28 years to replicate, and gas costs for transactions in that 28 years would be significant)
#1 - JTraversa
2022-07-24T16:06:40Z
Thought about this one a bit more and it might slip in as acknowledged and disagreed with severity as its more value leakage than anything else.
Its a design decision, but could still be considered a detriment so perhaps not worth disputing all together?
#2 - bghughes
2022-08-04T23:52:22Z
I agree with the warden here and regarding:
I believe that this would be valid if the
redeemable
was not redeemable by the user at any point in time.While interest accrues, it accrues to the
redeemable
balance which is withdrawn at any time.That said, in most cases, the math required to store and do the additional calculation marginal interest on the
redeemable
balance is largely a UX consideration? Should users be required to redeem theirredeemable
to earn interest or should it be compounded naturally though bloat tx costs?Should we force additional costs on a per transaction basis (that likely costs more than the interest itself for the vast majority of users), or should we assume that once significant enough
redeemable
is accrued to earn reasonable further interest, it will be redeemed by the user.(e.g. with example being 400% interest, and assuming an (optimistic) 5% APY is possible, the example would take ~28 years to replicate, and gas costs for transactions in that 28 years would be significant)
you should aspire to use the correct math so your users get the best rate (inclusive of compounding), respectfully. IMO what the warden suggests is what the protocol should do given the entire reason many lenders deposit into a yield token is to stack compounding interest. This could lead to misleading users and compounding loss of value.
#3 - robrobbins
2022-08-18T19:10:09Z
the above is nonsense
this is a design feature. we could just as easily dismiss this all together by stating it is now documented that .notional is non compounding. i don't think we should do that however as i agree with @JTraversa here that some acknowledgement should happen for bringing this up. but it is not a risk in any situation
#4 - robrobbins
2022-08-18T22:03:16Z
#5 - 0xean
2022-08-24T21:42:59Z
going to reduce the severity on this to medium as it has some pretty large external factors to end up in a scenario where it leaks any real value.
1005.6038 USDC - $1,005.60
https://github.com/code-423n4/2022-07-swivel/blob/main/VaultTracker/VaultTracker.sol#L7 https://github.com/code-423n4/2022-07-swivel/blob/main/Marketplace/MarketPlace.sol#L8 https://github.com/code-423n4/2022-07-swivel/blob/main/Creator/Creator.sol#L41
Functions which call VaultTracker admin functions (e.g. addNotional) from MarketPlace will always revert since the admin is Creator.
VaultTracker.sol has an authorized(admin)
modifier which only allows admin to call these functions. And the Creator will create VaultTracker, so the immutable admin of VaultTracker will always be Creator.
But some functions in MarketPlace will also call these admin-only functions of VaultTracker, leading to these functions will always revert.
These lines below will call admin functions of VaultTracker
in Marketplace/MarketPlace.sol
:
120: if (!IVaultTracker(market.vaultTracker).addNotional(t, a)) { revert Exception(25, 0, 0, address(0), address(0)); } 251: if (!IVaultTracker(market.vaultTracker).addNotional(n, a)) { revert Exception(25, 0, 0, address(0), address(0)); } 136: if (!IVaultTracker(market.vaultTracker).removeNotional(t, a)) { revert Exception(26, 0, 0, address(0), address(0)); } 269: if (!IVaultTracker(market.vaultTracker).removeNotional(n, a)) { revert Exception(26, 0, 0, address(0), address(0)); } 203: uint256 interest = IVaultTracker(markets[p][u][m].vaultTracker).redeemInterest(t); 102: IVaultTracker(market.vaultTracker).matureVault(exchangeRate); 302: if (!IVaultTracker(markets[p][u][m].vaultTracker).transferNotionalFrom(f, t, a)) { revert Exception(27, 0, 0, address(0), address(0)); } 316: if (!IVaultTracker(markets[p][u][m].vaultTracker).transferNotionalFrom(msg.sender, t, a)) { revert Exception(27, 0, 0, address(0), address(0)); } 329: IVaultTracker(markets[p][u][m].vaultTracker).transferNotionalFee(f, a);
Manual Review
Set admin when Creator create VaultTracker, and add admin argument in VaultTracker constructor:
constructor(..., address _admin, ...){ admin = _admin; ... }
#0 - JTraversa
2022-07-15T23:13:53Z
Duplicate of #36
#1 - robrobbins
2022-08-02T16:56:19Z
#2 - bghughes
2022-08-04T23:20:28Z
Duplicate of #36
π Selected for report: joestakey
Also found by: 0x1f8b, 0x52, 0xDjango, 0xNazgul, 0xNineDec, 8olidity, Avci, Bahurum, Bnke0x0, Chom, ElKu, Funen, GimelSec, JC, Junnon, Kaiziron, Meera, PaludoX0, Picodes, ReyAdmirado, Sm4rty, Soosh, Waze, _Adam, __141345__, ak1, aysha, benbaessler, bin2chen, c3phas, cccz, cryptphi, csanuragjain, defsec, exd0tpy, fatherOfBlocks, gogo, hake, hansfriese, itsmeSTYJ, jonatascm, kyteg, mektigboy, oyc_109, pashov, rbserver, rishabh, robee, rokinot, sach1r0, sashik_eth, scaraven, simon135, slywaters
45.0787 USDC - $45.08
We list 1 low-critical finding and 2 non-critical findings:
maturityRate
maturityRate
ZcToken will get wrong values if users don't call matureMarket
after maturity.
https://github.com/code-423n4/2022-07-swivel/blob/main/Tokens/ZcToken.sol
In ZcToken.convertToUnderlying
, ZcToken.previewRedeem
and ZcToken.maxWithdraw
, these functions will be reverted after maturity due to division by 0 if market is not matured.
In ZcToken.convertToPrincipal
and ZcToken.previewWithdraw
, these functions return incorrect value if they are called after maturity but the market is not matured.
This will cause ZcToken.withdraw
to pass incorrect value to authRedeem
, and potentially wastes user gas.
Check the market should be matured.
It will be reverted if length > feeNominator array length.
https://github.com/code-423n4/2022-07-swivel/blob/main/Swivel/Swivel.sol#L495
In setFee
, the length of array i
and d
should <= 4 which is the feeNominator array length.
Check i
and d
length <= feeNominator array length.
Using ecrecover is against best practice. Preferably use ECDSA.recover instead. EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature unique. However it should be impossible to be a threat by now.
https://github.com/code-423n4/2022-07-swivel/blob/main/Tokens/Erc20.sol#L141
Take these implementation into consideration
#0 - robrobbins
2022-08-25T22:27:00Z
i don't feel that ecrecover is a liability. OZs ECDSA is a little bit of a solution looking for a problem in this regard imo