-
Notifications
You must be signed in to change notification settings - Fork 40
/
VaultV3.vy
2198 lines (1843 loc) · 78.7 KB
/
VaultV3.vy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# @version 0.3.7
"""
@title Yearn V3 Vault
@license GNU AGPLv3
@author yearn.finance
@notice
The Yearn VaultV3 is designed as a non-opinionated system to distribute funds of
depositors for a specific `asset` into different opportunities (aka Strategies)
and manage accounting in a robust way.
Depositors receive shares (aka vaults tokens) proportional to their deposit amount.
Vault tokens are yield-bearing and can be redeemed at any time to get back deposit
plus any yield generated.
Addresses that are given different permissioned roles by the `role_manager`
are then able to allocate funds as they best see fit to different strategies
and adjust the strategies and allocations as needed, as well as reporting realized
profits or losses.
Strategies are any ERC-4626 compliant contracts that use the same underlying `asset`
as the vault. The vault provides no assurances as to the safety of any strategy
and it is the responsibility of those that hold the corresponding roles to choose
and fund strategies that best fit their desired specifications.
Those holding vault tokens are able to redeem the tokens for the corresponding
amount of underlying asset based on any reported profits or losses since their
initial deposit.
The vault is built to be customized by the management to be able to fit their
specific desired needs. Including the customization of strategies, accountants,
ownership etc.
"""
# INTERFACES #
from vyper.interfaces import ERC20
from vyper.interfaces import ERC20Detailed
interface IStrategy:
def asset() -> address: view
def balanceOf(owner: address) -> uint256: view
def convertToAssets(shares: uint256) -> uint256: view
def convertToShares(assets: uint256) -> uint256: view
def previewWithdraw(assets: uint256) -> uint256: view
def maxDeposit(receiver: address) -> uint256: view
def deposit(assets: uint256, receiver: address) -> uint256: nonpayable
def maxRedeem(owner: address) -> uint256: view
def redeem(shares: uint256, receiver: address, owner: address) -> uint256: nonpayable
interface IAccountant:
def report(strategy: address, gain: uint256, loss: uint256) -> (uint256, uint256): nonpayable
interface IDepositLimitModule:
def available_deposit_limit(receiver: address) -> uint256: view
interface IWithdrawLimitModule:
def available_withdraw_limit(owner: address, max_loss: uint256, strategies: DynArray[address, MAX_QUEUE]) -> uint256: view
interface IFactory:
def protocol_fee_config() -> (uint16, address): view
# EVENTS #
# ERC4626 EVENTS
event Deposit:
sender: indexed(address)
owner: indexed(address)
assets: uint256
shares: uint256
event Withdraw:
sender: indexed(address)
receiver: indexed(address)
owner: indexed(address)
assets: uint256
shares: uint256
# ERC20 EVENTS
event Transfer:
sender: indexed(address)
receiver: indexed(address)
value: uint256
event Approval:
owner: indexed(address)
spender: indexed(address)
value: uint256
# STRATEGY EVENTS
event StrategyChanged:
strategy: indexed(address)
change_type: indexed(StrategyChangeType)
event StrategyReported:
strategy: indexed(address)
gain: uint256
loss: uint256
current_debt: uint256
protocol_fees: uint256
total_fees: uint256
total_refunds: uint256
# DEBT MANAGEMENT EVENTS
event DebtUpdated:
strategy: indexed(address)
current_debt: uint256
new_debt: uint256
# ROLE UPDATES
event RoleSet:
account: indexed(address)
role: indexed(Roles)
# STORAGE MANAGEMENT EVENTS
event UpdateFutureRoleManager:
future_role_manager: indexed(address)
event UpdateRoleManager:
role_manager: indexed(address)
event UpdateAccountant:
accountant: indexed(address)
event UpdateDepositLimitModule:
deposit_limit_module: indexed(address)
event UpdateWithdrawLimitModule:
withdraw_limit_module: indexed(address)
event UpdateDefaultQueue:
new_default_queue: DynArray[address, MAX_QUEUE]
event UpdateUseDefaultQueue:
use_default_queue: bool
event UpdateAutoAllocate:
auto_allocate: bool
event UpdatedMaxDebtForStrategy:
sender: indexed(address)
strategy: indexed(address)
new_debt: uint256
event UpdateDepositLimit:
deposit_limit: uint256
event UpdateMinimumTotalIdle:
minimum_total_idle: uint256
event UpdateProfitMaxUnlockTime:
profit_max_unlock_time: uint256
event DebtPurchased:
strategy: indexed(address)
amount: uint256
event Shutdown:
pass
# STRUCTS #
struct StrategyParams:
# Timestamp when the strategy was added.
activation: uint256
# Timestamp of the strategies last report.
last_report: uint256
# The current assets the strategy holds.
current_debt: uint256
# The max assets the strategy can hold.
max_debt: uint256
# CONSTANTS #
# The max length the withdrawal queue can be.
MAX_QUEUE: constant(uint256) = 10
# 100% in Basis Points.
MAX_BPS: constant(uint256) = 10_000
# Extended for profit locking calculations.
MAX_BPS_EXTENDED: constant(uint256) = 1_000_000_000_000
# The version of this vault.
API_VERSION: constant(String[28]) = "3.0.4"
# ENUMS #
# Each permissioned function has its own Role.
# Roles can be combined in any combination or all kept separate.
# Follows python Enum patterns so the first Enum == 1 and doubles each time.
enum Roles:
ADD_STRATEGY_MANAGER # Can add strategies to the vault.
REVOKE_STRATEGY_MANAGER # Can remove strategies from the vault.
FORCE_REVOKE_MANAGER # Can force remove a strategy causing a loss.
ACCOUNTANT_MANAGER # Can set the accountant that assess fees.
QUEUE_MANAGER # Can set the default withdrawal queue.
REPORTING_MANAGER # Calls report for strategies.
DEBT_MANAGER # Adds and removes debt from strategies.
MAX_DEBT_MANAGER # Can set the max debt for a strategy.
DEPOSIT_LIMIT_MANAGER # Sets deposit limit and module for the vault.
WITHDRAW_LIMIT_MANAGER # Sets the withdraw limit module.
MINIMUM_IDLE_MANAGER # Sets the minimum total idle the vault should keep.
PROFIT_UNLOCK_MANAGER # Sets the profit_max_unlock_time.
DEBT_PURCHASER # Can purchase bad debt from the vault.
EMERGENCY_MANAGER # Can shutdown vault in an emergency.
enum StrategyChangeType:
ADDED
REVOKED
enum Rounding:
ROUND_DOWN
ROUND_UP
# STORAGE #
# Underlying token used by the vault.
asset: public(address)
# Based off the `asset` decimals.
decimals: public(uint8)
# Deployer contract used to retrieve the protocol fee config.
factory: address
# HashMap that records all the strategies that are allowed to receive assets from the vault.
strategies: public(HashMap[address, StrategyParams])
# The current default withdrawal queue.
default_queue: public(DynArray[address, MAX_QUEUE])
# Should the vault use the default_queue regardless whats passed in.
use_default_queue: public(bool)
# Should the vault automatically allocate funds to the first strategy in queue.
auto_allocate: public(bool)
### ACCOUNTING ###
# ERC20 - amount of shares per account
balance_of: HashMap[address, uint256]
# ERC20 - owner -> (spender -> amount)
allowance: public(HashMap[address, HashMap[address, uint256]])
# Total amount of shares that are currently minted including those locked.
total_supply: uint256
# Total amount of assets that has been deposited in strategies.
total_debt: uint256
# Current assets held in the vault contract. Replacing balanceOf(this) to avoid price_per_share manipulation.
total_idle: uint256
# Minimum amount of assets that should be kept in the vault contract to allow for fast, cheap redeems.
minimum_total_idle: public(uint256)
# Maximum amount of tokens that the vault can accept. If totalAssets > deposit_limit, deposits will revert.
deposit_limit: public(uint256)
### PERIPHERY ###
# Contract that charges fees and can give refunds.
accountant: public(address)
# Contract to control the deposit limit.
deposit_limit_module: public(address)
# Contract to control the withdraw limit.
withdraw_limit_module: public(address)
### ROLES ###
# HashMap mapping addresses to their roles
roles: public(HashMap[address, Roles])
# Address that can add and remove roles to addresses.
role_manager: public(address)
# Temporary variable to store the address of the next role_manager until the role is accepted.
future_role_manager: public(address)
# ERC20 - name of the vaults token.
name: public(String[64])
# ERC20 - symbol of the vaults token.
symbol: public(String[32])
# State of the vault - if set to true, only withdrawals will be available. It can't be reverted.
shutdown: bool
# The amount of time profits will unlock over.
profit_max_unlock_time: uint256
# The timestamp of when the current unlocking period ends.
full_profit_unlock_date: uint256
# The per second rate at which profit will unlock.
profit_unlocking_rate: uint256
# Last timestamp of the most recent profitable report.
last_profit_update: uint256
# `nonces` track `permit` approvals with signature.
nonces: public(HashMap[address, uint256])
DOMAIN_TYPE_HASH: constant(bytes32) = keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)')
PERMIT_TYPE_HASH: constant(bytes32) = keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
# Constructor
@external
def __init__():
# Set `asset` so it cannot be re-initialized.
self.asset = self
@external
def initialize(
asset: address,
name: String[64],
symbol: String[32],
role_manager: address,
profit_max_unlock_time: uint256
):
"""
@notice
Initialize a new vault. Sets the asset, name, symbol, and role manager.
@param asset
The address of the asset that the vault will accept.
@param name
The name of the vault token.
@param symbol
The symbol of the vault token.
@param role_manager
The address that can add and remove roles to addresses
@param profit_max_unlock_time
The amount of time that the profit will be locked for
"""
assert self.asset == empty(address), "initialized"
assert asset != empty(address), "ZERO ADDRESS"
assert role_manager != empty(address), "ZERO ADDRESS"
self.asset = asset
# Get the decimals for the vault to use.
self.decimals = ERC20Detailed(asset).decimals()
# Set the factory as the deployer address.
self.factory = msg.sender
# Must be less than one year for report cycles
assert profit_max_unlock_time <= 31_556_952 # dev: profit unlock time too long
self.profit_max_unlock_time = profit_max_unlock_time
self.name = name
self.symbol = symbol
self.role_manager = role_manager
## SHARE MANAGEMENT ##
## ERC20 ##
@internal
def _spend_allowance(owner: address, spender: address, amount: uint256):
# Unlimited approval does nothing (saves an SSTORE)
current_allowance: uint256 = self.allowance[owner][spender]
if (current_allowance < max_value(uint256)):
assert current_allowance >= amount, "insufficient allowance"
self._approve(owner, spender, unsafe_sub(current_allowance, amount))
@internal
def _transfer(sender: address, receiver: address, amount: uint256):
sender_balance: uint256 = self.balance_of[sender]
assert sender_balance >= amount, "insufficient funds"
self.balance_of[sender] = unsafe_sub(sender_balance, amount)
self.balance_of[receiver] = unsafe_add(self.balance_of[receiver], amount)
log Transfer(sender, receiver, amount)
@internal
def _transfer_from(sender: address, receiver: address, amount: uint256) -> bool:
self._spend_allowance(sender, msg.sender, amount)
self._transfer(sender, receiver, amount)
return True
@internal
def _approve(owner: address, spender: address, amount: uint256) -> bool:
self.allowance[owner][spender] = amount
log Approval(owner, spender, amount)
return True
@internal
def _permit(
owner: address,
spender: address,
amount: uint256,
deadline: uint256,
v: uint8,
r: bytes32,
s: bytes32
) -> bool:
assert owner != empty(address), "invalid owner"
assert deadline >= block.timestamp, "permit expired"
nonce: uint256 = self.nonces[owner]
digest: bytes32 = keccak256(
concat(
b'\x19\x01',
self.domain_separator(),
keccak256(
concat(
PERMIT_TYPE_HASH,
convert(owner, bytes32),
convert(spender, bytes32),
convert(amount, bytes32),
convert(nonce, bytes32),
convert(deadline, bytes32),
)
)
)
)
assert ecrecover(
digest, v, r, s
) == owner, "invalid signature"
self.allowance[owner][spender] = amount
self.nonces[owner] = nonce + 1
log Approval(owner, spender, amount)
return True
@internal
def _burn_shares(shares: uint256, owner: address):
self.balance_of[owner] -= shares
self.total_supply = unsafe_sub(self.total_supply, shares)
log Transfer(owner, empty(address), shares)
@view
@internal
def _unlocked_shares() -> uint256:
"""
Returns the amount of shares that have been unlocked.
To avoid sudden price_per_share spikes, profits can be processed
through an unlocking period. The mechanism involves shares to be
minted to the vault which are unlocked gradually over time. Shares
that have been locked are gradually unlocked over profit_max_unlock_time.
"""
_full_profit_unlock_date: uint256 = self.full_profit_unlock_date
unlocked_shares: uint256 = 0
if _full_profit_unlock_date > block.timestamp:
# If we have not fully unlocked, we need to calculate how much has been.
unlocked_shares = self.profit_unlocking_rate * (block.timestamp - self.last_profit_update) / MAX_BPS_EXTENDED
elif _full_profit_unlock_date != 0:
# All shares have been unlocked
unlocked_shares = self.balance_of[self]
return unlocked_shares
@view
@internal
def _total_supply() -> uint256:
# Need to account for the shares issued to the vault that have unlocked.
return self.total_supply - self._unlocked_shares()
@view
@internal
def _total_assets() -> uint256:
"""
Total amount of assets that are in the vault and in the strategies.
"""
return self.total_idle + self.total_debt
@view
@internal
def _convert_to_assets(shares: uint256, rounding: Rounding) -> uint256:
"""
assets = shares * (total_assets / total_supply) --- (== price_per_share * shares)
"""
if shares == max_value(uint256) or shares == 0:
return shares
total_supply: uint256 = self._total_supply()
# if total_supply is 0, price_per_share is 1
if total_supply == 0:
return shares
numerator: uint256 = shares * self._total_assets()
amount: uint256 = numerator / total_supply
if rounding == Rounding.ROUND_UP and numerator % total_supply != 0:
amount += 1
return amount
@view
@internal
def _convert_to_shares(assets: uint256, rounding: Rounding) -> uint256:
"""
shares = amount * (total_supply / total_assets) --- (== amount / price_per_share)
"""
if assets == max_value(uint256) or assets == 0:
return assets
total_supply: uint256 = self._total_supply()
# if total_supply is 0, price_per_share is 1
if total_supply == 0:
return assets
total_assets: uint256 = self._total_assets()
# if total_Supply > 0 but total_assets == 0, price_per_share = 0
if total_assets == 0:
return 0
numerator: uint256 = assets * total_supply
shares: uint256 = numerator / total_assets
if rounding == Rounding.ROUND_UP and numerator % total_assets != 0:
shares += 1
return shares
@internal
def _erc20_safe_approve(token: address, spender: address, amount: uint256):
# Used only to approve tokens that are not the type managed by this Vault.
# Used to handle non-compliant tokens like USDT
assert ERC20(token).approve(spender, amount, default_return_value=True), "approval failed"
@internal
def _erc20_safe_transfer_from(token: address, sender: address, receiver: address, amount: uint256):
# Used only to transfer tokens that are not the type managed by this Vault.
# Used to handle non-compliant tokens like USDT
assert ERC20(token).transferFrom(sender, receiver, amount, default_return_value=True), "transfer failed"
@internal
def _erc20_safe_transfer(token: address, receiver: address, amount: uint256):
# Used only to send tokens that are not the type managed by this Vault.
# Used to handle non-compliant tokens like USDT
assert ERC20(token).transfer(receiver, amount, default_return_value=True), "transfer failed"
@internal
def _issue_shares(shares: uint256, recipient: address):
self.balance_of[recipient] = unsafe_add(self.balance_of[recipient], shares)
self.total_supply += shares
log Transfer(empty(address), recipient, shares)
## ERC4626 ##
@view
@internal
def _max_deposit(receiver: address) -> uint256:
if receiver in [empty(address), self]:
return 0
# If there is a deposit limit module set use that.
deposit_limit_module: address = self.deposit_limit_module
if deposit_limit_module != empty(address):
return IDepositLimitModule(deposit_limit_module).available_deposit_limit(receiver)
# Else use the standard flow.
_deposit_limit: uint256 = self.deposit_limit
if (_deposit_limit == max_value(uint256)):
return _deposit_limit
_total_assets: uint256 = self._total_assets()
if (_total_assets >= _deposit_limit):
return 0
return unsafe_sub(_deposit_limit, _total_assets)
@view
@internal
def _max_withdraw(
owner: address,
max_loss: uint256,
strategies: DynArray[address, MAX_QUEUE]
) -> uint256:
"""
@dev Returns the max amount of `asset` an `owner` can withdraw.
This will do a full simulation of the withdraw in order to determine
how much is currently liquid and if the `max_loss` would allow for the
tx to not revert.
This will track any expected loss to check if the tx will revert, but
not account for it in the amount returned since it is unrealised and
therefore will not be accounted for in the conversion rates.
i.e. If we have 100 debt and 10 of unrealised loss, the max we can get
out is 90, but a user of the vault will need to call withdraw with 100
in order to get the full 90 out.
"""
# Get the max amount for the owner if fully liquid.
max_assets: uint256 = self._convert_to_assets(self.balance_of[owner], Rounding.ROUND_DOWN)
# If there is a withdraw limit module use that.
withdraw_limit_module: address = self.withdraw_limit_module
if withdraw_limit_module != empty(address):
return min(
# Use the min between the returned value and the max.
# Means the limit module doesn't need to account for balances or conversions.
IWithdrawLimitModule(withdraw_limit_module).available_withdraw_limit(owner, max_loss, strategies),
max_assets
)
# See if we have enough idle to service the withdraw.
current_idle: uint256 = self.total_idle
if max_assets > current_idle:
# Track how much we can pull.
have: uint256 = current_idle
loss: uint256 = 0
# Cache the default queue.
_strategies: DynArray[address, MAX_QUEUE] = self.default_queue
# If a custom queue was passed, and we don't force the default queue.
if len(strategies) != 0 and not self.use_default_queue:
# Use the custom queue.
_strategies = strategies
for strategy in _strategies:
# Can't use an invalid strategy.
assert self.strategies[strategy].activation != 0, "inactive strategy"
current_debt: uint256 = self.strategies[strategy].current_debt
# Get the maximum amount the vault would withdraw from the strategy.
to_withdraw: uint256 = min(
# What we still need for the full withdraw.
max_assets - have,
# The current debt the strategy has.
current_debt
)
# Get any unrealised loss for the strategy.
unrealised_loss: uint256 = self._assess_share_of_unrealised_losses(strategy, current_debt, to_withdraw)
# See if any limit is enforced by the strategy.
strategy_limit: uint256 = IStrategy(strategy).convertToAssets(
IStrategy(strategy).maxRedeem(self)
)
# Adjust accordingly if there is a max withdraw limit.
realizable_withdraw: uint256 = to_withdraw - unrealised_loss
if strategy_limit < realizable_withdraw:
if unrealised_loss != 0:
# lower unrealised loss proportional to the limit.
unrealised_loss = unrealised_loss * strategy_limit / realizable_withdraw
# Still count the unrealised loss as withdrawable.
to_withdraw = strategy_limit + unrealised_loss
# If 0 move on to the next strategy.
if to_withdraw == 0:
continue
# If there would be a loss with a non-maximum `max_loss` value.
if unrealised_loss > 0 and max_loss < MAX_BPS:
# Check if the loss is greater than the allowed range.
if loss + unrealised_loss > (have + to_withdraw) * max_loss / MAX_BPS:
# If so use the amounts up till now.
break
# Add to what we can pull.
have += to_withdraw
# If we have all we need break.
if have >= max_assets:
break
# Add any unrealised loss to the total
loss += unrealised_loss
# Update the max after going through the queue.
# In case we broke early or exhausted the queue.
max_assets = have
return max_assets
@internal
def _deposit(recipient: address, assets: uint256, shares: uint256):
"""
Used for `deposit` and `mint` calls to transfer the amount of `asset` to the vault,
issue the corresponding `shares` to the `recipient` and update all needed
vault accounting.
"""
assert assets <= self._max_deposit(recipient), "exceed deposit limit"
assert assets > 0, "cannot deposit zero"
assert shares > 0, "cannot mint zero"
# Transfer the tokens to the vault first.
self._erc20_safe_transfer_from(self.asset, msg.sender, self, assets)
# Record the change in total assets.
self.total_idle += assets
# Issue the corresponding shares for assets.
self._issue_shares(shares, recipient)
log Deposit(msg.sender, recipient, assets, shares)
if self.auto_allocate:
self._update_debt(self.default_queue[0], max_value(uint256), 0)
@view
@internal
def _assess_share_of_unrealised_losses(strategy: address, strategy_current_debt: uint256, assets_needed: uint256) -> uint256:
"""
Returns the share of losses that a user would take if withdrawing from this strategy
This accounts for losses that have been realized at the strategy level but not yet
realized at the vault level.
e.g. if the strategy has unrealised losses for 10% of its current debt and the user
wants to withdraw 1_000 tokens, the losses that they will take is 100 token
"""
# The actual amount that the debt is currently worth.
vault_shares: uint256 = IStrategy(strategy).balanceOf(self)
strategy_assets: uint256 = IStrategy(strategy).convertToAssets(vault_shares)
# If no losses, return 0
if strategy_assets >= strategy_current_debt or strategy_current_debt == 0:
return 0
# Users will withdraw assets_needed divided by loss ratio (strategy_assets / strategy_current_debt - 1).
# NOTE: If there are unrealised losses, the user will take his share.
numerator: uint256 = assets_needed * strategy_assets
users_share_of_loss: uint256 = assets_needed - numerator / strategy_current_debt
# Always round up.
if numerator % strategy_current_debt != 0:
users_share_of_loss += 1
return users_share_of_loss
@internal
def _withdraw_from_strategy(strategy: address, assets_to_withdraw: uint256):
"""
This takes the amount denominated in asset and performs a {redeem}
with the corresponding amount of shares.
We use {redeem} to natively take on losses without additional non-4626 standard parameters.
"""
# Need to get shares since we use redeem to be able to take on losses.
shares_to_redeem: uint256 = min(
# Use previewWithdraw since it should round up.
IStrategy(strategy).previewWithdraw(assets_to_withdraw),
# And check against our actual balance.
IStrategy(strategy).balanceOf(self)
)
# Redeem the shares.
IStrategy(strategy).redeem(shares_to_redeem, self, self)
@internal
def _redeem(
sender: address,
receiver: address,
owner: address,
assets: uint256,
shares: uint256,
max_loss: uint256,
strategies: DynArray[address, MAX_QUEUE]
) -> uint256:
"""
This will attempt to free up the full amount of assets equivalent to
`shares` and transfer them to the `receiver`. If the vault does
not have enough idle funds it will go through any strategies provided by
either the withdrawer or the default_queue to free up enough funds to
service the request.
The vault will attempt to account for any unrealized losses taken on from
strategies since their respective last reports.
Any losses realized during the withdraw from a strategy will be passed on
to the user that is redeeming their vault shares unless it exceeds the given
`max_loss`.
"""
assert receiver != empty(address), "ZERO ADDRESS"
assert shares > 0, "no shares to redeem"
assert assets > 0, "no assets to withdraw"
assert max_loss <= MAX_BPS, "max loss"
# If there is a withdraw limit module, check the max.
withdraw_limit_module: address = self.withdraw_limit_module
if withdraw_limit_module != empty(address):
assert assets <= IWithdrawLimitModule(withdraw_limit_module).available_withdraw_limit(owner, max_loss, strategies), "exceed withdraw limit"
assert self.balance_of[owner] >= shares, "insufficient shares to redeem"
if sender != owner:
self._spend_allowance(owner, sender, shares)
# The amount of the underlying token to withdraw.
requested_assets: uint256 = assets
# load to memory to save gas
current_total_idle: uint256 = self.total_idle
_asset: address = self.asset
# If there are not enough assets in the Vault contract, we try to free
# funds from strategies.
if requested_assets > current_total_idle:
# Cache the default queue.
_strategies: DynArray[address, MAX_QUEUE] = self.default_queue
# If a custom queue was passed, and we don't force the default queue.
if len(strategies) != 0 and not self.use_default_queue:
# Use the custom queue.
_strategies = strategies
# load to memory to save gas
current_total_debt: uint256 = self.total_debt
# Withdraw from strategies only what idle doesn't cover.
# `assets_needed` is the total amount we need to fill the request.
assets_needed: uint256 = unsafe_sub(requested_assets, current_total_idle)
# `assets_to_withdraw` is the amount to request from the current strategy.
assets_to_withdraw: uint256 = 0
# To compare against real withdrawals from strategies
previous_balance: uint256 = ERC20(_asset).balanceOf(self)
for strategy in _strategies:
# Make sure we have a valid strategy.
assert self.strategies[strategy].activation != 0, "inactive strategy"
# How much should the strategy have.
current_debt: uint256 = self.strategies[strategy].current_debt
# What is the max amount to withdraw from this strategy.
assets_to_withdraw = min(assets_needed, current_debt)
# Cache max_withdraw now for use if unrealized loss > 0
# Use maxRedeem and convert it since we use redeem.
max_withdraw: uint256 = IStrategy(strategy).convertToAssets(
IStrategy(strategy).maxRedeem(self)
)
# CHECK FOR UNREALISED LOSSES
# If unrealised losses > 0, then the user will take the proportional share
# and realize it (required to avoid users withdrawing from lossy strategies).
# NOTE: strategies need to manage the fact that realising part of the loss can
# mean the realisation of 100% of the loss!! (i.e. if for withdrawing 10% of the
# strategy it needs to unwind the whole position, generated losses might be bigger)
unrealised_losses_share: uint256 = self._assess_share_of_unrealised_losses(strategy, current_debt, assets_to_withdraw)
if unrealised_losses_share > 0:
# If max withdraw is limiting the amount to pull, we need to adjust the portion of
# the unrealized loss the user should take.
if max_withdraw < assets_to_withdraw - unrealised_losses_share:
# How much would we want to withdraw
wanted: uint256 = assets_to_withdraw - unrealised_losses_share
# Get the proportion of unrealised comparing what we want vs. what we can get
unrealised_losses_share = unrealised_losses_share * max_withdraw / wanted
# Adjust assets_to_withdraw so all future calculations work correctly
assets_to_withdraw = max_withdraw + unrealised_losses_share
# User now "needs" less assets to be unlocked (as he took some as losses)
assets_to_withdraw -= unrealised_losses_share
requested_assets -= unrealised_losses_share
# NOTE: done here instead of waiting for regular update of these values
# because it's a rare case (so we can save minor amounts of gas)
assets_needed -= unrealised_losses_share
current_total_debt -= unrealised_losses_share
# If max withdraw is 0 and unrealised loss is still > 0 then the strategy likely
# realized a 100% loss and we will need to realize that loss before moving on.
if max_withdraw == 0 and unrealised_losses_share > 0:
# Adjust the strategy debt accordingly.
new_debt: uint256 = current_debt - unrealised_losses_share
# Update strategies storage
self.strategies[strategy].current_debt = new_debt
# Log the debt update
log DebtUpdated(strategy, current_debt, new_debt)
# Adjust based on the max withdraw of the strategy.
assets_to_withdraw = min(assets_to_withdraw, max_withdraw)
# Can't withdraw 0.
if assets_to_withdraw == 0:
continue
# WITHDRAW FROM STRATEGY
self._withdraw_from_strategy(strategy, assets_to_withdraw)
post_balance: uint256 = ERC20(_asset).balanceOf(self)
# Always check against the real amounts.
withdrawn: uint256 = post_balance - previous_balance
loss: uint256 = 0
# Check if we redeemed too much.
if withdrawn > assets_to_withdraw:
# Make sure we don't underflow in debt updates.
if withdrawn > current_debt:
# Can't withdraw more than our debt.
assets_to_withdraw = current_debt
else:
# Add the extra to how much we withdrew.
assets_to_withdraw += (unsafe_sub(withdrawn, assets_to_withdraw))
# If we have not received what we expected, we consider the difference a loss.
elif withdrawn < assets_to_withdraw:
loss = unsafe_sub(assets_to_withdraw, withdrawn)
# NOTE: strategy's debt decreases by the full amount but the total idle increases
# by the actual amount only (as the difference is considered lost).
current_total_idle += (unsafe_sub(assets_to_withdraw, loss))
requested_assets -= loss
current_total_debt -= assets_to_withdraw
# Vault will reduce debt because the unrealised loss has been taken by user
new_debt: uint256 = current_debt - (assets_to_withdraw + unrealised_losses_share)
# Update strategies storage
self.strategies[strategy].current_debt = new_debt
# Log the debt update
log DebtUpdated(strategy, current_debt, new_debt)
# Break if we have enough total idle to serve initial request.
if requested_assets <= current_total_idle:
break
# We update the previous_balance variable here to save gas in next iteration.
previous_balance = post_balance
# Reduce what we still need. Safe to use assets_to_withdraw
# here since it has been checked against requested_assets
assets_needed -= assets_to_withdraw
# If we exhaust the queue and still have insufficient total idle, revert.
assert current_total_idle >= requested_assets, "insufficient assets in vault"
# Commit memory to storage.
self.total_debt = current_total_debt
# Check if there is a loss and a non-default value was set.
if assets > requested_assets and max_loss < MAX_BPS:
# Assure the loss is within the allowed range.
assert assets - requested_assets <= assets * max_loss / MAX_BPS, "too much loss"
# First burn the corresponding shares from the redeemer.
self._burn_shares(shares, owner)
# Commit memory to storage.
self.total_idle = current_total_idle - requested_assets
# Transfer the requested amount to the receiver.
self._erc20_safe_transfer(_asset, receiver, requested_assets)
log Withdraw(sender, receiver, owner, requested_assets, shares)
return requested_assets
## STRATEGY MANAGEMENT ##
@internal
def _add_strategy(new_strategy: address, add_to_queue: bool):
assert new_strategy not in [self, empty(address)], "strategy cannot be zero address"
assert IStrategy(new_strategy).asset() == self.asset, "invalid asset"
assert self.strategies[new_strategy].activation == 0, "strategy already active"
# Add the new strategy to the mapping.
self.strategies[new_strategy] = StrategyParams({
activation: block.timestamp,
last_report: block.timestamp,
current_debt: 0,
max_debt: 0
})
# If we are adding to the queue and the default queue has space, add the strategy.
if add_to_queue and len(self.default_queue) < MAX_QUEUE:
self.default_queue.append(new_strategy)
log StrategyChanged(new_strategy, StrategyChangeType.ADDED)
@internal
def _revoke_strategy(strategy: address, force: bool=False):
assert self.strategies[strategy].activation != 0, "strategy not active"
if self.strategies[strategy].current_debt != 0:
assert force, "strategy has debt"
# Vault realizes the full loss of outstanding debt.
loss: uint256 = self.strategies[strategy].current_debt
# Adjust total vault debt.
self.total_debt -= loss
log StrategyReported(strategy, 0, loss, 0, 0, 0, 0)
# Set strategy params all back to 0 (WARNING: it can be re-added).
self.strategies[strategy] = StrategyParams({
activation: 0,
last_report: 0,
current_debt: 0,
max_debt: 0
})
# Remove strategy if it is in the default queue.
new_queue: DynArray[address, MAX_QUEUE] = []
for _strategy in self.default_queue:
# Add all strategies to the new queue besides the one revoked.
if _strategy != strategy:
new_queue.append(_strategy)
# Set the default queue to our updated queue.
self.default_queue = new_queue
log StrategyChanged(strategy, StrategyChangeType.REVOKED)
# DEBT MANAGEMENT #
@internal
def _update_debt(strategy: address, target_debt: uint256, max_loss: uint256) -> uint256:
"""
The vault will re-balance the debt vs target debt. Target debt must be
smaller or equal to strategy's max_debt. This function will compare the
current debt with the target debt and will take funds or deposit new
funds to the strategy.
The strategy can require a maximum amount of funds that it wants to receive
to invest. The strategy can also reject freeing funds if they are locked.
"""
# How much we want the strategy to have.
new_debt: uint256 = target_debt
# How much the strategy currently has.
current_debt: uint256 = self.strategies[strategy].current_debt
# If the vault is shutdown we can only pull funds.
if self.shutdown:
new_debt = 0
assert new_debt != current_debt, "new debt equals current debt"
if current_debt > new_debt:
# Reduce debt.
assets_to_withdraw: uint256 = unsafe_sub(current_debt, new_debt)
# Ensure we always have minimum_total_idle when updating debt.
minimum_total_idle: uint256 = self.minimum_total_idle
total_idle: uint256 = self.total_idle
# Respect minimum total idle in vault
if total_idle + assets_to_withdraw < minimum_total_idle:
assets_to_withdraw = unsafe_sub(minimum_total_idle, total_idle)
# Cant withdraw more than the strategy has.
if assets_to_withdraw > current_debt:
assets_to_withdraw = current_debt