Skip to content

Commit e59aef2

Browse files
committed
staking,disputes: slash can be bypassed
The DisputeManager contract was enforcing a minimum indexer stake for slashing leading to indexers below that stake to be able to bypass slashing. This is a non-intended consequence of adding a minimum indexer stake to avoid the abuse of the conflicting attestation feature. This commit add a configurable minimum indexer stake in the Staking contract that is enforced when staking and unstaking. Now, the DisputeManager will always slash indexers not matter their current stake as long as it is non-zero.
1 parent 40f8a23 commit e59aef2

File tree

12 files changed

+164
-78
lines changed

12 files changed

+164
-78
lines changed

contracts/disputes/DisputeManager.sol

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,6 @@ contract DisputeManager is Managed {
9292
// Minimum deposit required to create a Dispute
9393
uint256 public minimumDeposit;
9494

95-
// Minimum stake an indexer needs to have to allow be disputed
96-
uint256 public minimumIndexerStake;
97-
9895
// Percentage of indexer slashed funds to assign as a reward to fisherman in successful dispute
9996
// Parts per million. (Allows for 4 decimal points, 999,999 = 99.9999%)
10097
uint32 public fishermanRewardPercentage;
@@ -260,15 +257,6 @@ contract DisputeManager is Managed {
260257
emit ParameterUpdated("slashingPercentage");
261258
}
262259

263-
/**
264-
* @dev Set the minimum indexer stake required to allow be disputed.
265-
* @param _minimumIndexerStake Minimum indexer stake
266-
*/
267-
function setMinimumIndexerStake(uint256 _minimumIndexerStake) external onlyGovernor {
268-
minimumIndexerStake = _minimumIndexerStake;
269-
emit ParameterUpdated("minimumIndexerStake");
270-
}
271-
272260
/**
273261
* @dev Return whether a dispute exists or not.
274262
* @notice Return if dispute with ID `_disputeID` exists
@@ -460,10 +448,7 @@ contract DisputeManager is Managed {
460448
address indexer = getAttestationIndexer(_attestation);
461449

462450
// The indexer is disputable
463-
require(
464-
staking().getIndexerStakedTokens(indexer) >= minimumIndexerStake,
465-
"Dispute under minimum indexer stake amount"
466-
);
451+
require(staking().hasStake(indexer), "Dispute indexer has no stake");
467452

468453
// Create a disputeID
469454
bytes32 disputeID = keccak256(
@@ -540,10 +525,7 @@ contract DisputeManager is Managed {
540525
require(alloc.indexer != address(0), "Dispute allocation must exist");
541526

542527
// The indexer must be disputable
543-
require(
544-
staking().getIndexerStakedTokens(alloc.indexer) >= minimumIndexerStake,
545-
"Dispute under minimum indexer stake amount"
546-
);
528+
require(staking().hasStake(alloc.indexer), "Dispute indexer has no stake");
547529

548530
// Store dispute
549531
disputes[disputeID] = Dispute(alloc.indexer, _fisherman, _deposit, 0);

contracts/staking/IStaking.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ interface IStaking {
5656

5757
// -- Configuration --
5858

59+
function setMinimumIndexerStake(uint256 _minimumIndexerStake) external;
60+
5961
function setThawingPeriod(uint32 _thawingPeriod) external;
6062

6163
function setCurationPercentage(uint32 _percentage) external;

contracts/staking/Staking.sol

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,15 @@ contract Staking is StakingV1Storage, GraphUpgradeable, IStaking {
216216
Staking(address(_proxy)).initialize(_controller);
217217
}
218218

219+
/**
220+
* @dev Set the minimum indexer stake required to.
221+
* @param _minimumIndexerStake Minimum indexer stake
222+
*/
223+
function setMinimumIndexerStake(uint256 _minimumIndexerStake) external override onlyGovernor {
224+
minimumIndexerStake = _minimumIndexerStake;
225+
emit ParameterUpdated("minimumIndexerStake");
226+
}
227+
219228
/**
220229
* @dev Set the thawing period for unstaking.
221230
* @param _thawingPeriod Period in blocks to wait for token withdrawals after unstaking
@@ -478,6 +487,7 @@ contract Staking is StakingV1Storage, GraphUpgradeable, IStaking {
478487

479488
/**
480489
* @dev Get the total amount of tokens available to use in allocations.
490+
* This considers the indexer stake and delegated tokens according to delegation ratio
481491
* @param _indexer Address of the indexer
482492
* @return Amount of tokens staked by the indexer
483493
*/
@@ -490,20 +500,7 @@ contract Staking is StakingV1Storage, GraphUpgradeable, IStaking {
490500
? pool.tokens
491501
: tokensDelegatedMax;
492502

493-
uint256 tokensUsed = indexerStake.tokensUsed();
494-
uint256 tokensCapacity = indexerStake.tokensStaked.add(tokensDelegated);
495-
496-
// If more tokens are used than the current capacity, the indexer is overallocated.
497-
// This means the indexer doesn't have available capacity to create new allocations.
498-
// We can reach this state when the indexer has funds allocated and then any
499-
// of these conditions happen:
500-
// - The delegationRatio ratio is reduced.
501-
// - The indexer stake is slashed.
502-
// - A delegator removes enough stake.
503-
if (tokensUsed > tokensCapacity) {
504-
return 0;
505-
}
506-
return tokensCapacity.sub(tokensUsed);
503+
return indexerStake.tokensAvailableWithDelegation(tokensDelegated);
507504
}
508505

509506
/**
@@ -533,6 +530,12 @@ contract Staking is StakingV1Storage, GraphUpgradeable, IStaking {
533530
function stakeTo(address _indexer, uint256 _tokens) public override notPartialPaused {
534531
require(_tokens > 0, "Cannot stake zero tokens");
535532

533+
// Ensure minimum stake
534+
require(
535+
stakes[_indexer].tokensSecureStake().add(_tokens) >= minimumIndexerStake,
536+
"Stake must be above minimum required"
537+
);
538+
536539
// Transfer tokens to stake from caller to this contract
537540
require(graphToken().transferFrom(msg.sender, address(this), _tokens), "!transfer");
538541

@@ -554,6 +557,13 @@ contract Staking is StakingV1Storage, GraphUpgradeable, IStaking {
554557
"Not enough tokens available to unstake"
555558
);
556559

560+
// Ensure minimum stake
561+
uint256 newStake = indexerStake.tokensSecureStake().sub(_tokens);
562+
require(
563+
newStake == 0 || newStake >= minimumIndexerStake,
564+
"Stake must be above minimum required"
565+
);
566+
557567
indexerStake.lockTokens(_tokens, thawingPeriod);
558568

559569
emit StakeLocked(indexer, indexerStake.tokensLocked, indexerStake.tokensLockedUntil);
@@ -564,10 +574,9 @@ contract Staking is StakingV1Storage, GraphUpgradeable, IStaking {
564574
*/
565575
function withdraw() external override notPaused {
566576
address indexer = msg.sender;
567-
Stakes.Indexer storage indexerStake = stakes[indexer];
568577

569578
// Get tokens available for withdraw and update balance
570-
uint256 tokensToWithdraw = indexerStake.withdrawTokens();
579+
uint256 tokensToWithdraw = stakes[indexer].withdrawTokens();
571580
require(tokensToWithdraw > 0, "No tokens available to withdraw");
572581

573582
// Return tokens to the indexer
@@ -849,7 +858,10 @@ contract Staking is StakingV1Storage, GraphUpgradeable, IStaking {
849858
}
850859

851860
/**
852-
* @dev Stake tokens on the indexer
861+
* @dev Stake tokens on the indexer.
862+
* This function does not check minimum indexer stake requirement to allow
863+
* to be called by functions that increase the stake when collecting rewards
864+
* without reverting
853865
* @param _indexer Address of staking party
854866
* @param _tokens Amount of tokens to stake
855867
*/

contracts/staking/StakingStorage.sol

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import "./libs/Stakes.sol";
99
contract StakingV1Storage is Managed {
1010
// -- Staking --
1111

12+
// Minimum amount of tokens an indexer needs to stake
13+
uint256 public minimumIndexerStake;
14+
1215
// Time in blocks to unstake
1316
uint32 public thawingPeriod; // in blocks
1417

contracts/staking/libs/Stakes.sol

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,19 +151,57 @@ library Stakes {
151151
}
152152

153153
/**
154-
* @dev Tokens free balance on the indexer stake.
155-
* tokensStaked - tokensAllocated - tokensLocked
154+
* @dev Return the amount of tokens staked not considering the ones that are already going
155+
* through the thawing period or are ready for withdrawal. We call it secure stake because
156+
* it is not subject to change by a withdraw call from the indexer.
157+
* @param stake Stake data
158+
* @return Token amount
159+
*/
160+
function tokensSecureStake(Stakes.Indexer memory stake) internal pure returns (uint256) {
161+
return stake.tokensStaked.sub(stake.tokensLocked);
162+
}
163+
164+
/**
165+
* @dev Tokens free balance on the indexer stake that can be used for any purpose.
166+
* Any token that is allocated cannot be used as well as tokens that are going through the
167+
* thawing period or are withdrawable
168+
* Calc: tokensStaked - tokensAllocated - tokensLocked
156169
* @param stake Stake data
157170
* @return Token amount
158171
*/
159172
function tokensAvailable(Stakes.Indexer memory stake) internal pure returns (uint256) {
173+
return stake.tokensAvailableWithDelegation(0);
174+
}
175+
176+
/**
177+
* @dev Tokens free balance on the indexer stake that can be used for allocations.
178+
* This function accepts a parameter for extra delegated capacity that takes into
179+
* account delegated tokens
180+
* @param stake Stake data
181+
* @param _delegatedCapacity Amount of tokens used from delegators to calculate availability
182+
* @return Token amount
183+
*/
184+
function tokensAvailableWithDelegation(Stakes.Indexer memory stake, uint256 _delegatedCapacity)
185+
internal
186+
pure
187+
returns (uint256)
188+
{
189+
uint256 tokensCapacity = stake.tokensStaked.add(_delegatedCapacity);
160190
uint256 _tokensUsed = stake.tokensUsed();
161-
// Indexer stake is over allocated: return 0 to avoid stake to be used until
162-
// the overallocation is restored by staking more tokens or unallocating tokens
163-
if (_tokensUsed > stake.tokensStaked) {
191+
// If more tokens are used than the current capacity, the indexer is overallocated.
192+
// This means the indexer doesn't have available capacity to create new allocations.
193+
// We can reach this state when the indexer has funds allocated and then any
194+
// of these conditions happen:
195+
// - The delegationCapacity ratio is reduced.
196+
// - The indexer stake is slashed.
197+
// - A delegator removes enough stake.
198+
if (_tokensUsed > tokensCapacity) {
199+
// Indexer stake is over allocated: return 0 to avoid stake to be used until
200+
// the overallocation is restored by staking more tokens, unallocating tokens
201+
// or using more delegated funds
164202
return 0;
165203
}
166-
return stake.tokensStaked.sub(_tokensUsed);
204+
return tokensCapacity.sub(_tokensUsed);
167205
}
168206

169207
/**

graph.config.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,6 @@ contracts:
5353
minimumDeposit: "100000000000000000000" # 100 GRT (in wei)
5454
fishermanRewardPercentage: 100000 # 10% (in basis points)
5555
slashingPercentage: 100000 # 10% (in basis points)
56-
calls:
57-
- fn: "setMinimumIndexerStake"
58-
minimumIndexerStake: "1000000000000000000" # 1 GRT (in wei)
5956
GNS:
6057
init:
6158
controller: "${{Controller.address}}"
@@ -86,6 +83,8 @@ contracts:
8683
- fn: "setSlasher"
8784
slasher: "${{DisputeManager.address}}"
8885
allowed: true
86+
- fn: "setMinimumIndexerStake"
87+
minimumIndexerStake: "10000000000000000000" # 10 GRT (in wei)
8988
RewardsManager:
9089
init:
9190
controller: "${{Controller.address}}"

test/disputes/configuration.test.ts

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { DisputeManager } from '../../build/typechain/contracts/DisputeManager'
44

55
import { defaults } from '../lib/deployment'
66
import { NetworkFixture } from '../lib/fixtures'
7-
import { getAccounts, toBN, toGRT, Account } from '../lib/testHelpers'
7+
import { getAccounts, toBN, Account } from '../lib/testHelpers'
88

99
const MAX_PPM = 1000000
1010

@@ -70,26 +70,6 @@ describe('DisputeManager:Config', () => {
7070
})
7171
})
7272

73-
describe('minimumIndexerStake', function () {
74-
it('should set `minimumIndexerStake`', async function () {
75-
const oldValue = defaults.dispute.minimumIndexerStake
76-
const newValue = toGRT('100')
77-
78-
// Set right in the constructor
79-
expect(await disputeManager.minimumIndexerStake()).eq(oldValue)
80-
81-
// Set new value
82-
await disputeManager.connect(governor.signer).setMinimumIndexerStake(newValue)
83-
expect(await disputeManager.minimumIndexerStake()).eq(newValue)
84-
})
85-
86-
it('reject set `minimumIndexerStake` if not allowed', async function () {
87-
const newValue = toGRT('100')
88-
const tx = disputeManager.connect(me.signer).setMinimumIndexerStake(newValue)
89-
await expect(tx).revertedWith('Caller must be Controller governor')
90-
})
91-
})
92-
9373
describe('fishermanRewardPercentage', function () {
9474
it('should set `fishermanRewardPercentage`', async function () {
9575
const newValue = defaults.dispute.fishermanRewardPercentage

test/disputes/poi.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ describe('DisputeManager:POI', async () => {
140140
const tx = disputeManager
141141
.connect(fisherman.signer)
142142
.createIndexingDispute(event1.allocationID, fishermanDeposit)
143-
await expect(tx).revertedWith('Dispute under minimum indexer stake amount')
143+
await expect(tx).revertedWith('Dispute indexer has no stake')
144144
})
145145

146146
context('> when indexer is staked', function () {

test/disputes/query.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ describe('DisputeManager:Query', async () => {
233233
const tx = disputeManager
234234
.connect(fisherman.signer)
235235
.createQueryDispute(dispute.encodedAttestation, fishermanDeposit)
236-
await expect(tx).revertedWith('Dispute under minimum indexer stake amount')
236+
await expect(tx).revertedWith('Dispute indexer has no stake')
237237
})
238238

239239
context('> when indexer is staked', function () {

test/lib/deployment.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ export const defaults = {
2929
},
3030
dispute: {
3131
minimumDeposit: toGRT('100'),
32-
minimumIndexerStake: toGRT('1'),
3332
fishermanRewardPercentage: toBN('1000'), // in basis points
3433
slashingPercentage: toBN('1000'), // in basis points
3534
},
3635
epochs: {
3736
lengthInBlocks: toBN((15 * 60) / 15), // 15 minutes in blocks
3837
},
3938
staking: {
39+
minimumIndexerStake: toGRT('10'),
4040
channelDisputeEpochs: 1,
4141
maxAllocationEpochs: 5,
4242
thawingPeriod: 20, // in blocks
@@ -128,20 +128,15 @@ export async function deployDisputeManager(
128128
arbitrator: string,
129129
): Promise<DisputeManager> {
130130
// Deploy
131-
const contract = ((await deployContract(
131+
return deployContract(
132132
'DisputeManager',
133133
deployer,
134134
controller,
135135
arbitrator,
136136
defaults.dispute.minimumDeposit.toString(),
137137
defaults.dispute.fishermanRewardPercentage.toString(),
138138
defaults.dispute.slashingPercentage.toString(),
139-
)) as unknown) as DisputeManager
140-
141-
// Config
142-
await contract.connect(deployer).setMinimumIndexerStake(defaults.dispute.minimumIndexerStake)
143-
144-
return contract
139+
) as Promise<DisputeManager>
145140
}
146141

147142
export async function deployEpochManager(
@@ -212,6 +207,7 @@ export async function deployStaking(deployer: Signer, controller: string): Promi
212207

213208
// Configure
214209
const staking = contract.attach(proxy.address)
210+
await staking.connect(deployer).setMinimumIndexerStake(defaults.staking.minimumIndexerStake)
215211
await staking.connect(deployer).setChannelDisputeEpochs(defaults.staking.channelDisputeEpochs)
216212
await staking.connect(deployer).setMaxAllocationEpochs(defaults.staking.maxAllocationEpochs)
217213
await staking.connect(deployer).setThawingPeriod(defaults.staking.thawingPeriod)

0 commit comments

Comments
 (0)