-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathStakeWeight.sol
More file actions
1497 lines (1266 loc) · 65.3 KB
/
StakeWeight.sol
File metadata and controls
1497 lines (1266 loc) · 65.3 KB
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
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";
import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol";
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { Pauser } from "./Pauser.sol";
import { WalletConnectConfig } from "./WalletConnectConfig.sol";
import { L2WCT } from "./L2WCT.sol";
/**
* @title StakeWeight
* @notice This contract implements a vote-escrowed token model for WCT (WalletConnect Token)
* to create a staking mechanism with time-weighted power.
* @dev This contract was inspired by Curve's veCRV and PancakeSwap's veCake implementations.
* @author WalletConnect
*/
contract StakeWeight is Initializable, AccessControlUpgradeable, ReentrancyGuardUpgradeable {
using SafeERC20 for IERC20;
/*//////////////////////////////////////////////////////////////////////////
STRUCTS
//////////////////////////////////////////////////////////////////////////*/
/// @notice A point in the linear decay graph
struct Point {
/// @notice The bias of the point
int128 bias;
/// @notice The slope of the point
int128 slope;
/// @notice The timestamp of the point
uint256 timestamp;
/// @notice The block number of the point
uint256 blockNumber;
}
/// @notice A struct representing a locked balance
struct LockedBalance {
/// @notice The amount of locked tokens
int128 amount;
/// @notice The end time of the lock
uint256 end;
/// @notice The transferred tokens (if any)
uint256 transferredAmount;
}
/// @notice Initialization parameters for the StakeWeight contract
struct Init {
/// @notice The address of the admin
address admin;
/// @notice The address of the WalletConnectConfig contract
address config;
}
/*//////////////////////////////////////////////////////////////////////////
CONSTANTS
//////////////////////////////////////////////////////////////////////////*/
// Define the storage namespace
bytes32 constant STAKE_WEIGHT_STORAGE_POSITION = keccak256("com.walletconnect.stakeweight.storage");
// Max lock duration
uint256 public constant MAX_LOCK_CAP = (209 weeks) - 1;
// Multiplier for the slope calculation
uint256 public constant MULTIPLIER = 1e18;
// Maximum iterations for checkpoint loops (approx 5 years)
uint256 private constant MAX_CHECKPOINT_ITERATIONS = 255;
// Maximum iterations for binary search
uint256 private constant MAX_BINARY_SEARCH_ITERATIONS = 128;
// Action Types
uint256 public constant ACTION_DEPOSIT_FOR = 0;
uint256 public constant ACTION_CREATE_LOCK = 1;
uint256 public constant ACTION_INCREASE_LOCK_AMOUNT = 2;
uint256 public constant ACTION_INCREASE_UNLOCK_TIME = 3;
uint256 public constant ACTION_UPDATE_LOCK = 4;
// Roles
// @dev Role for the locked token staker, needs to happen after deployment for circular dependency
bytes32 public constant LOCKED_TOKEN_STAKER_ROLE = keccak256("LOCKED_TOKEN_STAKER_ROLE");
/*//////////////////////////////////////////////////////////////////////////
STORAGE
//////////////////////////////////////////////////////////////////////////*/
/// @dev Storage structure for StakeWeight
/// @custom:storage-location erc7201:com.walletconnect.stakeweight.storage
struct StakeWeightStorage {
/// @notice Configuration for WalletConnect
WalletConnectConfig config;
/// @notice Total supply of WCT locked
uint256 supply;
/// @notice Maximum lock duration
uint256 maxLock;
/// @notice Mapping (user => LockedBalance) to keep locking information for each user
mapping(address user => LockedBalance lock) locks;
/// @notice A global point of time
uint256 epoch;
/// @notice An array of points (global)
Point[] pointHistory;
/// @notice Mapping (user => Point[]) to keep track of user point of a given epoch (index of Point is epoch)
mapping(address user => Point[] points) userPointHistory;
/// @notice Mapping (user => epoch) to keep track which epoch user is at
mapping(address user => uint256 epoch) userPointEpoch;
/// @notice Mapping (round off timestamp to week => slopeDelta) to keep track of slope changes over epoch
mapping(uint256 timestamp => int128 slopeDelta) slopeChanges;
/// ---------------- Permanent lock storage (DO NOT REORDER ABOVE) ----------------
// Permanent state (per-user)
mapping(address => bool) isPermanent; // current permanent flag
mapping(address => uint256) permanentBaseWeeks; // chosen discrete weeks
mapping(address => uint256) permanentStakeWeight; // current user's permanent stake weight (amount * baseWeeks /
// maxWeeks)
// Global accumulator (stake weight units, not tokens)
uint256 permanentTotalSupply; // sum of all permanent stake weight
// Parallel histories (align with existing epochs / userEpochs)
mapping(uint256 => uint256) globalPermanentSupplyAtEpoch; // epoch -> permanentTotalSupply at that global point
mapping(address => mapping(uint256 => uint256)) userPermanentWeightAtEpoch; // user, userEpoch -> permanent
// stake weight at that user point
}
function _getStakeWeightStorage() internal pure returns (StakeWeightStorage storage s) {
bytes32 position = STAKE_WEIGHT_STORAGE_POSITION;
assembly {
s.slot := position
}
}
function _requireNotPaused(StakeWeightStorage storage s) private view {
_requireNotPausedConfig(s.config);
}
function _requireNotPausedConfig(WalletConnectConfig config_) private view {
if (Pauser(config_.getPauser()).isStakeWeightPaused()) revert Paused();
}
function _validatePermanentDuration(uint256 duration) private pure returns (uint256 durationWeeks) {
durationWeeks = duration / 1 weeks;
if (
durationWeeks != 4 && durationWeeks != 8 && durationWeeks != 12 && durationWeeks != 26
&& durationWeeks != 52 && durationWeeks != 78 && durationWeeks != 104
) revert InvalidDuration(duration);
}
/*//////////////////////////////////////////////////////////////////////////
EVENTS
//////////////////////////////////////////////////////////////////////////*/
event Deposit(
address indexed provider,
uint256 amount,
uint256 locktime,
uint256 type_,
uint256 transferredAmount,
uint256 timestamp
);
event Withdraw(address indexed provider, uint256 totalAmount, uint256 transferredAmount, uint256 timestamp);
event ForcedWithdraw(
address indexed provider,
uint256 totalAmount,
uint256 transferredAmount,
uint256 timestamp,
uint256 endTimestamp
);
event Supply(uint256 previousSupply, uint256 newSupply);
event MaxLockUpdated(uint256 previousMaxLock, uint256 newMaxLock);
event PermanentConversion(address indexed user, uint256 duration, uint256 timestamp);
event UnlockTriggered(address indexed user, uint256 endTime, uint256 timestamp);
event DurationIncreased(address indexed user, uint256 newDuration, uint256 timestamp);
/*//////////////////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////////////////*/
/// @notice Thrown when an invalid amount is provided
/// @param amount The invalid amount
error InvalidAmount(uint256 amount);
/// @notice Thrown when an invalid address is provided
/// @param addr The invalid address
error InvalidAddress(address addr);
/// @notice Thrown when attempting to create a lock that already exists
error AlreadyCreatedLock();
/// @notice Thrown when attempting to operate on a non-existent lock
error NonExistentLock();
/// @notice Thrown when attempting to withdraw from an active lock
/// @param lockEndTime The time when the lock ends
error LockStillActive(uint256 lockEndTime);
/// @notice Thrown when an invalid unlock time is provided
/// @param unlockTime The invalid unlock time
error InvalidUnlockTime(uint256 unlockTime);
/// @notice Thrown when an invalid max lock duration is provided
/// @param maxLock The invalid max lock duration
error InvalidMaxLock(uint256 maxLock);
/// @notice Thrown when attempting to operate on an expired lock
/// @param currentTime The current time
/// @param lockEndTime The time when the lock ended
error ExpiredLock(uint256 currentTime, uint256 lockEndTime);
/// @notice Thrown when attempting to create a lock exceeding the maximum duration
/// @param attemptedDuration The attempted lock duration
/// @param maxDuration The maximum allowed lock duration
error LockMaxDurationExceeded(uint256 attemptedDuration, uint256 maxDuration);
/// @notice Thrown when attempting to increase lock time but new duration is not greater
/// @param currentDuration The current lock duration
/// @param attemptedDuration The attempted new lock duration
error LockTimeNotIncreased(uint256 currentDuration, uint256 attemptedDuration);
/// @notice Thrown when attempting to query a future block number
/// @param currentBlock The current block number
/// @param requestedBlock The requested future block number
error FutureBlockNumber(uint256 currentBlock, uint256 requestedBlock);
/// @notice Thrown when the locked amount is insufficient for an operation
/// @param actualLockedAmount The actual locked amount
/// @param requiredLockedAmount The required locked amount
error InsufficientLockedAmount(uint256 actualLockedAmount, uint256 requiredLockedAmount);
/// @notice Thrown when attempting to perform an action while the contract is paused
error Paused();
/// @notice Thrown when attempting to perform an action while transfer restrictions are enabled
error TransferRestrictionsEnabled();
/// @notice Thrown when an invalid duration is provided for permanent lock
/// @param duration The invalid duration
error InvalidDuration(uint256 duration);
/// @notice Thrown when attempting to convert an already permanent position
error AlreadyPermanent();
/// @notice Thrown when attempting to trigger unlock on a non-permanent position
error NotPermanent();
/// @notice Thrown when new duration is shorter than remaining lock time
/// @param newDuration The attempted new duration
/// @param remainingTime The remaining lock time
error DurationTooShort(uint256 newDuration, uint256 remainingTime);
/*//////////////////////////////////////////////////////////////////////////
CONSTRUCTOR
//////////////////////////////////////////////////////////////////////////*/
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
/*//////////////////////////////////////////////////////////////////////////
INITIALIZER
//////////////////////////////////////////////////////////////////////////*/
function initialize(Init memory init) external initializer {
__AccessControl_init();
__ReentrancyGuard_init();
if (init.config == address(0)) revert InvalidAddress(init.config);
if (init.admin == address(0)) revert InvalidAddress(init.admin);
_grantRole(DEFAULT_ADMIN_ROLE, init.admin);
StakeWeightStorage storage s = _getStakeWeightStorage();
s.config = WalletConnectConfig(init.config);
// Around 2 years in seconds (based on weeks)
s.maxLock = 105 weeks - 1;
s.pointHistory.push(Point({ bias: 0, slope: 0, timestamp: block.timestamp, blockNumber: block.number }));
}
/*//////////////////////////////////////////////////////////////////////////
FUNCTIONS
//////////////////////////////////////////////////////////////////////////*/
/// @notice Return the balance of Stake Weight at a given "blockNumber"
/// @param user The address to get a balance of Stake Weight
/// @param blockNumber The specific block number that you want to check the balance of Stake Weight
function balanceOfAt(address user, uint256 blockNumber) external view returns (uint256) {
return _balanceOfAt(user, blockNumber);
}
function _balanceOfAt(address user, uint256 blockNumber) internal view returns (uint256) {
StakeWeightStorage storage s = _getStakeWeightStorage();
// Get most recent user Point to block
uint256 userEpoch = _findUserBlockEpoch(user, blockNumber);
if (userEpoch == 0) {
return 0;
}
// Check if user was permanent at this epoch
uint256 permanentWeight = s.userPermanentWeightAtEpoch[user][userEpoch];
if (permanentWeight > 0) {
return permanentWeight;
}
Point memory userPoint = s.userPointHistory[user][userEpoch];
// Get most recent global point to block
uint256 maxEpoch = s.epoch;
uint256 epoch_ = _findBlockEpoch(blockNumber, maxEpoch);
Point memory point0 = s.pointHistory[epoch_];
uint256 blockDelta = 0;
uint256 timeDelta = 0;
if (epoch_ < maxEpoch) {
Point memory point1 = s.pointHistory[epoch_ + 1];
blockDelta = point1.blockNumber - point0.blockNumber;
timeDelta = point1.timestamp - point0.timestamp;
} else {
blockDelta = block.number - point0.blockNumber;
timeDelta = block.timestamp - point0.timestamp;
}
uint256 blockTime = point0.timestamp;
if (blockDelta != 0) {
blockTime += (timeDelta * (blockNumber - point0.blockNumber)) / blockDelta;
}
userPoint.bias -= (userPoint.slope * SafeCast.toInt128(int256(blockTime - userPoint.timestamp)));
if (userPoint.bias < 0) {
return 0;
}
return SafeCast.toUint256(userPoint.bias);
}
/// @notice Return the voting weight of a givne user
/// @param user The address of a user
function balanceOf(address user) external view returns (uint256) {
StakeWeightStorage storage s = _getStakeWeightStorage();
if (s.isPermanent[user]) {
return s.permanentStakeWeight[user];
}
return _balanceOf(user, block.timestamp);
}
/// @notice Calculate the stake weight of a user at a specific timestamp
/// @dev This function is primarily designed for calculating future balances.
/// CAUTION: It will revert due to underflow for timestamps before the user's last checkpoint.
/// This behavior is intentional and should be carefully considered when calling this function.
/// - For timestamps > last checkpoint: Projects future balance based on current slope
/// - For timestamps == last checkpoint: Returns current balance
/// - For timestamps < last checkpoint: Reverts due to underflow
/// Use with care in contract interactions and consider implementing try/catch for calls to this function.
/// @param user The address of the user to check
/// @param timestamp The timestamp to check the stake weight at
/// @return The user's projected stake weight at the specified timestamp
function balanceOfAtTime(address user, uint256 timestamp) external view returns (uint256) {
StakeWeightStorage storage s = _getStakeWeightStorage();
// If user has no history at all, it's definitely zero
uint256 uEpochMax = s.userPointEpoch[user];
if (uEpochMax == 0) {
return 0;
}
// If caller asks before the first user checkpoint, also return 0 (not revert)
uint256 firstTs = s.userPointHistory[user][1].timestamp;
if (timestamp < firstTs) {
return 0;
}
// Find the epoch at timestamp
uint256 uEpoch = _findUserTimestampEpoch(user, timestamp);
if (uEpoch == 0) {
return 0;
}
// If user was permanent at that epoch, return permanent weight directly
uint256 perm = s.userPermanentWeightAtEpoch[user][uEpoch];
if (perm > 0) return perm;
// Otherwise fall through to decaying math
return _balanceOf(user, timestamp);
}
function _balanceOf(address user, uint256 timestamp) internal view returns (uint256) {
StakeWeightStorage storage s = _getStakeWeightStorage();
// Find the user epoch at this timestamp
uint256 uEpoch = _findUserTimestampEpoch(user, timestamp);
if (uEpoch == 0) {
return 0;
}
// Check if user was permanent at this epoch
uint256 permanentWeight = s.userPermanentWeightAtEpoch[user][uEpoch];
if (permanentWeight > 0) {
return permanentWeight;
}
// Calculate decaying balance
Point memory lastPoint = s.userPointHistory[user][uEpoch];
lastPoint.bias = lastPoint.bias - (lastPoint.slope * SafeCast.toInt128(int256(timestamp - lastPoint.timestamp)));
if (lastPoint.bias < 0) {
lastPoint.bias = 0;
}
return SafeCast.toUint256(lastPoint.bias);
}
/// @notice Record global and per-user slope to checkpoint
/// @param address_ User's wallet address. Only global if 0x0
/// @param prevLocked User's previous locked balance and end lock time
/// @param newLocked User's new locked balance and end lock time
function _checkpoint(address address_, LockedBalance memory prevLocked, LockedBalance memory newLocked) internal {
StakeWeightStorage storage s = _getStakeWeightStorage();
Point memory userPrevPoint = Point({ slope: 0, bias: 0, timestamp: 0, blockNumber: 0 });
Point memory userNewPoint = Point({ slope: 0, bias: 0, timestamp: 0, blockNumber: 0 });
int128 prevSlopeDelta = 0;
int128 newSlopeDelta = 0;
uint256 epoch_ = s.epoch;
// if not 0x0, then update user's point
if (address_ != address(0)) {
// Calculate slopes and biases according to linear decay graph
// slope = lockedAmount / MAX_LOCK_CAP => Get the slope of a linear decay graph
// bias = slope * (lockedEnd - currentTimestamp) => Get the voting weight at a given time
// Kept at zero when they have to
// IMPORTANT: Skip decaying math for permanent locks (end == 0)
if (prevLocked.end > 0 && prevLocked.end > block.timestamp && prevLocked.amount > 0) {
// Calculate slope and bias for the prev point
userPrevPoint.slope = prevLocked.amount / SafeCast.toInt128(int256(MAX_LOCK_CAP));
userPrevPoint.bias = userPrevPoint.slope * SafeCast.toInt128(int256(prevLocked.end - block.timestamp));
}
if (newLocked.end > 0 && newLocked.end > block.timestamp && newLocked.amount > 0) {
// Calculate slope and bias for the new point
userNewPoint.slope = newLocked.amount / SafeCast.toInt128(int256(MAX_LOCK_CAP));
userNewPoint.bias = userNewPoint.slope * SafeCast.toInt128(int256(newLocked.end - block.timestamp));
}
// Handle user history here
// Do it here to prevent stack overflow
uint256 userEpoch = s.userPointEpoch[address_];
// If user never ever has any point history, push it here for him.
if (userEpoch == 0) {
s.userPointHistory[address_].push(userPrevPoint);
}
// Shift user's epoch by 1 as we are writing a new point for a user
s.userPointEpoch[address_] = userEpoch + 1;
// Update timestamp & block number then push new point to user's history
userNewPoint.timestamp = block.timestamp;
userNewPoint.blockNumber = block.number;
s.userPointHistory[address_].push(userNewPoint);
// Read values of scheduled changes in the slope
// prevLocked.end can be in the past and in the future
// newLocked.end can ONLY be in the FUTURE unless everything expired (anything more than zeros)
// IMPORTANT: Only read slopeChanges for non-permanent locks
if (prevLocked.end > 0) {
prevSlopeDelta = s.slopeChanges[prevLocked.end];
}
if (newLocked.end > 0) {
// Handle when newLocked.end != 0
if (newLocked.end == prevLocked.end) {
// This will happen when user adjust lock but end remains the same
// Possibly when user deposited more WCT to his locker
newSlopeDelta = prevSlopeDelta;
} else {
// This will happen when user increase lock
newSlopeDelta = s.slopeChanges[newLocked.end];
}
}
}
// Handle global states here
Point memory lastPoint = Point({ bias: 0, slope: 0, timestamp: block.timestamp, blockNumber: block.number });
if (epoch_ > 0) {
// If epoch_ > 0, then there is some history written
// Hence, lastPoint should be pointHistory[epoch_]
// else lastPoint should an empty point
lastPoint = s.pointHistory[epoch_];
}
// lastCheckpoint => timestamp of the latest point
// if no history, lastCheckpoint should be block.timestamp
// else lastCheckpoint should be the timestamp of latest pointHistory
uint256 lastCheckpoint = lastPoint.timestamp;
// initialLastPoint is used for extrapolation to calculate block number
// (approximately, for xxxAt methods) and save them
// as we cannot figure that out exactly from inside contract
Point memory initialLastPoint =
Point({ bias: 0, slope: 0, timestamp: lastPoint.timestamp, blockNumber: lastPoint.blockNumber });
// If last point is already recorded in this block, blockSlope=0
// That is ok because we know the block in such case
uint256 blockSlope = 0;
if (block.timestamp > lastPoint.timestamp) {
// Recalculate blockSlope if lastPoint.timestamp < block.timestamp
// Possiblity when epoch = 0 or blockSlope hasn't get updated in this block
blockSlope = (MULTIPLIER * (block.number - lastPoint.blockNumber)) / (block.timestamp - lastPoint.timestamp);
}
// Go over weeks to fill history and calculate what the current point is
uint256 weekCursor = _timestampToFloorWeek(lastCheckpoint);
for (uint256 i = 0; i < MAX_CHECKPOINT_ITERATIONS; i++) {
// This logic will works for 5 years, if more than that vote power will be broken 😟
// Bump weekCursor a week
weekCursor = weekCursor + 1 weeks;
int128 slopeDelta = 0;
if (weekCursor > block.timestamp) {
// If the given weekCursor go beyond block.timestamp,
// We take block.timestamp as the cursor
weekCursor = block.timestamp;
} else {
// If the given weekCursor is behind block.timestamp
// We take slopeDelta from the recorded slopeChanges
// We can use weekCursor directly because key of slopeChanges is timestamp round off to week
slopeDelta = s.slopeChanges[weekCursor];
}
// Calculate biasDelta = lastPoint.slope * (weekCursor - lastCheckpoint)
int128 biasDelta = lastPoint.slope * SafeCast.toInt128(int256((weekCursor - lastCheckpoint)));
lastPoint.bias = lastPoint.bias - biasDelta;
lastPoint.slope = lastPoint.slope + slopeDelta;
if (lastPoint.bias < 0) {
// This can happen
lastPoint.bias = 0;
}
if (lastPoint.slope < 0) {
// This cannot happen, just make sure
lastPoint.slope = 0;
}
// Update lastPoint to the new one
lastCheckpoint = weekCursor;
lastPoint.timestamp = weekCursor;
// As we cannot figure that out block timestamp -> block number exactly
// when query states from xxxAt methods, we need to calculate block number
// based on initalLastPoint
lastPoint.blockNumber =
initialLastPoint.blockNumber + ((blockSlope * ((weekCursor - initialLastPoint.timestamp))) / MULTIPLIER);
epoch_ = epoch_ + 1;
if (weekCursor == block.timestamp) {
// Hard to be happened, but better handling this case too
lastPoint.blockNumber = block.number;
break;
} else {
s.pointHistory.push(lastPoint);
// Record parallel permanent history indexed by array length
uint256 newEpochIndex = s.pointHistory.length - 1;
s.globalPermanentSupplyAtEpoch[newEpochIndex] = s.permanentTotalSupply;
}
}
// Now, each week pointHistory has been filled until current timestamp (round off by week)
// Update epoch to be the latest state
s.epoch = epoch_;
if (address_ != address(0)) {
// If the last point was in the block, the slope change should have been applied already
// But in such case slope shall be 0
lastPoint.slope = lastPoint.slope + userNewPoint.slope - userPrevPoint.slope;
lastPoint.bias = lastPoint.bias + userNewPoint.bias - userPrevPoint.bias;
if (lastPoint.slope < 0) {
lastPoint.slope = 0;
}
if (lastPoint.bias < 0) {
lastPoint.bias = 0;
}
}
// Record the new point to pointHistory
// This would be the latest point for global epoch
s.pointHistory.push(lastPoint);
// Record parallel permanent history indexed by array length
s.globalPermanentSupplyAtEpoch[s.pointHistory.length - 1] = s.permanentTotalSupply;
if (address_ != address(0)) {
// Schedule the slope changes (slope is going downward)
// We substract newSlopeDelta from `newLocked.end`
// and add prevSlopeDelta to `prevLocked.end`
// IMPORTANT: Skip slopeChanges for permanent locks (end == 0)
if (prevLocked.end > 0 && prevLocked.end > block.timestamp) {
// prevSlopeDelta was <something> - userPrevPoint.slope, so we offset that first
prevSlopeDelta = prevSlopeDelta + userPrevPoint.slope;
if (newLocked.end == prevLocked.end) {
// Handle the new deposit. Not increasing lock.
prevSlopeDelta = prevSlopeDelta - userNewPoint.slope;
}
s.slopeChanges[prevLocked.end] = prevSlopeDelta;
}
if (newLocked.end > 0 && newLocked.end > block.timestamp) {
if (newLocked.end > prevLocked.end) {
// At this line, the old slope should gone
newSlopeDelta = newSlopeDelta - userNewPoint.slope;
s.slopeChanges[newLocked.end] = newSlopeDelta;
}
}
}
}
/// @notice Trigger global checkpoint
function checkpoint() external {
LockedBalance memory empty = LockedBalance({ amount: 0, end: 0, transferredAmount: 0 });
_checkpoint(address(0), empty, empty);
}
/// @notice Create a new lock.
/// @dev This will crate a new lock and deposit WCT to Stake Weight Vault
/// @param amount the amount that user wishes to deposit
/// @param unlockTime the timestamp when WCT get unlocked, it will be
/// floored down to whole weeks
function createLock(uint256 amount, uint256 unlockTime) external nonReentrant {
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
_createLock(msg.sender, amount, unlockTime, true);
}
function createLockFor(
address for_,
uint256 amount,
uint256 unlockTime
)
external
nonReentrant
onlyRole(LOCKED_TOKEN_STAKER_ROLE)
{
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
_createLock(for_, amount, unlockTime, false);
}
function _createLock(address for_, uint256 amount, uint256 unlockTime, bool isTransferred) internal {
unlockTime = _timestampToFloorWeek(unlockTime);
StakeWeightStorage storage s = _getStakeWeightStorage();
LockedBalance memory locked = s.locks[for_];
if (amount == 0) revert InvalidAmount(amount);
if (locked.amount != 0) revert AlreadyCreatedLock();
if (unlockTime <= block.timestamp) revert InvalidUnlockTime(unlockTime);
if (unlockTime > block.timestamp + s.maxLock) {
revert LockMaxDurationExceeded(unlockTime, block.timestamp + s.maxLock);
}
_depositFor(for_, amount, unlockTime, locked, ACTION_CREATE_LOCK, isTransferred);
}
/// @notice Deposit `amount` tokens for `for_` and add to `locks[for_]`
/// @dev This function is used for deposit to created lock. Not for extend locktime.
/// @param for_ The address to do the deposit
/// @param amount The amount that user wishes to deposit
function depositFor(address for_, uint256 amount) external nonReentrant {
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
if (L2WCT(s.config.getL2wct()).transferRestrictionsDisabledAfter() >= block.timestamp) {
revert TransferRestrictionsEnabled();
}
LockedBalance memory lock = LockedBalance({
amount: s.locks[for_].amount,
end: s.locks[for_].end,
transferredAmount: s.locks[for_].transferredAmount
});
if (for_ == address(0)) revert InvalidAddress(for_);
if (amount == 0) revert InvalidAmount(amount);
if (lock.amount == 0) revert NonExistentLock();
if (lock.end <= block.timestamp) revert ExpiredLock(block.timestamp, lock.end);
_depositFor(for_, amount, 0, lock, ACTION_DEPOSIT_FOR, true);
}
/// @notice Internal function to perform deposit and lock WCT for a user
/// @param for_ The address to be locked and received Stake Weight
/// @param amount The amount to deposit
/// @param unlockTime New time to unlock WCT. Pass 0 if no change.
/// @param prevLocked Existed locks[for]
/// @param actionType The action that user did as this internal function shared among
function _depositFor(
address for_,
uint256 amount,
uint256 unlockTime,
LockedBalance memory prevLocked,
uint256 actionType,
bool isTransferred
)
internal
{
StakeWeightStorage storage s = _getStakeWeightStorage();
// Initiate supplyBefore & update supply
uint256 supplyBefore = s.supply;
s.supply = supplyBefore + amount;
// Store prevLocked
LockedBalance memory newLocked = LockedBalance({
amount: prevLocked.amount,
end: prevLocked.end,
transferredAmount: prevLocked.transferredAmount
});
// Adding new lock to existing lock, or if lock is expired
// - creating a new one
newLocked.amount = newLocked.amount + SafeCast.toInt128(int256(amount));
if (unlockTime != 0) {
newLocked.end = unlockTime;
}
if (isTransferred) {
newLocked.transferredAmount += amount;
}
s.locks[for_] = newLocked;
// Handling checkpoint here
_checkpoint(for_, prevLocked, newLocked);
// Handle permanent positions - recalculate weight from total amount
if (s.isPermanent[for_] && amount > 0) {
_updatePermanentWeight(for_, newLocked);
}
if (isTransferred) {
IERC20(s.config.getL2wct()).safeTransferFrom(msg.sender, address(this), amount);
}
emit Deposit(for_, amount, newLocked.end, actionType, isTransferred ? amount : 0, block.timestamp);
emit Supply(supplyBefore, s.supply);
}
/// @notice Find the epoch that contains a given timestamp
/// @param timestamp The timestamp to find the epoch for
function _findTimestampEpoch(uint256 timestamp) internal view returns (uint256) {
StakeWeightStorage storage s = _getStakeWeightStorage();
uint256 min = 0;
uint256 max = s.epoch;
// Binary search through epochs
for (uint256 i = 0; i < MAX_BINARY_SEARCH_ITERATIONS; i++) {
if (min >= max) {
break;
}
uint256 mid = (min + max + 1) / 2;
if (s.pointHistory[mid].timestamp <= timestamp) {
min = mid;
} else {
max = mid - 1;
}
}
return min;
}
/// @notice Do Binary Search to find out block timestamp for block number
/// @param blockNumber The block number to find timestamp
/// @param maxEpoch No beyond this timestamp
function _findBlockEpoch(uint256 blockNumber, uint256 maxEpoch) internal view returns (uint256) {
StakeWeightStorage storage s = _getStakeWeightStorage();
uint256 min = 0;
uint256 max = maxEpoch;
// Loop for 128 times -> enough for 128-bit numbers
for (uint256 i = 0; i < MAX_BINARY_SEARCH_ITERATIONS; i++) {
if (min >= max) {
break;
}
uint256 mid = (min + max + 1) / 2;
if (s.pointHistory[mid].blockNumber <= blockNumber) {
min = mid;
} else {
max = mid - 1;
}
}
return min;
}
/// @notice Find the user epoch that contains a given timestamp
/// @param user The user address
/// @param timestamp The timestamp to find the epoch for
function _findUserTimestampEpoch(address user, uint256 timestamp) internal view returns (uint256) {
StakeWeightStorage storage s = _getStakeWeightStorage();
uint256 min = 0;
uint256 max = s.userPointEpoch[user];
for (uint256 i = 0; i < MAX_BINARY_SEARCH_ITERATIONS; i++) {
if (min >= max) break;
uint256 mid = (min + max + 1) / 2;
if (s.userPointHistory[user][mid].timestamp <= timestamp) {
min = mid;
} else {
max = mid - 1;
}
}
return min;
}
/// @notice Do Binary Search to find the most recent user point history preceeding block
/// @param user The address of user to find
/// @param blockNumber Find the most recent point history before this block number
function _findUserBlockEpoch(address user, uint256 blockNumber) internal view returns (uint256) {
StakeWeightStorage storage s = _getStakeWeightStorage();
uint256 min = 0;
uint256 max = s.userPointEpoch[user];
for (uint256 i = 0; i < MAX_BINARY_SEARCH_ITERATIONS; i++) {
if (min >= max) {
break;
}
uint256 mid = (min + max + 1) / 2;
if (s.userPointHistory[user][mid].blockNumber <= blockNumber) {
min = mid;
} else {
max = mid - 1;
}
}
return min;
}
/// @notice Increase lock amount without increase "end"
/// @param amount The amount of WCT to be added to the lock
function increaseLockAmount(uint256 amount) external nonReentrant {
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
_increaseLockAmount(msg.sender, amount, true);
}
function increaseLockAmountFor(
address for_,
uint256 amount
)
external
nonReentrant
onlyRole(LOCKED_TOKEN_STAKER_ROLE)
{
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
_increaseLockAmount(for_, amount, false);
}
function _increaseLockAmount(address for_, uint256 amount, bool isTransferred) internal {
StakeWeightStorage storage s = _getStakeWeightStorage();
LockedBalance memory lock = s.locks[for_];
if (amount == 0) revert InvalidAmount(amount);
if (lock.amount == 0) revert NonExistentLock();
if (lock.end <= block.timestamp) revert ExpiredLock(block.timestamp, lock.end);
_depositFor(for_, amount, 0, lock, ACTION_INCREASE_LOCK_AMOUNT, isTransferred);
}
/// @notice Increase unlock time without changing locked amount
/// @param newUnlockTime The new unlock time to be updated
function increaseUnlockTime(uint256 newUnlockTime) external nonReentrant {
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
if (s.isPermanent[msg.sender]) revert AlreadyPermanent();
LockedBalance memory locked = s.locks[msg.sender];
newUnlockTime = _timestampToFloorWeek(newUnlockTime);
if (locked.amount == 0) revert NonExistentLock();
if (locked.end <= block.timestamp) revert ExpiredLock(block.timestamp, locked.end);
if (newUnlockTime <= locked.end) revert LockTimeNotIncreased(locked.end, newUnlockTime);
if (newUnlockTime > block.timestamp + s.maxLock) {
revert LockMaxDurationExceeded(newUnlockTime, _timestampToFloorWeek(block.timestamp + s.maxLock));
}
_depositFor(msg.sender, 0, newUnlockTime, locked, ACTION_INCREASE_UNLOCK_TIME, false);
}
/// @notice Atomically update both lock duration and amount
/// @param amount The additional amount to lock
/// @param unlockTime The new unlock time
function updateLock(uint256 amount, uint256 unlockTime) external nonReentrant {
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
if (s.isPermanent[msg.sender]) revert AlreadyPermanent();
LockedBalance memory lock = s.locks[msg.sender];
if (lock.amount == 0) revert NonExistentLock();
if (lock.end <= block.timestamp) revert ExpiredLock(block.timestamp, lock.end);
if (amount == 0) revert InvalidAmount(amount);
// Floor the unlock time first
unlockTime = _timestampToFloorWeek(unlockTime);
if (unlockTime <= lock.end) revert LockTimeNotIncreased(lock.end, unlockTime);
if (unlockTime > _timestampToFloorWeek(block.timestamp + s.maxLock)) {
revert LockMaxDurationExceeded(unlockTime, _timestampToFloorWeek(block.timestamp + s.maxLock));
}
// Update both unlock time and amount in a single _depositFor call
_depositFor(msg.sender, amount, unlockTime, lock, ACTION_UPDATE_LOCK, true);
}
/// @notice Convert an existing decaying lock to permanent
/// @param duration The duration for future unlocking (must be >= remaining lock time)
function convertToPermanent(uint256 duration) external nonReentrant {
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
LockedBalance memory lock = s.locks[msg.sender];
if (lock.amount == 0) revert NonExistentLock();
if (s.isPermanent[msg.sender]) revert AlreadyPermanent();
if (lock.end <= block.timestamp) revert ExpiredLock(block.timestamp, lock.end);
uint256 baseWeeks = _validatePermanentDuration(duration);
// Ensure new duration is not shorter than remaining lock time
uint256 remainingTime = lock.end - block.timestamp;
if (duration < remainingTime) revert DurationTooShort(duration, remainingTime);
// Store base weeks for later unlock
s.permanentBaseWeeks[msg.sender] = baseWeeks;
// TWO-PHASE CHECKPOINT for clean conversion:
// Phase 1: Remove all decaying weight (amount -> 0)
LockedBalance memory zeroLock = LockedBalance({
amount: 0,
end: lock.end, // Keep end for proper slope change cancellation
transferredAmount: lock.transferredAmount
});
_checkpoint(msg.sender, lock, zeroLock);
// Phase 2: Create permanent lock (restore amount with end = 0)
LockedBalance memory permanentLock = LockedBalance({
amount: lock.amount, // Restore the amount
end: 0, // Mark as permanent
transferredAmount: lock.transferredAmount
});
_checkpoint(msg.sender, zeroLock, permanentLock);
// CRITICAL: Persist the permanent lock with amount restored
s.locks[msg.sender] = permanentLock;
// Calculate permanent stake weight using same formula as bias calculation
// permanentWeight = amount * duration / MAX_LOCK_CAP
// This ensures consistency with decaying positions
uint256 amount = SafeCast.toUint256(lock.amount);
uint256 permanentWeight = Math.mulDiv(amount, duration, MAX_LOCK_CAP);
// Update permanent state
s.isPermanent[msg.sender] = true;
s.permanentStakeWeight[msg.sender] = permanentWeight;
s.permanentTotalSupply += permanentWeight;
// Record in parallel histories (after checkpoint incremented epoch)
uint256 userEpoch = s.userPointEpoch[msg.sender];
s.userPermanentWeightAtEpoch[msg.sender][userEpoch] = permanentWeight;
// Use pointHistory.length - 1 as the checkpoint was just pushed
s.globalPermanentSupplyAtEpoch[s.pointHistory.length - 1] = s.permanentTotalSupply;
emit PermanentConversion(msg.sender, duration, block.timestamp);
}
/// @notice Trigger unlocking of a permanent position
function triggerUnlock() external nonReentrant {
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
LockedBalance memory lock = s.locks[msg.sender];
if (lock.amount == 0) revert NonExistentLock();
if (!s.isPermanent[msg.sender]) revert NotPermanent();
// Get stored base weeks
uint256 baseWeeks = s.permanentBaseWeeks[msg.sender];
uint256 newEnd = _timestampToFloorWeek(block.timestamp + baseWeeks * 1 weeks);
LockedBalance memory prev = lock;
// Create synthetic next lock with same amount but new end time
LockedBalance memory next =
LockedBalance({ amount: prev.amount, end: newEnd, transferredAmount: prev.transferredAmount });
// Run checkpoint to re-introduce slope and bias
_checkpoint(msg.sender, prev, next);
// CRITICAL: Persist the lock change with new end time
s.locks[msg.sender] = next;
// Remove permanent stake weight
uint256 permanentWeight = s.permanentStakeWeight[msg.sender];
s.permanentTotalSupply -= permanentWeight;
s.isPermanent[msg.sender] = false;
s.permanentStakeWeight[msg.sender] = 0;
s.permanentBaseWeeks[msg.sender] = 0; // Clear the duration
// Update parallel histories
uint256 userEpoch = s.userPointEpoch[msg.sender];
s.userPermanentWeightAtEpoch[msg.sender][userEpoch] = 0;
// Use pointHistory.length - 1 as the checkpoint was just pushed
s.globalPermanentSupplyAtEpoch[s.pointHistory.length - 1] = s.permanentTotalSupply;
emit UnlockTriggered(msg.sender, newEnd, block.timestamp);
}
/// @notice Create a new permanent lock with constant weight based on duration
/// @param amount The amount of tokens to lock
/// @param duration The duration that determines weight multiplier (must be in valid set)
function createPermanentLock(uint256 amount, uint256 duration) external nonReentrant {
StakeWeightStorage storage s = _getStakeWeightStorage();
_requireNotPaused(s);
uint256 durationWeeks = _validatePermanentDuration(duration);
// Check user doesn't already have a lock
LockedBalance memory existing = s.locks[msg.sender];
if (existing.amount != 0) revert AlreadyCreatedLock();
if (amount == 0) revert InvalidAmount(amount);
// 1) Transfer tokens and update supply