-
Notifications
You must be signed in to change notification settings - Fork 34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(contracts): validator gating #1127
Changes from 7 commits
49555f7
5697ee4
2e7761c
43ee469
d961819
05d1095
9f00973
7102a78
92a1dc4
2cec1a7
374a7df
6057dd8
e89c069
4c6000c
3dcacd0
9388b24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,63 @@ | ||||||
// SPDX-License-Identifier: MIT OR Apache-2.0 | ||||||
pragma solidity ^0.8.23; | ||||||
|
||||||
import {IValidatorGater} from "../interfaces/IValidatorGater.sol"; | ||||||
import {InvalidSubnet, NotAuthorized, PowerChangeRequestNotApproved} from "../errors/IPCErrors.sol"; | ||||||
import {SubnetID} from "../structs/Subnet.sol"; | ||||||
import {SubnetIDHelper} from "../lib/SubnetIDHelper.sol"; | ||||||
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; | ||||||
|
||||||
/// The power range that an approved validator can have. | ||||||
struct PowerRange { | ||||||
uint256 min; | ||||||
uint256 max; | ||||||
} | ||||||
|
||||||
/// This is a simple implementation of `IValidatorGater`. It makes sure the exact power change | ||||||
/// request is approved. This is a very strict requirement. | ||||||
contract SubnetValidatorGater is IValidatorGater, Ownable { | ||||||
using SubnetIDHelper for SubnetID; | ||||||
|
||||||
SubnetID public subnet; | ||||||
mapping(address => PowerRange) public allowed; | ||||||
|
||||||
constructor(SubnetID memory _subnet) Ownable(msg.sender) { | ||||||
subnet = _subnet; | ||||||
} | ||||||
|
||||||
function isAllow(address validator, uint256 power) public view returns (bool) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit
Suggested change
|
||||||
PowerRange memory range = allowed[validator]; | ||||||
return range.min <= power && power <= range.max; | ||||||
} | ||||||
|
||||||
/// Only owner can approve the validator join request | ||||||
function approve(address validator, uint256 minPower, uint256 maxPower) external onlyOwner { | ||||||
allowed[validator] = PowerRange({min: minPower, max: maxPower}); | ||||||
} | ||||||
|
||||||
/// Revoke approved power range | ||||||
function revoke(address validator) external onlyOwner { | ||||||
delete allowed[validator]; | ||||||
} | ||||||
|
||||||
function interceptPowerDelta( | ||||||
SubnetID memory id, | ||||||
address validator, | ||||||
uint256 /*prevPower*/, | ||||||
uint256 newPower | ||||||
) external view override { | ||||||
SubnetID memory targetSubnet = subnet; | ||||||
|
||||||
if (!id.equals(targetSubnet)) { | ||||||
revert InvalidSubnet(); | ||||||
} | ||||||
|
||||||
if (msg.sender != targetSubnet.getAddress()) { | ||||||
revert NotAuthorized(msg.sender); | ||||||
} | ||||||
|
||||||
if (!isAllow(validator, newPower)) { | ||||||
revert PowerChangeRequestNotApproved(); | ||||||
} | ||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
// SPDX-License-Identifier: MIT OR Apache-2.0 | ||
pragma solidity ^0.8.23; | ||
|
||
import {SubnetID} from "../structs/Subnet.sol"; | ||
|
||
/// @title Validator Gater interface | ||
/// This interface introduces the ability to intercept validator power updates before it's executed. Power updates could | ||
/// come from staking, unstaking, and explicit validator membership adjustments (federated membership). With this interface, | ||
/// it introduces an extra layer of checking to directly allow or deny the action, according to a user-defined policy. | ||
interface IValidatorGater { | ||
/// This intercepts the power update call. | ||
/// @notice This method should revert if the power update is not allowed. | ||
function interceptPowerDelta(SubnetID memory id, address validator, uint256 prevPower, uint256 newPower) external; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,7 +5,8 @@ import {VALIDATOR_SECP256K1_PUBLIC_KEY_LENGTH} from "../constants/Constants.sol" | |
import {ERR_VALIDATOR_JOINED, ERR_VALIDATOR_NOT_JOINED} from "../errors/IPCErrors.sol"; | ||
import {InvalidFederationPayload, SubnetAlreadyBootstrapped, NotEnoughFunds, CollateralIsZero, CannotReleaseZero, NotOwnerOfPublicKey, EmptyAddress, NotEnoughBalance, NotEnoughCollateral, NotValidator, NotAllValidatorsHaveLeft, InvalidPublicKeyLength, MethodNotAllowed, SubnetNotBootstrapped} from "../errors/IPCErrors.sol"; | ||
import {IGateway} from "../interfaces/IGateway.sol"; | ||
import {Validator, ValidatorSet} from "../structs/Subnet.sol"; | ||
import {Validator, ValidatorSet, SubnetID} from "../structs/Subnet.sol"; | ||
import {SubnetIDHelper} from "../lib/SubnetIDHelper.sol"; | ||
import {LibDiamond} from "../lib/LibDiamond.sol"; | ||
import {ReentrancyGuard} from "../lib/LibReentrancyGuard.sol"; | ||
import {SubnetActorModifiers} from "../lib/LibSubnetActorStorage.sol"; | ||
|
@@ -17,6 +18,7 @@ import {Pausable} from "../lib/LibPausable.sol"; | |
|
||
contract SubnetActorManagerFacet is SubnetActorModifiers, ReentrancyGuard, Pausable { | ||
using EnumerableSet for EnumerableSet.AddressSet; | ||
using SubnetIDHelper for SubnetID; | ||
using LibValidatorSet for ValidatorSet; | ||
using Address for address payable; | ||
|
||
|
@@ -67,6 +69,11 @@ contract SubnetActorManagerFacet is SubnetActorModifiers, ReentrancyGuard, Pausa | |
payable(msg.sender).sendValue(amount); | ||
} | ||
|
||
function setValidatorGater(address gater) external notKilled { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The gater needs to be updated this way as compared to in a constructor because the gater needs the subnet address, but the subnet is not created at the constructor. Alternatively, one can also create the gater first then set the subnet address in gater. But I figured this is less intrusive to downstream ipc rust code and in the redesign, we can update accordingly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the second approach you proposed leads to better security and encapsulation.
So let's set the validator gater in the creation parameters, and let the gater manage the subnet whitelisting if it needs to. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @cryptoAtwill Do we still needs this? Since the IPC CLI only supports the constructor right? Also point no. 1 from Raul is very legit. I think we should just have users to have setSubnet method on their own gater implementations if they need to. |
||
LibDiamond.enforceIsContractOwner(); | ||
s.validatorGater = gater; | ||
} | ||
|
||
/// @notice Sets the federated power of validators. | ||
/// @dev method that allows the contract owner to set the validators' federated power. | ||
/// @param validators The addresses of validators. | ||
|
@@ -134,6 +141,8 @@ contract SubnetActorManagerFacet is SubnetActorModifiers, ReentrancyGuard, Pausa | |
revert NotOwnerOfPublicKey(); | ||
} | ||
|
||
LibSubnetActor.gateValidatorPowerDelta(msg.sender, 0, msg.value); | ||
|
||
if (!s.bootstrapped) { | ||
// if the subnet has not been bootstrapped, join directly | ||
// without delays, and collect collateral to register | ||
|
@@ -167,6 +176,9 @@ contract SubnetActorManagerFacet is SubnetActorModifiers, ReentrancyGuard, Pausa | |
revert MethodNotAllowed(ERR_VALIDATOR_NOT_JOINED); | ||
} | ||
|
||
uint256 collateral = LibStaking.totalValidatorCollateral(msg.sender); | ||
LibSubnetActor.gateValidatorPowerDelta(msg.sender, collateral, collateral + msg.value); | ||
Comment on lines
+179
to
+180
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The problem that makes this impractical to use is that weights are relative. So I'm thinking it might be useful to pass the projected sum of total weights to the gater. That way the gater could reject takeover attempts (by having the old power, new power, and total network power). Granted, the gater could call back the subnet actor to get the current membership table and calculate the total, I think so? What do you think? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the idea works, but just from gas concern + responsitbility, I'm not sure if SubnetActor should enforce this. I can achieve the same check with my own Gater implementation because I know the old and new value. In my custom gater implementation, I can hardcode a total power limit. Then with each validator update, I can calculate the new total power with I feel (I could be wrong) the current interface is general enough for first iteration, adding total network power might incur a gas cost to users who dont need it. And I can achieve that with my custom implementation. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah here I think I stand with Will. Since the users can have any arbitrary logic in the gater, I would leave it to them. They can quite easily store the current total powers if they want to. |
||
|
||
if (!s.bootstrapped) { | ||
LibStaking.depositWithConfirm(msg.sender, msg.value); | ||
|
||
|
@@ -196,6 +208,9 @@ contract SubnetActorManagerFacet is SubnetActorModifiers, ReentrancyGuard, Pausa | |
if (collateral <= amount) { | ||
revert NotEnoughCollateral(); | ||
} | ||
|
||
LibSubnetActor.gateValidatorPowerDelta(msg.sender, collateral, collateral - amount); | ||
|
||
if (!s.bootstrapped) { | ||
LibStaking.withdrawWithConfirm(msg.sender, amount); | ||
return; | ||
|
@@ -221,6 +236,8 @@ contract SubnetActorManagerFacet is SubnetActorModifiers, ReentrancyGuard, Pausa | |
revert NotValidator(msg.sender); | ||
} | ||
|
||
LibSubnetActor.gateValidatorPowerDelta(msg.sender, amount, 0); | ||
|
||
// slither-disable-next-line unused-return | ||
s.bootstrapOwners.remove(msg.sender); | ||
delete s.bootstrapNodes[msg.sender]; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -67,25 +67,25 @@ def get_selectors(contract): | |
def main(): | ||
contract_selectors = {} | ||
filepaths_to_target = [ | ||
'src/GatewayDiamond.sol', | ||
'src/SubnetActorDiamond.sol', | ||
'src/SubnetRegistryDiamond.sol', | ||
'src/OwnershipFacet.sol', | ||
'src/diamond/DiamondCutFacet.sol', | ||
'src/diamond/DiamondLoupeFacet.sol', | ||
'src/gateway/GatewayGetterFacet.sol', | ||
'src/gateway/GatewayManagerFacet.sol', | ||
'src/gateway/GatewayMessengerFacet.sol', | ||
'src/gateway/router/CheckpointingFacet.sol', | ||
'src/gateway/router/TopDownFinalityFacet.sol', | ||
'src/gateway/router/XnetMessagingFacet.sol', | ||
'src/subnet/SubnetActorGetterFacet.sol', | ||
'src/subnet/SubnetActorManagerFacet.sol', | ||
'src/subnet/SubnetActorPauseFacet.sol', | ||
'src/subnet/SubnetActorRewardFacet.sol', | ||
'src/subnet/SubnetActorCheckpointingFacet.sol', | ||
'src/subnetregistry/RegisterSubnetFacet.sol', | ||
'src/subnetregistry/SubnetGetterFacet.sol', | ||
Comment on lines
-70
to
-88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thank you! |
||
'contracts/GatewayDiamond.sol', | ||
'contracts/SubnetActorDiamond.sol', | ||
'contracts/SubnetRegistryDiamond.sol', | ||
'contracts/OwnershipFacet.sol', | ||
'contracts/diamond/DiamondCutFacet.sol', | ||
'contracts/diamond/DiamondLoupeFacet.sol', | ||
'contracts/gateway/GatewayGetterFacet.sol', | ||
'contracts/gateway/GatewayManagerFacet.sol', | ||
'contracts/gateway/GatewayMessengerFacet.sol', | ||
'contracts/gateway/router/CheckpointingFacet.sol', | ||
'contracts/gateway/router/TopDownFinalityFacet.sol', | ||
'contracts/gateway/router/XnetMessagingFacet.sol', | ||
'contracts/subnet/SubnetActorGetterFacet.sol', | ||
'contracts/subnet/SubnetActorManagerFacet.sol', | ||
'contracts/subnet/SubnetActorPauseFacet.sol', | ||
'contracts/subnet/SubnetActorRewardFacet.sol', | ||
'contracts/subnet/SubnetActorCheckpointingFacet.sol', | ||
'contracts/subnetregistry/RegisterSubnetFacet.sol', | ||
'contracts/subnetregistry/SubnetGetterFacet.sol', | ||
Comment on lines
+70
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for catching this! |
||
'test/helpers/ERC20PresetFixedSupply.sol', | ||
'test/helpers/NumberContractFacetEight.sol', | ||
'test/helpers/NumberContractFacetSeven.sol', | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
// SPDX-License-Identifier: MIT OR Apache-2.0 | ||
pragma solidity ^0.8.23; | ||
|
||
import {Test} from "forge-std/Test.sol"; | ||
|
||
import {SubnetID} from "../../contracts/structs/Subnet.sol"; | ||
import {SubnetValidatorGater, InvalidSubnet, PowerChangeRequestNotApproved, NotAuthorized} from "../../contracts/examples/SubnetValidatorGater.sol"; | ||
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; | ||
|
||
contract SubnetValidatorGaterTest is Test { | ||
function subnet_id(address baseRoute) internal view returns (SubnetID memory id) { | ||
address[] memory route = new address[](1); | ||
route[0] = baseRoute; | ||
|
||
id = SubnetID({root: 0, route: route}); | ||
} | ||
|
||
function test_gater_approve_works() public { | ||
SubnetID memory id = subnet_id(address(this)); | ||
|
||
SubnetValidatorGater gater = new SubnetValidatorGater(id); | ||
|
||
address validator = address(1); | ||
uint256 minPower = 100; | ||
uint256 maxPower = 200; | ||
gater.approve(validator, minPower, maxPower); | ||
require(gater.isAllow(validator, 110), "should allow"); | ||
} | ||
|
||
function test_gater_approve_not_owner() public { | ||
SubnetID memory id = subnet_id(address(this)); | ||
|
||
SubnetValidatorGater gater = new SubnetValidatorGater(id); | ||
|
||
address validator = address(1); | ||
uint256 minPower = 100; | ||
uint256 maxPower = 200; | ||
|
||
vm.prank(validator); | ||
|
||
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, validator)); | ||
gater.approve(validator, minPower, maxPower); | ||
|
||
require(!gater.isAllow(validator, 110), "should not allow"); | ||
} | ||
|
||
function test_gater_intercept_ok() public { | ||
SubnetID memory id = subnet_id(address(this)); | ||
|
||
SubnetValidatorGater gater = new SubnetValidatorGater(id); | ||
|
||
address validator = address(1); | ||
uint256 minPower = 100; | ||
uint256 maxPower = 200; | ||
gater.approve(validator, minPower, maxPower); | ||
|
||
vm.prank(address(this)); | ||
|
||
gater.interceptPowerDelta(id, validator, 0, 110); | ||
|
||
vm.expectRevert(PowerChangeRequestNotApproved.selector); | ||
gater.interceptPowerDelta(id, validator, 0, 210); | ||
} | ||
|
||
function test_gater_intercept_invalid_subnet() public { | ||
SubnetID memory id = subnet_id(address(this)); | ||
|
||
SubnetValidatorGater gater = new SubnetValidatorGater(id); | ||
|
||
address validator = address(1); | ||
uint256 minPower = 100; | ||
uint256 maxPower = 200; | ||
gater.approve(validator, minPower, maxPower); | ||
|
||
vm.prank(address(this)); | ||
vm.expectRevert(InvalidSubnet.selector); | ||
|
||
gater.interceptPowerDelta(subnet_id(address(2)), validator, 0, 110); | ||
} | ||
|
||
function test_gater_intercept_not_authorized() public { | ||
SubnetID memory id = subnet_id(address(this)); | ||
|
||
SubnetValidatorGater gater = new SubnetValidatorGater(id); | ||
|
||
address validator = address(1); | ||
uint256 minPower = 100; | ||
uint256 maxPower = 200; | ||
gater.approve(validator, minPower, maxPower); | ||
|
||
vm.prank(validator); | ||
vm.expectRevert(abi.encodeWithSelector(NotAuthorized.selector, validator)); | ||
|
||
gater.interceptPowerDelta(id, validator, 0, 110); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.