Skip to content
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

prepare for outstanding tests #5

Draft
wants to merge 5 commits into
base: role-staking
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 10 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ Kicks a single member out of the DAO completely, if they are in bad standing for

A Baal manager shaman that allows members of the DAO to stake their shares to earn a role. The DAO (or its delegate, the `ROLE_MANAGER`), can create new roles and/or register existing existing roles, each with a minimum staking requirement. Members can then stake their shares to receive the role. The DAO (or its delegate, the `JUDGE`), can put stakers in bad standing, resulting in their staked shares being slashed. If a staker's stake drops below the minimum staking requirement for a given role — whether via slashing or some other DAO action — they immediately lose that role.

Staking in this contract is "virtual" — stakers retain custody of their shares at all times, but this contract is authorized to burn the staked shares if slashing conditions are met. Stakers must always have sufficient shares to cover the total amount they have staked across all roles. If at any time they do not, they lose all roles and can only regain (a subset of) them by unstaking from enough roles to meet the coverage requirement for the remaining roles.

### Special Roles

The `ROLE_MANAGER` is a special role that can create new roles, set a staking requirement for (aka register) existing roles, or change/remove staking requirements for registered roles.
Expand Down Expand Up @@ -82,9 +84,9 @@ Claiming a role mints the hat to the claimer.

### Unstaking from DAO Roles

DAO members can unstake from a role at any time. However, to prevent gaming the system, unstaking is subject to a cooldown period.
DAO members can unstake from a role at any time. To ensure that stakers cannot game the system by unstaking their shares before they can be slashed, unstaking is subject to a cooldown period.

Like staking, members can unstake any amount they choose, as long as they have sufficient shares staked on that role. As always, dropping below the minimum staking requirement for a given role results in the member losing that role.
Like staking, members can unstake any amount they choose, as long as they are not unstaking more shares than they have staked on the given role or more shares than they have in their account. As always, dropping below the minimum staking requirement for a given role results in the member losing that role.

Here's how unstaking typically works:

Expand All @@ -98,7 +100,7 @@ If at any point the member becomes in bad standing for the role, they will be sl

Sometimes, a staker may want to restart the unstaking process.

This can be for any reason, but the most common is that since their cooldown period began, their staked shares have been reduced (such as by another shaman burning them). In this case, `completeUnstakeFromRole()` would fail, since they would no longer have sufficient shares to withdraw. Resetting would allow them to reduce the number of shares they are attempting to unstake to a number they can actually withdraw.
This can be for any reason, but the most common is that since their cooldown period began, their total shares has decreased such that they no longer have sufficient coverage of both staked shares and the amount they wish to unstake. In this case, `completeUnstakeFromRole()` would fail. Resetting would allow them to reduce the number of shares they are attempting to unstake to a number for which they have sufficient coverage.

Resetting the unstaking process has two effects: a) it changes the amount of the member's shares that are in "unstaking" state, and b) it restarts the cooldown period.

Expand All @@ -110,21 +112,14 @@ When the role on which a member has been staked is deregistered, there is no nee

This contract also serves as a Hats eligibility module. It is designed to be set as the eligibility module for each hat that is registered to it.

When set as the eligibility module for a registered hat, it will check both members' eligibility for the hat as well as their standing. `Eligibility` is determined by whether the member has staked sufficient shares on the role. `Standing` is set by the wearer of the `JUDGE_HAT`.

If a member is in bad standing, they can be slashed by any account. If they attempt any phase of unstaking, they will also be slashed.

### The Staking Proxy

A member's Baal shares have voting rights that can be delegated to other accounts of the member's choosing. In order to preserve this property while their shares are staked, staked shares are held in a staking proxy contract.

This contract is a simple proxy that has just a single function: `delegate()`. Each member has their own staking proxy, which is deployed when they stake shares for the first time.
When set as the eligibility module for a registered hat, it will check both members' eligibility for the hat as well as their standing.

Whenever the member stakes additional shares — such as when calling `stakeOnRole()` or `stakeAndClaimRole()`, those shares are transferred to their staking proxy and the associated voting power is delegated to the account of their choosing (including themselves). When the member unstakes shares, the shares are transferred back to their account and the associated voting power defaults back to the member.
`Eligibility` is determined by the following conditions. Both must be true for a member to be eligible for a given hat:

The member can redelegate their staked shares at any time by calling `delegate()` on their staking proxy.
1. Has the member staked at least the minStake amount of shares for the role?
2. Does the member have sufficient shares to cover the total amount they have staked across all roles?

The address of a given member's staking proxy can be found by calling `getStakedSharesAndProxy()`. This function returns both the member's staking proxy address as well as the total shares held in that proxy.
`Standing` is set by the wearer of the `JUDGE_HAT`. If a member is in bad standing, they can be slashed by any account. If they attempt any phase of unstaking, they will also be slashed.

## Development

Expand Down
168 changes: 26 additions & 142 deletions src/HatsOnboardingShaman.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ pragma solidity ^0.8.19;

