Skip to content

Commit 3882b35

Browse files
committed
fix: enforce minimum delegation in all cases, including undelegation
1 parent ef55d45 commit 3882b35

File tree

4 files changed

+54
-38
lines changed

4 files changed

+54
-38
lines changed

contracts/l2/staking/L2Staking.sol

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ contract L2Staking is Staking, IL2StakingBase {
1919
using SafeMath for uint256;
2020
using Stakes for Stakes.Indexer;
2121

22-
/// @dev Minimum delegation for the first delegator of an indexer
22+
/// @dev Minimum amount of tokens that can be delegated
2323
uint256 private constant MINIMUM_DELEGATION = 1e18;
2424

2525
/**
@@ -125,17 +125,12 @@ contract L2Staking is Staking, IL2StakingBase {
125125
Delegation storage delegation = pool.delegators[_delegationData.delegator];
126126

127127
// Calculate shares to issue (without applying any delegation tax)
128-
uint256 shares;
129-
if (pool.tokens == 0) {
130-
if (_amount >= MINIMUM_DELEGATION) {
131-
shares = _amount;
132-
}
133-
} else {
134-
shares = _amount.mul(pool.shares).div(pool.tokens);
135-
}
128+
uint256 shares = (pool.tokens == 0) ? _amount : _amount.mul(pool.shares).div(pool.tokens);
136129

137-
if (shares == 0) {
138-
// If no shares would be issued (probably a rounding issue or attack), return the tokens to the delegator
130+
if (shares == 0 || _amount < MINIMUM_DELEGATION) {
131+
// If no shares would be issued (probably a rounding issue or attack),
132+
// or if the amount is under the minimum delegation (which could be part of a rounding attack),
133+
// return the tokens to the delegator
139134
graphToken().transfer(_delegationData.delegator, _amount);
140135
emit TransferredDelegationReturnedToDelegator(
141136
_delegationData.indexer,

contracts/staking/StakingExtension.sol

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
2626

2727
/// @dev 100% in parts per million
2828
uint32 private constant MAX_PPM = 1000000;
29-
/// @dev Minimum delegation for the first delegator of an indexer
29+
/// @dev Minimum amount of tokens that can be delegated
3030
uint256 private constant MINIMUM_DELEGATION = 1e18;
3131

3232
/**
@@ -530,8 +530,8 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
530530
address _indexer,
531531
uint256 _tokens
532532
) private returns (uint256) {
533-
// Only delegate a non-zero amount of tokens
534-
require(_tokens > 0, "!tokens");
533+
// Only allow delegations over a minimum, to prevent rounding attacks
534+
require(_tokens >= MINIMUM_DELEGATION, "!minimum-delegation");
535535
// Only delegate to non-empty address
536536
require(_indexer != address(0), "!indexer");
537537
// Only delegate to staked indexer
@@ -546,13 +546,9 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
546546
uint256 delegatedTokens = _tokens.sub(delegationTax);
547547

548548
// Calculate shares to issue
549-
uint256 shares;
550-
if (pool.tokens == 0) {
551-
require(_tokens >= MINIMUM_DELEGATION, "!minimum-delegation");
552-
shares = delegatedTokens;
553-
} else {
554-
shares = delegatedTokens.mul(pool.shares).div(pool.tokens);
555-
}
549+
uint256 shares = (pool.tokens == 0)
550+
? delegatedTokens
551+
: delegatedTokens.mul(pool.shares).div(pool.tokens);
556552
require(shares > 0, "!shares");
557553

558554
// Update the delegation pool
@@ -603,6 +599,13 @@ contract StakingExtension is StakingV4Storage, GraphUpgradeable, IStakingExtensi
603599

604600
// Update the delegation
605601
delegation.shares = delegation.shares.sub(_shares);
602+
// Enforce more than the minimum delegation is left,
603+
// to prevent rounding attacks
604+
require(
605+
delegation.shares == 0 ||
606+
delegation.shares.mul(pool.tokens).div(pool.shares) >= MINIMUM_DELEGATION,
607+
"!minimum-delegation"
608+
);
606609
delegation.tokensLocked = delegation.tokensLocked.add(tokens);
607610
delegation.tokensLockedUntil = epochManager().currentEpoch().add(
608611
__delegationUnbondingPeriod

test/l2/l2Staking.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ describe('L2Staking', () => {
337337
const delegatorGRTBalanceAfter = await grt.balanceOf(other.address)
338338
expect(delegatorGRTBalanceAfter.sub(delegatorGRTBalanceBefore)).to.equal(toBN(1))
339339
})
340-
it('returns delegation to the delegator if it initialize the pool with less than the minimum delegation', async function () {
340+
it('returns delegation to the delegator if it initializes the pool with less than the minimum delegation', async function () {
341341
await fixtureContracts.rewardsManager
342342
.connect(governor.signer)
343343
.setIssuancePerBlock(toGRT('114'))
@@ -383,7 +383,7 @@ describe('L2Staking', () => {
383383
const delegatorGRTBalanceAfter = await grt.balanceOf(other.address)
384384
expect(delegatorGRTBalanceAfter.sub(delegatorGRTBalanceBefore)).to.equal(toGRT('0.1'))
385385
})
386-
it('adds delegation under the minimum if the pool is initialized', async function () {
386+
it('returns delegation under the minimum if the pool is initialized', async function () {
387387
await staking.connect(me.signer).stake(tokens100k)
388388

389389
// Initialize the delegation pool to allow delegating less than 1 GRT
@@ -398,6 +398,7 @@ describe('L2Staking', () => {
398398
['uint8', 'bytes'],
399399
[toBN(1), functionData], // code = 1 means RECEIVE_DELEGATION_CODE
400400
)
401+
const delegatorGRTBalanceBefore = await grt.balanceOf(other.address)
401402
const tx = gatewayFinalizeTransfer(
402403
mockL1Staking.address,
403404
staking.address,
@@ -408,12 +409,15 @@ describe('L2Staking', () => {
408409
await expect(tx)
409410
.emit(l2GraphTokenGateway, 'DepositFinalized')
410411
.withArgs(mockL1GRT.address, mockL1Staking.address, staking.address, toGRT('0.1'))
411-
const expectedShares = toGRT('0.1')
412-
await expect(tx)
413-
.emit(staking, 'StakeDelegated')
414-
.withArgs(me.address, other.address, toGRT('0.1'), expectedShares)
412+
415413
const delegation = await staking.getDelegation(me.address, other.address)
416-
expect(delegation.shares).to.equal(expectedShares)
414+
await expect(tx)
415+
.emit(staking, 'TransferredDelegationReturnedToDelegator')
416+
.withArgs(me.address, other.address, toGRT('0.1'))
417+
418+
expect(delegation.shares).to.equal(0)
419+
const delegatorGRTBalanceAfter = await grt.balanceOf(other.address)
420+
expect(delegatorGRTBalanceAfter.sub(delegatorGRTBalanceBefore)).to.equal(toGRT('0.1'))
417421
})
418422
})
419423
describe('onTokenTransfer with invalid messages', function () {

test/staking/delegation.test.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020

2121
const { AddressZero, HashZero } = constants
2222
const MAX_PPM = toBN('1000000')
23+
const tokensToCollect = toGRT('50000000000000000000')
2324

2425
describe('Staking::Delegation', () => {
2526
let me: Account
@@ -190,10 +191,12 @@ describe('Staking::Delegation', () => {
190191
}
191192

192193
// Distribute test funds
193-
for (const wallet of [me, indexer, indexer2, assetHolder]) {
194+
for (const wallet of [me, indexer, indexer2]) {
194195
await grt.connect(governor.signer).mint(wallet.address, toGRT('1000000'))
195196
await grt.connect(wallet.signer).approve(staking.address, toGRT('1000000'))
196197
}
198+
await grt.connect(governor.signer).mint(assetHolder.address, tokensToCollect)
199+
await grt.connect(assetHolder.signer).approve(staking.address, tokensToCollect)
197200
})
198201

199202
beforeEach(async function () {
@@ -369,7 +372,7 @@ describe('Staking::Delegation', () => {
369372
it('reject delegate with zero tokens', async function () {
370373
const tokensToDelegate = toGRT('0')
371374
const tx = staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate)
372-
await expect(tx).revertedWith('!tokens')
375+
await expect(tx).revertedWith('!minimum-delegation')
373376
})
374377

375378
it('reject delegate with less than 1 GRT when the pool is not initialized', async function () {
@@ -378,9 +381,11 @@ describe('Staking::Delegation', () => {
378381
await expect(tx).revertedWith('!minimum-delegation')
379382
})
380383

381-
it('allows delegating under 1 GRT when the pool is initialized', async function () {
384+
it('reject delegating under 1 GRT when the pool is initialized', async function () {
382385
await shouldDelegate(delegator, toGRT('1'))
383-
await shouldDelegate(delegator, toGRT('0.01'))
386+
const tokensToDelegate = toGRT('0.5')
387+
const tx = staking.connect(delegator.signer).delegate(indexer.address, tokensToDelegate)
388+
await expect(tx).revertedWith('!minimum-delegation')
384389
})
385390

386391
it('reject delegate to empty address', async function () {
@@ -479,6 +484,12 @@ describe('Staking::Delegation', () => {
479484
await advanceToNextEpoch(epochManager) // epoch 2
480485
await shouldUndelegate(delegator, toGRT('10'))
481486
})
487+
488+
it('reject undelegate if remaining tokens are less than the minimum', async function () {
489+
await shouldDelegate(delegator, toGRT('100'))
490+
const tx = staking.connect(delegator.signer).undelegate(indexer.address, toGRT('99.5'))
491+
await expect(tx).revertedWith('!minimum-delegation')
492+
})
482493
})
483494

484495
describe('withdraw', function () {
@@ -534,7 +545,6 @@ describe('Staking::Delegation', () => {
534545
// Test values
535546
const tokensToStake = toGRT('200')
536547
const tokensToAllocate = toGRT('2000')
537-
const tokensToCollect = toGRT('500')
538548
const tokensToDelegate = toGRT('1800')
539549
const subgraphDeploymentID = randomHexBytes()
540550
const channelKey = deriveChannelKey()
@@ -604,20 +614,24 @@ describe('Staking::Delegation', () => {
604614

605615
it('revert if it cannot assign the smallest amount of shares', async function () {
606616
// Init the delegation pool
607-
await shouldDelegate(delegator, tokensToDelegate)
608-
617+
await shouldDelegate(delegator, toGRT('1'))
618+
const tokensToAllocate = toGRT('1')
609619
// Collect funds thru full allocation cycle
620+
// Set rebate alpha to 0 to ensure all fees are collected
621+
await staking.connect(governor.signer).setRebateParameters(0, 1, 1, 1)
610622
await staking.connect(governor.signer).setDelegationRatio(10)
611623
await staking.connect(indexer.signer).setDelegationParameters(0, 0, 0)
612624
await setupAllocation(tokensToAllocate)
613625
await advanceToNextEpoch(epochManager)
614626
await staking.connect(assetHolder.signer).collect(tokensToCollect, allocationID)
615627
await advanceToNextEpoch(epochManager)
616628
await staking.connect(indexer.signer).closeAllocation(allocationID, poi)
629+
// We've callected 5e18 GRT (a ridiculous amount),
630+
// which means the price of a share is now 5 GRT
617631

618-
// Delegate with such small amount of tokens (1 wei) that we do not have enough precision
619-
// to even assign 1 wei of shares
620-
const tx = staking.connect(delegator.signer).delegate(indexer.address, toBN(1))
632+
// Delegate with such small amount of tokens (1 GRT) that we do not have enough precision
633+
// to even assign 1 share
634+
const tx = staking.connect(delegator.signer).delegate(indexer.address, toGRT('1'))
621635
await expect(tx).revertedWith('!shares')
622636
})
623637
})

0 commit comments

Comments
 (0)