Skip to content

Commit 72fed91

Browse files
authored
Enable quota-based instant delegation with era restrictions (#447)
* Implement quota-based instant delegation feature Add support for instant delegation with per-wallet, per-era quota limits to encourage delegation while preventing attacks: - Add quota tracking with auto-reset on era change using storage-efficient struct - Implement 70% era window restriction to prevent last-minute sniping - Support automatic splitting of large delegations into instant + pending portions - Reuse existing applyRedelegation() for instant rewards distribution - Add configuration parameters for quota amount and era window percentage Changes: - Staking.sol: Add instant quota tracking, token transfer, and parameter configuration - StakingManager.sol: Refactor delegate() with era progress calculation and quota logic - IStaking.sol: Add new interface methods for instant delegation - Test coverage for all instant delegation scenarios * Refactor instant delegation implementation - Move InstantQuotaUsage struct to IStaking interface for better organization - Remove delegateToIndexer function, use transferDelegationTokens + addDelegation instead - Extract common transferDelegationTokens call in delegate() to reduce code duplication * clean up * Run yarn dedupe to fix dependency resolution Fix YN0078 invalid resolution warning for @ethersproject/abi and other packages * Fix invalid resolution for @ethersproject/abi@npm:5.0.7 Remove invalid resolution entry that pointed 5.0.7 to 5.1.2, let yarn regenerate the correct resolution * Add resolutions to fix @ethersproject version conflicts Fix TypeScript compilation errors caused by multiple versions of @ethersproject packages. Force all @ethersproject dependencies to use 5.5.x versions to match ethers 5.5.4 * fix vesting test * fix per comment * make test more robust * upgrade to testnet - make test more robust * remove .only from Staking.test.ts * fix per comment * replace @nomiclabs/hardhat-etherscan with @nomicfoundation/hardhat-verify
1 parent 842525f commit 72fed91

File tree

17 files changed

+13046
-14451
lines changed

17 files changed

+13046
-14451
lines changed

.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs

Lines changed: 0 additions & 541 deletions
This file was deleted.

.yarn/plugins/@yarnpkg/plugin-typescript.cjs

Lines changed: 0 additions & 9 deletions
This file was deleted.

.yarn/plugins/@yarnpkg/plugin-version.cjs

Lines changed: 0 additions & 550 deletions
This file was deleted.

.yarn/releases/yarn-3.6.4.cjs

Lines changed: 0 additions & 874 deletions
This file was deleted.

.yarn/releases/yarn-4.10.3.cjs

Lines changed: 942 additions & 0 deletions
Large diffs are not rendered by default.

.yarnrc.yml

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,15 @@
11
changesetBaseRefs:
2-
- main
3-
- origin/main
2+
- main
3+
- origin/main
44

55
enableImmutableInstalls: false
66

77
enableProgressBars: false
88

99
nodeLinker: node-modules
1010

11-
npmAuthToken: '${NPM_TOKEN:-}'
11+
npmAuthToken: "${NPM_TOKEN:-}"
1212

13-
npmPublishRegistry: 'https://registry.npmjs.org'
13+
npmPublishRegistry: "https://registry.npmjs.org"
1414

15-
plugins:
16-
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
17-
spec: '@yarnpkg/plugin-typescript'
18-
- path: .yarn/plugins/@yarnpkg/plugin-version.cjs
19-
spec: '@yarnpkg/plugin-version'
20-
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
21-
spec: '@yarnpkg/plugin-interactive-tools'
22-
23-
yarnPath: .yarn/releases/yarn-3.6.4.cjs
15+
yarnPath: .yarn/releases/yarn-4.10.3.cjs

contracts/Staking.sol

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,17 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter {
128128
// Staking runner lengths
129129
mapping(address => uint256) public stakingIndexerLengths;
130130

131+
// Instant delegation quota per era (per wallet)
132+
uint256 public instantDelegationQuota;
133+
134+
// Era window percentage for instant delegation (in perMill, e.g., 700,000 = 70%)
135+
uint256 public instantEraWindowPercent;
136+
137+
// Instant quota usage tracking
138+
139+
// Track instant quota used per delegator: delegator => QuotaUsage
140+
mapping(address => InstantQuotaUsage) public instantQuotaUsed;
141+
131142
// -- Events --
132143

133144
/**
@@ -350,18 +361,20 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter {
350361
emit DelegationAdded2(_source, _runner, _amount, instant);
351362
}
352363

353-
function delegateToIndexer(
364+
/**
365+
* @dev Transfer tokens from source to this contract (for instant delegation)
366+
* @param _source The source address
367+
* @param _amount The amount to transfer
368+
*/
369+
function transferDelegationTokens(
354370
address _source,
355-
address _runner,
356371
uint256 _amount
357372
) external onlyStakingManager {
358373
IERC20(settings.getContractAddress(SQContracts.SQToken)).safeTransferFrom(
359374
_source,
360375
address(this),
361376
_amount
362377
);
363-
364-
this.addDelegation(_source, _runner, _amount, false);
365378
}
366379

367380
function removeDelegation(address _source, address _runner, uint256 _amount) external {
@@ -492,11 +505,72 @@ contract Staking is IStaking, Initializable, OwnableUpgradeable, SQParameter {
492505
this.startUnbond(_runner, _runner, _amount, UnbondType.Commission);
493506
}
494507

508+
/**
509+
* @notice Update instant quota used for a delegator in a specific era
510+
* @param delegator The delegator address
511+
* @param era The era number
512+
* @param amount The amount to add to used quota
513+
*/
514+
function updateInstantQuotaUsed(
515+
address delegator,
516+
uint256 era,
517+
uint256 amount
518+
) external onlyStakingManager {
519+
InstantQuotaUsage storage usage = instantQuotaUsed[delegator];
520+
521+
// If era changed, reset the quota usage
522+
if (usage.era != era) {
523+
usage.era = era;
524+
usage.amount = amount;
525+
} else {
526+
// Same era, accumulate
527+
usage.amount += amount;
528+
}
529+
}
530+
531+
/**
532+
* @notice Set instant delegation parameters
533+
* @param _perEraQuota The quota per era per wallet
534+
* @param _windowPercent The era window percentage (in perMill)
535+
*/
536+
function setInstantDelegationParams(
537+
uint256 _perEraQuota,
538+
uint256 _windowPercent
539+
) external onlyOwner {
540+
require(_windowPercent <= PER_MILL, 'S015');
541+
instantDelegationQuota = _perEraQuota;
542+
instantEraWindowPercent = _windowPercent;
543+
544+
emit Parameter('instantDelegationQuota', abi.encode(_perEraQuota));
545+
emit Parameter('instantEraWindowPercent', abi.encode(_windowPercent));
546+
}
547+
495548
// -- Views --
496549

497550
function isEmptyDelegation(address _source, address _runner) external view returns (bool) {
498551
return
499552
delegation[_source][_runner].valueAt == 0 &&
500553
delegation[_source][_runner].valueAfter == 0;
501554
}
555+
556+
/**
557+
* @notice Get remaining instant quota for a delegator in a specific era
558+
* @param delegator The delegator address
559+
* @param era The era number
560+
* @return The remaining quota amount
561+
*/
562+
function getInstantQuotaRemaining(
563+
address delegator,
564+
uint256 era
565+
) external view returns (uint256) {
566+
InstantQuotaUsage memory usage = instantQuotaUsed[delegator];
567+
568+
// If different era or not yet used, full quota available
569+
if (usage.era != era) {
570+
return instantDelegationQuota;
571+
}
572+
573+
// Same era, return remaining
574+
return instantDelegationQuota > usage.amount ? instantDelegationQuota - usage.amount : 0;
575+
}
502576
}

contracts/StakingManager.sol

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import './interfaces/IIndexerRegistry.sol';
1111
import './interfaces/IStakingManager.sol';
1212
import './utils/MathUtil.sol';
1313
import './utils/StakingUtil.sol';
14+
import './Constants.sol';
1415
import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol';
1516
import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol';
1617

1718
/**
1819
* Split from Staking, to keep contract size under control
1920
*/
2021
contract StakingManager is IStakingManager, Initializable, OwnableUpgradeable {
22+
using MathUtil for uint256;
23+
2124
ISettings public settings;
2225

2326
/**
@@ -50,18 +53,59 @@ contract StakingManager is IStakingManager, Initializable, OwnableUpgradeable {
5053
} else {
5154
require(msg.sender == _runner, 'G002');
5255
}
53-
staking.delegateToIndexer(_runner, _runner, _amount);
56+
staking.transferDelegationTokens(_runner, _amount);
57+
staking.addDelegation(_runner, _runner, _amount, false);
5458
}
5559

5660
/**
5761
* @dev Delegator stake to Indexer, Indexer cannot call this.
62+
* Supports instant delegation with quota-based limits and era window restrictions.
5863
*/
5964
function delegate(address _runner, uint256 _amount) external {
6065
require(msg.sender != _runner, 'G004');
6166
Staking staking = Staking(settings.getContractAddress(SQContracts.Staking));
62-
// delegation limit should not exceed
67+
68+
// Check delegation limitation
6369
staking.checkDelegateLimitation(_runner, _amount);
64-
staking.delegateToIndexer(msg.sender, _runner, _amount);
70+
71+
// Transfer tokens first
72+
staking.transferDelegationTokens(msg.sender, _amount);
73+
74+
// Check era progress (70% window by default)
75+
uint256 eraProgress = _calculateEraProgress();
76+
uint256 windowPercent = staking.instantEraWindowPercent();
77+
bool inInstantWindow = windowPercent > 0 && eraProgress <= windowPercent;
78+
79+
if (!inInstantWindow) {
80+
// After window: all delegation is pending
81+
staking.addDelegation(msg.sender, _runner, _amount, false);
82+
return;
83+
}
84+
85+
// Within window: check quota
86+
uint256 remainingQuota = _getRemainingQuota(msg.sender);
87+
88+
if (_amount <= remainingQuota) {
89+
// Case A: Fully instant
90+
staking.addDelegation(msg.sender, _runner, _amount, true);
91+
_applyInstantDelegation(msg.sender, _runner);
92+
_consumeInstantQuota(msg.sender, _amount);
93+
} else if (remainingQuota > 0) {
94+
// Case B: Split - instant + pending
95+
uint256 instantAmount = remainingQuota;
96+
uint256 pendingAmount = _amount - remainingQuota;
97+
98+
// Instant portion
99+
staking.addDelegation(msg.sender, _runner, instantAmount, true);
100+
_applyInstantDelegation(msg.sender, _runner);
101+
_consumeInstantQuota(msg.sender, instantAmount);
102+
103+
// Pending portion
104+
staking.addDelegation(msg.sender, _runner, pendingAmount, false);
105+
} else {
106+
// Case C: Quota exhausted - all pending
107+
staking.addDelegation(msg.sender, _runner, _amount, false);
108+
}
65109
}
66110

67111
/**
@@ -214,6 +258,64 @@ contract StakingManager is IStakingManager, Initializable, OwnableUpgradeable {
214258
staking.slashRunner(_indexer, _amount);
215259
}
216260

261+
/**
262+
* @dev Calculate current era progress as a percentage (in perMill)
263+
* @return Progress value (0-PER_MILL, where PER_MILL = 100%)
264+
*/
265+
function _calculateEraProgress() internal view returns (uint256) {
266+
IEraManager eraManager = IEraManager(settings.getContractAddress(SQContracts.EraManager));
267+
268+
uint256 eraStartTime = eraManager.eraStartTime();
269+
uint256 eraPeriod = eraManager.eraPeriod();
270+
271+
uint256 elapsed = block.timestamp - eraStartTime;
272+
273+
// Prevent overflow: if elapsed >= eraPeriod, return 100%
274+
if (elapsed >= eraPeriod) {
275+
return PER_MILL;
276+
}
277+
278+
return MathUtil.mulDiv(elapsed, PER_MILL, eraPeriod);
279+
}
280+
281+
/**
282+
* @dev Get remaining instant delegation quota for a delegator
283+
* @param delegator The delegator address
284+
* @return Remaining quota amount
285+
*/
286+
function _getRemainingQuota(address delegator) internal view returns (uint256) {
287+
IEraManager eraManager = IEraManager(settings.getContractAddress(SQContracts.EraManager));
288+
Staking staking = Staking(settings.getContractAddress(SQContracts.Staking));
289+
290+
uint256 currentEra = eraManager.eraNumber();
291+
return staking.getInstantQuotaRemaining(delegator, currentEra);
292+
}
293+
294+
/**
295+
* @dev Apply instant delegation to rewards system
296+
* @param delegator The delegator address
297+
* @param runner The runner address
298+
*/
299+
function _applyInstantDelegation(address delegator, address runner) internal {
300+
IRewardsStaking rewardsStaking = IRewardsStaking(
301+
settings.getContractAddress(SQContracts.RewardsStaking)
302+
);
303+
rewardsStaking.applyRedelegation(runner, delegator);
304+
}
305+
306+
/**
307+
* @dev Consume instant quota for a delegator
308+
* @param delegator The delegator address
309+
* @param amount The amount to consume
310+
*/
311+
function _consumeInstantQuota(address delegator, uint256 amount) internal {
312+
IEraManager eraManager = IEraManager(settings.getContractAddress(SQContracts.EraManager));
313+
Staking staking = Staking(settings.getContractAddress(SQContracts.Staking));
314+
315+
uint256 currentEra = eraManager.eraNumber();
316+
staking.updateInstantQuotaUsed(delegator, currentEra, amount);
317+
}
318+
217319
// -- Views --
218320

219321
function _getCurrentDelegationAmount(

contracts/interfaces/IStaking.sol

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,39 @@ enum UnbondType {
3030
Merge
3131
}
3232

33+
/**
34+
* @dev Instant delegation quota tracking. One per Delegator.
35+
* Tracks quota usage within current era, auto-resets on era change.
36+
*/
37+
struct InstantQuotaUsage {
38+
uint256 era; // era of quota usage
39+
uint256 amount; // quota used in this era
40+
}
41+
3342
interface IStaking {
3443
function lockedAmount(address _delegator) external view returns (uint256);
3544

3645
function unbondCommission(address _runner, uint256 _amount) external;
46+
47+
function addDelegation(
48+
address _source,
49+
address _runner,
50+
uint256 _amount,
51+
bool instant
52+
) external;
53+
54+
function transferDelegationTokens(address _source, uint256 _amount) external;
55+
56+
function updateInstantQuotaUsed(address delegator, uint256 era, uint256 amount) external;
57+
58+
function setInstantDelegationParams(uint256 _perEraQuota, uint256 _windowPercent) external;
59+
60+
function getInstantQuotaRemaining(
61+
address delegator,
62+
uint256 era
63+
) external view returns (uint256);
64+
65+
function instantDelegationQuota() external view returns (uint256);
66+
67+
function instantEraWindowPercent() external view returns (uint256);
3768
}

hardhat.config.ts

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as dotenv from 'dotenv';
2-
import '@nomiclabs/hardhat-etherscan';
2+
import '@nomicfoundation/hardhat-verify';
33
import '@nomiclabs/hardhat-waffle';
44
import '@typechain/hardhat';
55
import 'hardhat-contract-sizer';
@@ -453,33 +453,7 @@ const config: HardhatUserConfig = {
453453
gasPrice: 21,
454454
},
455455
etherscan: {
456-
apiKey: {
457-
polygonMumbai: process.env.POLYGONSCAN_API_KEY,
458-
goerli: process.env.ETHERSCAN_API_KEY,
459-
sepolia: process.env.ETHERSCAN_API_KEY,
460-
base: process.env.BASESCAN_API_KEY,
461-
'base-sepolia': process.env.BASESCAN_API_KEY,
462-
polygon: process.env.POLYGONSCAN_API_KEY,
463-
mainnet: process.env.ETHERSCAN_API_KEY,
464-
},
465-
customChains: [
466-
{
467-
network: 'base-sepolia',
468-
chainId: 84532,
469-
urls: {
470-
apiURL: 'https://api-sepolia.basescan.org/api',
471-
browserURL: 'https://sepolia.basescan.org',
472-
},
473-
},
474-
{
475-
network: 'base',
476-
chainId: 8453,
477-
urls: {
478-
apiURL: 'https://api.basescan.org/api',
479-
browserURL: 'https://basescan.org',
480-
},
481-
},
482-
],
456+
apiKey: process.env.ETHERSCAN_API_KEY,
483457
},
484458
typechain: {
485459
outDir: 'src/typechain',

0 commit comments

Comments
 (0)