// import { console2 } from "forge-std/Test.sol"; // remove before deploy
import { HatsModule } from "hats-module/HatsModule.sol";
import { IRoleStakingShaman } from "src/interfaces/IRoleStakingShaman.sol";
import { IBaal } from "baal/interfaces/IBaal.sol";
import { IBaalToken } from "baal/interfaces/IBaalToken.sol";

Expand All @@ -30,7 +29,6 @@ contract HatsOnboardingShaman is HatsModule {
error NoShares(address member);
error NotMember(address nonMember);
error NotInBadStanding(address member);
error BatchActionsNotSupportedWithRoleStakingShaman(address roleStakingShaman);

/*//////////////////////////////////////////////////////////////
EVENTS
Expand All @@ -43,7 +41,6 @@ contract HatsOnboardingShaman is HatsModule {
event Kicked(address member, uint256 sharesBurned, uint256 lootBurned);
event KickedBatch(address[] members, uint256[] sharesBurned, uint256[] lootBurned);
event StartingSharesSet(uint256 newStartingShares);
event RoleStakingShamanSet(address newRoleStakingShaman);

/*//////////////////////////////////////////////////////////////
PUBLIC CONSTANTS
Expand Down Expand Up @@ -94,7 +91,6 @@ contract HatsOnboardingShaman is HatsModule {
//////////////////////////////////////////////////////////////*/

uint256 public startingShares;
address public roleStakingShaman;

/*//////////////////////////////////////////////////////////////
CONSTRUCTOR
Expand Down Expand Up @@ -127,14 +123,8 @@ contract HatsOnboardingShaman is HatsModule {
* number of shares
*/
function onboard() external wearsMemberHat(msg.sender) {
uint256 stakedAmount;
address roleStakingShaman_ = roleStakingShaman;
if (roleStakingShaman_ > address(0)) {
(stakedAmount,) = _getStakedSharesAndProxy(msg.sender, roleStakingShaman_);
}

/// @dev checked since if this overflows, we know msg.sender is a member and we need to revert
if (SHARES_TOKEN.balanceOf(msg.sender) + stakedAmount + LOOT_TOKEN.balanceOf(msg.sender) > 0) {
if (SHARES_TOKEN.balanceOf(msg.sender) + LOOT_TOKEN.balanceOf(msg.sender) > 0) {
revert AlreadyBoarded();
}

Expand All @@ -154,11 +144,6 @@ contract HatsOnboardingShaman is HatsModule {
* @param _members The addresses of the members to offboard.
*/
function offboard(address[] calldata _members) external {
address roleStakingShaman_ = roleStakingShaman;
if (roleStakingShaman_ > address(0)) {
revert BatchActionsNotSupportedWithRoleStakingShaman(roleStakingShaman_);
}

uint256 length = _members.length;
uint256[] memory amounts = new uint256[](length);
uint256 amount;
Expand Down Expand Up @@ -186,64 +171,28 @@ contract HatsOnboardingShaman is HatsModule {

/**
* @notice Offboards a single member from the DAO, if they are not wearing the member hat. Offboarded members
* lose their voting power by having their shares down-converted to loot. If the member has staked shares elsewhere
* (eg for a role via a HatsRoleStakingShaman), then the staked shares are also converted to loot, resulting in the
* member losing that role.
* lose their voting power by having their shares down-converted to loot.
* @param _member The address of the member to offboard.
*/
function offboard(address _member) external {
if (HATS().isWearerOfHat(_member, hatId())) revert StillWearsMemberHat(_member);

uint256 stakedAmount;
address proxy;
address[] memory shareMembers;
uint256[] memory shareAmounts;

address roleStakingShaman_ = roleStakingShaman;

if (roleStakingShaman_ > address(0)) {
(stakedAmount, proxy) = _getStakedSharesAndProxy(_member, roleStakingShaman_);
}

uint256 amount = SHARES_TOKEN.balanceOf(_member);

if (stakedAmount > 0) {
// there are staked shares, so the shares in the proxy must be burned as well
shareMembers = new address[](2);
shareAmounts = new uint256[](2);
shareMembers[0] = _member;
shareMembers[1] = proxy;
shareAmounts[0] = amount;
shareAmounts[1] = stakedAmount;

// we combine the shares and staked shares into a single loot amount to mint
address[] memory lootMembers = new address[](1);
uint256[] memory lootAmounts = new uint256[](1);
lootMembers[0] = _member;
unchecked {
/// @dev Safe, since stakedAmount is always <= the original amount staked by the _member, so it can't overflow
amount += stakedAmount;
}
lootAmounts[0] = amount;

BAAL().burnShares(shareMembers, shareAmounts);
BAAL().mintLoot(lootMembers, lootAmounts);

emit Offboarded(_member, amount);
} else {
if (amount == 0) revert NoShares(_member);
if (amount == 0) revert NoShares(_member);

// there are no staked shares, so we just burn the shares and mint loot from the same arrays
shareMembers = new address[](1);
shareAmounts = new uint256[](1);
shareMembers[0] = _member;
shareAmounts[0] = amount;
shareMembers = new address[](1);
shareAmounts = new uint256[](1);
shareMembers[0] = _member;
shareAmounts[0] = amount;

BAAL().burnShares(shareMembers, shareAmounts);
BAAL().mintLoot(shareMembers, shareAmounts);
BAAL().burnShares(shareMembers, shareAmounts);
BAAL().mintLoot(shareMembers, shareAmounts);

emit Offboarded(_member, amount);
}
emit Offboarded(_member, amount);
}

/**
Expand Down Expand Up @@ -272,11 +221,6 @@ contract HatsOnboardingShaman is HatsModule {
* @param _members The addresses of the members to kick.
*/
function kick(address[] calldata _members) external {
address roleStakingShaman_ = roleStakingShaman;
if (roleStakingShaman_ > address(0)) {
revert BatchActionsNotSupportedWithRoleStakingShaman(roleStakingShaman_);
}

uint256 length = _members.length;
uint256[] memory shares = new uint256[](length);
uint256[] memory loots = new uint256[](length);
Expand Down Expand Up @@ -319,87 +263,33 @@ contract HatsOnboardingShaman is HatsModule {
function kick(address _member) external {
if (HATS().isInGoodStanding(_member, hatId())) revert NotInBadStanding(_member);

uint256 stakedAmount;
address proxy;

address roleStakingShaman_ = roleStakingShaman;

if (roleStakingShaman_ > address(0)) {
(stakedAmount, proxy) = _getStakedSharesAndProxy(_member, roleStakingShaman_);
}

uint256 shareAmount = SHARES_TOKEN.balanceOf(_member);
uint256 lootAmount = LOOT_TOKEN.balanceOf(_member);
address[] memory members;
uint256[] memory shares;
uint256[] memory loots;

if (stakedAmount > 0) {
// there are staked shares, so the shares in the proxy must be burned as well
if (shareAmount > 0) {
// there are shares, so we need to burn both shares and staked shares
members = new address[](2);
shares = new uint256[](2);
members[0] = _member;
members[1] = proxy;
shares[0] = shareAmount;
shares[1] = stakedAmount;
} else {
// there are no shares, so we just need to burn staked shares
members = new address[](1);
shares = new uint256[](1);
members[0] = proxy;
shares[0] = stakedAmount;
}

BAAL().burnShares(members, shares);
members = new address[](1);
shares = new uint256[](1);
loots = new uint256[](1);
members[0] = _member;

if (lootAmount > 0) {
// there's just a single loot amount, so we need arrays of length 1
loots = new uint256[](1);
address[] memory lootMembers = new address[](1);
lootMembers[0] = _member;
loots[0] = lootAmount;

BAAL().burnLoot(lootMembers, loots);
}

emit Kicked(_member, shareAmount + stakedAmount, lootAmount);
} else {
members = new address[](1);
shares = new uint256[](1);
loots = new uint256[](1);
members[0] = _member;

unchecked {
/// @dev safe, since if this overflows, we know _member is a member, so we should not revert
if (shareAmount + lootAmount == 0) revert NotMember(_member);
}

if (shareAmount > 0) {
shares[0] = shareAmount;
BAAL().burnShares(members, shares);
}

if (lootAmount > 0) {
loots[0] = lootAmount;
BAAL().burnLoot(members, loots);
}
unchecked {
/// @dev safe, since if this overflows, we know _member is a member, so we should not revert
if (shareAmount + lootAmount == 0) revert NotMember(_member);
}

emit Kicked(_member, shareAmount, lootAmount);
if (shareAmount > 0) {
shares[0] = shareAmount;
BAAL().burnShares(members, shares);
}
}

/*//////////////////////////////////////////////////////////////
INTERNAL FUNCTIONS
//////////////////////////////////////////////////////////////*/
if (lootAmount > 0) {
loots[0] = lootAmount;
BAAL().burnLoot(members, loots);
}

function _getStakedSharesAndProxy(address member, address _roleStakingShaman)
internal
view
returns (uint256 amount, address proxy)
{
(amount, proxy) = IRoleStakingShaman(_roleStakingShaman).getStakedSharesAndProxy(member);
emit Kicked(_member, shareAmount, lootAmount);
}

/*//////////////////////////////////////////////////////////////
Expand All @@ -417,12 +307,6 @@ contract HatsOnboardingShaman is HatsModule {
emit StartingSharesSet(_startingShares);
}

function setRoleStakingShaman(address _newRoleStakingShaman) external onlyOwner {
roleStakingShaman = _newRoleStakingShaman;

emit RoleStakingShamanSet(_newRoleStakingShaman);
}

/*//////////////////////////////////////////////////////////////
MODIFIERS
//////////////////////////////////////////////////////////////*/
Expand Down
Loading