forked from mico/idle_moloch
1059 lines
40 KiB
Solidity
1059 lines
40 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
/*
|
|
███ ██ ██ █
|
|
█ █ █ █ █ █ █
|
|
█ ▀ ▄ █▄▄█ █▄▄█ █
|
|
█ ▄▀ █ █ █ █ ███▄
|
|
███ █ █ ▀
|
|
█ █
|
|
▀ ▀*/
|
|
pragma solidity ^0.8.7;
|
|
|
|
import "@gnosis.pm/safe-contracts/contracts/base/Executor.sol";
|
|
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
|
|
import "@gnosis.pm/zodiac/contracts/core/Module.sol";
|
|
import "@gnosis.pm/safe-contracts/contracts/common/Enum.sol";
|
|
import "@opengsn/contracts/src/BaseRelayRecipient.sol";
|
|
import "@openzeppelin/contracts-upgradeable/utils/cryptography/draft-EIP712Upgradeable.sol";
|
|
import "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
|
|
|
|
import "./interfaces/IBaalToken.sol";
|
|
|
|
/// @title Baal ';_;'.
|
|
/// @notice Flexible guild contract inspired by Moloch DAO framework.
|
|
contract Baal is Module, EIP712Upgradeable, ReentrancyGuardUpgradeable, BaseRelayRecipient {
|
|
using ECDSAUpgradeable for bytes32;
|
|
|
|
// ERC20 SHARES + LOOT
|
|
|
|
IBaalToken public lootToken; /*Sub ERC20 for loot mgmt*/
|
|
IBaalToken public sharesToken; /*Sub ERC20 for loot mgmt*/
|
|
|
|
address private constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; /*ETH reference for redemptions*/
|
|
|
|
// GOVERNANCE PARAMS
|
|
uint32 public votingPeriod; /* voting period in seconds - amendable through 'period'[2] proposal*/
|
|
uint32 public gracePeriod; /*time delay after proposal voting period for processing*/
|
|
uint32 public proposalCount; /*counter for total `proposals` submitted*/
|
|
uint256 public proposalOffering; /* non-member proposal offering*/
|
|
uint256 public quorumPercent; /* minimum % of shares that must vote yes for it to pass*/
|
|
uint256 public sponsorThreshold; /* minimum number of shares to sponsor a proposal (not %)*/
|
|
uint256 public minRetentionPercent; /* auto-fails a proposal if more than (1- minRetentionPercent) * total shares exit before processing*/
|
|
|
|
// SHAMAN PERMISSIONS
|
|
bool public adminLock; /* once set to true, no new admin roles can be assigned to shaman */
|
|
bool public managerLock; /* once set to true, no new manager roles can be assigned to shaman */
|
|
bool public governorLock; /* once set to true, no new governor roles can be assigned to shaman */
|
|
mapping(address => uint256) public shamans; /*maps shaman addresses to their permission level*/
|
|
/* permissions registry for shamans
|
|
0 = no permission
|
|
1 = admin only
|
|
2 = manager only
|
|
4 = governance only
|
|
3 = admin + manager
|
|
5 = admin + governance
|
|
6 = manager + governance
|
|
7 = admin + manager + governance */
|
|
|
|
// PROPOSAL TRACKING
|
|
mapping(address => mapping(uint32 => bool)) public memberVoted; /*maps members to their proposal votes (true = voted) */
|
|
mapping(address => uint256) public votingNonces; /*maps members to their voting nonce*/
|
|
mapping(uint256 => Proposal) public proposals; /*maps `proposal id` to struct details*/
|
|
|
|
// MISCELLANEOUS PARAMS
|
|
uint32 public latestSponsoredProposalId; /* the id of the last proposal to be sponsored */
|
|
address public multisendLibrary; /*address of multisend library*/
|
|
string public override versionRecipient; /* version recipient for OpenGSN */
|
|
|
|
// SIGNATURE HELPERS
|
|
bytes32 constant VOTE_TYPEHASH = keccak256("Vote(string name,address voter,uint256 expiry,uint256 nonce,uint32 proposalId,bool support)");
|
|
|
|
// DATA STRUCTURES
|
|
struct Proposal {
|
|
/*Baal proposal details*/
|
|
uint32 id; /*id of this proposal, used in existence checks (increments from 1)*/
|
|
uint32 prevProposalId; /* id of the previous proposal - set at sponsorship from latestSponsoredProposalId */
|
|
uint32 votingStarts; /*starting time for proposal in seconds since unix epoch*/
|
|
uint32 votingEnds; /*termination date for proposal in seconds since unix epoch - derived from `votingPeriod` set on proposal*/
|
|
uint32 graceEnds; /*termination date for proposal in seconds since unix epoch - derived from `gracePeriod` set on proposal*/
|
|
uint32 expiration; /*timestamp after which proposal should be considered invalid and skipped. */
|
|
uint256 baalGas; /* gas needed to process proposal */
|
|
uint256 yesVotes; /*counter for `members` `approved` 'votes' to calculate approval on processing*/
|
|
uint256 noVotes; /*counter for `members` 'dis-approved' 'votes' to calculate approval on processing*/
|
|
uint256 maxTotalSharesAndLootAtVote; /* highest share+loot count during any individual yes vote*/
|
|
uint256 maxTotalSharesAtSponsor; /* highest share+loot count during any individual yes vote*/
|
|
bool[4] status; /* [cancelled, processed, passed, actionFailed] */
|
|
address sponsor; /* address of the sponsor - set at sponsor proposal - relevant for cancellation */
|
|
bytes32 proposalDataHash; /*hash of raw data associated with state updates*/
|
|
}
|
|
|
|
/* Unborn -> Submitted -> Voting -> Grace -> Ready -> Processed
|
|
\-> Cancelled \-> Defeated */
|
|
enum ProposalState {
|
|
Unborn, /* 0 - can submit */
|
|
Submitted, /* 1 - can sponsor -> voting */
|
|
Voting, /* 2 - can be cancelled, otherwise proceeds to grace */
|
|
Cancelled, /* 3 - terminal state, counts as processed */
|
|
Grace, /* 4 - proceeds to ready/defeated */
|
|
Ready, /* 5 - can be processed */
|
|
Processed, /* 6 - terminal state */
|
|
Defeated /* 7 - terminal state, yes votes <= no votes, counts as processed */
|
|
}
|
|
|
|
// MODIFIERS
|
|
|
|
modifier baalOnly() {
|
|
require(_msgSender() == avatar, "!baal");
|
|
_;
|
|
}
|
|
|
|
modifier baalOrAdminOnly() {
|
|
require(_msgSender() == avatar || isAdmin(_msgSender()), "!baal & !admin"); /*check `shaman` is admin*/
|
|
_;
|
|
}
|
|
|
|
modifier baalOrManagerOnly() {
|
|
require(
|
|
_msgSender() == avatar || isManager(_msgSender()),
|
|
"!baal & !manager"
|
|
); /*check `shaman` is manager*/
|
|
_;
|
|
}
|
|
|
|
modifier baalOrGovernorOnly() {
|
|
require(
|
|
_msgSender() == avatar || isGovernor(_msgSender()),
|
|
"!baal & !governor"
|
|
); /*check `shaman` is governor*/
|
|
_;
|
|
}
|
|
|
|
// EVENTS
|
|
event SetupComplete(
|
|
bool lootPaused,
|
|
bool sharesPaused,
|
|
uint32 gracePeriod,
|
|
uint32 votingPeriod,
|
|
uint256 proposalOffering,
|
|
uint256 quorumPercent,
|
|
uint256 sponsorThreshold,
|
|
uint256 minRetentionPercent,
|
|
string name,
|
|
string symbol,
|
|
uint256 totalShares,
|
|
uint256 totalLoot
|
|
); /*emits after Baal summoning*/
|
|
event SubmitProposal(
|
|
uint256 indexed proposal,
|
|
bytes32 indexed proposalDataHash,
|
|
uint256 votingPeriod,
|
|
bytes proposalData,
|
|
uint256 expiration,
|
|
uint256 baalGas,
|
|
bool selfSponsor,
|
|
uint256 timestamp,
|
|
string details
|
|
); /*emits after proposal is submitted*/
|
|
event SponsorProposal(
|
|
address indexed member,
|
|
uint256 indexed proposal,
|
|
uint256 indexed votingStarts
|
|
); /*emits after member has sponsored proposal*/
|
|
event CancelProposal(uint256 indexed proposal); /*emits when proposal is cancelled*/
|
|
event SubmitVote(
|
|
address indexed member,
|
|
uint256 balance,
|
|
uint256 indexed proposal,
|
|
bool indexed approved
|
|
); /*emits after vote is submitted on proposal*/
|
|
event ProcessProposal(
|
|
uint256 indexed proposal,
|
|
bool passed,
|
|
bool actionFailed
|
|
); /*emits when proposal is processed & executed*/
|
|
event Ragequit(
|
|
address indexed member,
|
|
address to,
|
|
uint256 indexed lootToBurn,
|
|
uint256 indexed sharesToBurn,
|
|
address[] tokens
|
|
); /*emits when users burn Baal `shares` and/or `loot` for given `to` account*/
|
|
event Approval(
|
|
address indexed owner,
|
|
address indexed spender,
|
|
uint256 amount
|
|
); /*emits when Baal `shares` are approved for pulls with erc20 accounting*/
|
|
|
|
event ShamanSet(address indexed shaman, uint256 permission); /*emits when a shaman permission changes*/
|
|
event SetTrustedForwarder(address indexed forwarder); /*emits when a trusted forwarder changes*/
|
|
event GovernanceConfigSet(
|
|
uint32 voting,
|
|
uint32 grace,
|
|
uint256 newOffering,
|
|
uint256 quorum,
|
|
uint256 sponsor,
|
|
uint256 minRetention
|
|
); /*emits when gov config changes*/
|
|
event SharesPaused(bool paused); /*emits when shares are paused or unpaused*/
|
|
event LootPaused(bool paused); /*emits when loot is paused or unpaused*/
|
|
event LockAdmin(bool adminLock); /*emits when admin is locked*/
|
|
event LockManager(bool managerLock); /*emits when admin is locked*/
|
|
event LockGovernor(bool governorLock); /*emits when admin is locked*/
|
|
|
|
function encodeMultisend(bytes[] memory _calls, address _target)
|
|
external
|
|
pure
|
|
returns (bytes memory encodedMultisend)
|
|
{
|
|
bytes memory encodedActions;
|
|
for (uint256 i = 0; i < _calls.length; i++) {
|
|
encodedActions = abi.encodePacked(
|
|
encodedActions,
|
|
uint8(0),
|
|
_target,
|
|
uint256(0),
|
|
uint256(_calls[i].length),
|
|
bytes(_calls[i])
|
|
);
|
|
}
|
|
encodedMultisend = abi.encodeWithSignature(
|
|
"multiSend(bytes)",
|
|
encodedActions
|
|
);
|
|
}
|
|
|
|
constructor() {
|
|
_disableInitializers();
|
|
}
|
|
|
|
/// @notice Summon Baal with voting configuration & initial array of `members` accounts with `shares` & `loot` weights.
|
|
/// @param _initializationParams Encoded setup information.
|
|
function setUp(bytes memory _initializationParams)
|
|
public
|
|
override(FactoryFriendly)
|
|
initializer
|
|
nonReentrant
|
|
{
|
|
(
|
|
address _lootToken, /*loot ERC20 token*/
|
|
address _sharesToken, /*shares ERC20 token*/
|
|
address _multisendLibrary, /*address of multisend library*/
|
|
address _avatar, /*Safe contract address*/
|
|
address _forwarder, /*Trusted forwarder address for meta-transactions (EIP 2771)*/
|
|
bytes memory _initializationMultisendData /*here you call BaalOnly functions to set up initial shares, loot, shamans, periods, etc.*/
|
|
) = abi.decode(
|
|
_initializationParams,
|
|
(address, address, address, address, address, bytes)
|
|
);
|
|
|
|
require(
|
|
_multisendLibrary != address(0) &&
|
|
_avatar != address(0),
|
|
"0 addr used"
|
|
);
|
|
// no need to check _forwarder address exists, the default is address(0) for no forwarder
|
|
|
|
versionRecipient = "2.2.5+opengsn.payablewithbaal.irelayrecipient";
|
|
__Ownable_init();
|
|
__ReentrancyGuard_init();
|
|
__EIP712_init("Vote", "4");
|
|
transferOwnership(_avatar);
|
|
|
|
// Set the Gnosis safe address
|
|
avatar = _avatar;
|
|
target = _avatar; /*Set target to same address as avatar on setup - can be changed later via setTarget, though probably not a good idea*/
|
|
|
|
// Set trusted forwarder
|
|
_setTrustedForwarder(_forwarder);
|
|
|
|
lootToken = IBaalToken(_lootToken);
|
|
sharesToken = IBaalToken(_sharesToken);
|
|
|
|
/*Set address of Gnosis multisend library to use for all execution*/
|
|
multisendLibrary = _multisendLibrary;
|
|
|
|
// Execute all setups including but not limited to
|
|
// * mint shares
|
|
// * convert shares to loot
|
|
// * set shamans
|
|
// * set admin configurations
|
|
require(
|
|
exec(
|
|
multisendLibrary,
|
|
0,
|
|
_initializationMultisendData,
|
|
Enum.Operation.DelegateCall
|
|
),
|
|
"call failure setup"
|
|
);
|
|
|
|
emit SetupComplete(
|
|
lootToken.paused(),
|
|
sharesToken.paused(),
|
|
gracePeriod,
|
|
votingPeriod,
|
|
proposalOffering,
|
|
quorumPercent,
|
|
sponsorThreshold,
|
|
minRetentionPercent,
|
|
sharesToken.name(),
|
|
sharesToken.symbol(),
|
|
totalShares(),
|
|
totalLoot()
|
|
);
|
|
|
|
}
|
|
|
|
/*****************
|
|
PROPOSAL FUNCTIONS
|
|
*****************/
|
|
/// @notice Submit proposal to Baal `members` for approval within given voting period.
|
|
/// @param proposalData Multisend encoded transactions or proposal data
|
|
/// @param details Context for proposal.
|
|
/// @return proposal Count for submitted proposal.
|
|
function submitProposal(
|
|
bytes calldata proposalData,
|
|
uint32 expiration,
|
|
uint256 baalGas,
|
|
string calldata details
|
|
) external payable nonReentrant returns (uint256) {
|
|
require(
|
|
expiration == 0 ||
|
|
expiration > block.timestamp + votingPeriod + gracePeriod,
|
|
"expired"
|
|
);
|
|
require(baalGas <= 20000000, "baalGas to high"); /* gwei 2/3 eth block limit */
|
|
|
|
bool selfSponsor = false; /*plant sponsor flag*/
|
|
if (sharesToken.getVotes(_msgSender()) >= sponsorThreshold ) {
|
|
selfSponsor = true; /*if above sponsor threshold, self-sponsor*/
|
|
} else {
|
|
require(msg.value == proposalOffering, "Baal requires an offering"); /*Optional anti-spam gas token tribute*/
|
|
(bool _success, ) = target.call{value: msg.value}(""); /*Send ETH to sink*/
|
|
require(_success, "could not send");
|
|
}
|
|
|
|
bytes32 proposalDataHash = hashOperation(proposalData); /*Store only hash of proposal data*/
|
|
|
|
proposalCount++; /*increment proposal counter*/
|
|
proposals[proposalCount] = Proposal( /*push params into proposal struct - start voting period timer if member submission*/
|
|
proposalCount,
|
|
selfSponsor ? latestSponsoredProposalId : 0, /* prevProposalId */
|
|
selfSponsor ? uint32(block.timestamp) : 0, /* votingStarts */
|
|
selfSponsor ? uint32(block.timestamp) + votingPeriod : 0, /* votingEnds */
|
|
selfSponsor
|
|
? uint32(block.timestamp) + votingPeriod + gracePeriod
|
|
: 0, /* graceEnds */
|
|
expiration,
|
|
baalGas,
|
|
0, /* yes votes */
|
|
0, /* no votes */
|
|
selfSponsor ? totalSupply() : 0, /* maxTotalSharesAndLootAtVote */
|
|
selfSponsor ? totalShares() : 0, /* maxTotalSharesAtSponsor */
|
|
[false, false, false, false], /* [cancelled, processed, passed, actionFailed] */
|
|
selfSponsor ? _msgSender() : address(0),
|
|
proposalDataHash
|
|
);
|
|
|
|
if (selfSponsor) {
|
|
latestSponsoredProposalId = proposalCount;
|
|
}
|
|
|
|
emit SubmitProposal(
|
|
proposalCount,
|
|
proposalDataHash,
|
|
votingPeriod,
|
|
proposalData,
|
|
expiration,
|
|
baalGas,
|
|
selfSponsor,
|
|
block.timestamp,
|
|
details
|
|
); /*emit event reflecting proposal submission*/
|
|
|
|
return proposalCount;
|
|
}
|
|
|
|
/// @notice Sponsor proposal to Baal `members` for approval within voting period.
|
|
/// @param id Number of proposal in `proposals` mapping to sponsor.
|
|
function sponsorProposal(uint32 id) external nonReentrant {
|
|
Proposal storage prop = proposals[id]; /*alias proposal storage pointers*/
|
|
|
|
require(sharesToken.getVotes(_msgSender()) >= sponsorThreshold, "!sponsor"); /*check 'votes > threshold - required to sponsor proposal*/
|
|
require(state(id) == ProposalState.Submitted, "!submitted");
|
|
require(
|
|
prop.expiration == 0 ||
|
|
prop.expiration > block.timestamp + votingPeriod + gracePeriod,
|
|
"expired"
|
|
);
|
|
|
|
prop.votingStarts = uint32(block.timestamp);
|
|
|
|
unchecked {
|
|
prop.votingEnds = uint32(block.timestamp) + votingPeriod;
|
|
prop.graceEnds =
|
|
uint32(block.timestamp) +
|
|
votingPeriod +
|
|
gracePeriod;
|
|
}
|
|
|
|
prop.prevProposalId = latestSponsoredProposalId;
|
|
prop.sponsor = _msgSender();
|
|
// snapshot both total supply and total shares
|
|
prop.maxTotalSharesAndLootAtVote = totalSupply(); // updaed in votes for min retention
|
|
prop.maxTotalSharesAtSponsor = totalShares(); // for yes vote quorum
|
|
latestSponsoredProposalId = id;
|
|
|
|
emit SponsorProposal(_msgSender(), id, block.timestamp);
|
|
}
|
|
|
|
/// @notice Submit vote - proposal must exist & voting period must not have ended.
|
|
/// @param id Number of proposal in `proposals` mapping to cast vote on.
|
|
/// @param approved If 'true', member will cast `yesVotes` onto proposal - if 'false', `noVotes` will be counted.
|
|
function submitVote(uint32 id, bool approved) external nonReentrant {
|
|
_submitVote(_msgSender(), id, approved);
|
|
}
|
|
|
|
/// @notice Submit vote with EIP-712 signature - proposal must exist & voting period must not have ended.
|
|
/// @param voter Address of member who submitted vote.
|
|
/// @param expiry Expiration of signature.
|
|
/// @param id Number of proposal in `proposals` mapping to cast vote on.
|
|
/// @param approved If 'true', member will cast `yesVotes` onto proposal - if 'false', `noVotes` will be counted.
|
|
/// @param v v in signature
|
|
/// @param r r in signature
|
|
/// @param s s in signature
|
|
function submitVoteWithSig(
|
|
address voter,
|
|
uint256 expiry,
|
|
uint256 nonce,
|
|
uint32 id,
|
|
bool approved,
|
|
uint8 v,
|
|
bytes32 r,
|
|
bytes32 s
|
|
) external nonReentrant {
|
|
require(block.timestamp <= expiry, "ERC20Votes: signature expired");
|
|
require(nonce == votingNonces[voter], "!nonce");
|
|
|
|
/*calculate EIP-712 struct hash*/
|
|
bytes32 structHash = keccak256(
|
|
abi.encode(
|
|
VOTE_TYPEHASH,
|
|
keccak256(abi.encodePacked(sharesToken.name())),
|
|
voter,
|
|
expiry,
|
|
nonce,
|
|
id,
|
|
approved
|
|
)
|
|
);
|
|
bytes32 hash = _hashTypedDataV4(structHash);
|
|
address signer = ECDSAUpgradeable.recover(hash, v, r, s);
|
|
|
|
require(signer == voter, "invalid signature");
|
|
require(signer != address(0), "!signer");
|
|
votingNonces[voter] += 1;
|
|
|
|
_submitVote(signer, id, approved);
|
|
}
|
|
|
|
/// @notice Execute vote submission internally - callable by submit vote or submit vote with signature
|
|
/// @param voter Address of voter
|
|
/// @param id Number of proposal in `proposals` mapping to cast vote on.
|
|
/// @param approved If 'true', member will cast `yesVotes` onto proposal - if 'false', `noVotes` will be counted.
|
|
function _submitVote(
|
|
address voter,
|
|
uint32 id,
|
|
bool approved
|
|
) internal {
|
|
Proposal storage prop = proposals[id]; /*alias proposal storage pointers*/
|
|
require(state(id) == ProposalState.Voting, "!voting");
|
|
|
|
uint256 balance = sharesToken.getPastVotes(voter, prop.votingStarts); /*fetch & gas-optimize voting weight at proposal creation time*/
|
|
|
|
require(balance > 0, "!member"); /* check that user has shares*/
|
|
require(!memberVoted[voter][id], "voted"); /*check vote not already cast*/
|
|
|
|
memberVoted[voter][id] = true; /*record voting action to `members` struct per user account*/
|
|
|
|
// get high water mark on all votes
|
|
uint256 _totalSupply = totalSupply();
|
|
if (_totalSupply > prop.maxTotalSharesAndLootAtVote) {
|
|
prop.maxTotalSharesAndLootAtVote = _totalSupply;
|
|
}
|
|
|
|
unchecked {
|
|
if (approved) {
|
|
/*if `approved`, cast delegated balance `yesVotes` to proposal*/
|
|
prop.yesVotes += balance;
|
|
} else {
|
|
/*otherwise, cast delegated balance `noVotes` to proposal*/
|
|
prop.noVotes += balance;
|
|
}
|
|
}
|
|
|
|
emit SubmitVote(voter, balance, id, approved); /*emit event reflecting vote*/
|
|
}
|
|
|
|
/// @notice Process `proposal` & execute internal functions.
|
|
/// @dev Proposal must have succeeded, not been processed, not expired, retention threshold must be met
|
|
/// @param id Number of proposal in `proposals` mapping to process for execution.
|
|
/// @param proposalData Packed multisend data to execute via Gnosis multisend library
|
|
function processProposal(uint32 id, bytes calldata proposalData)
|
|
external
|
|
nonReentrant
|
|
{
|
|
Proposal storage prop = proposals[id]; /*alias `proposal` storage pointers*/
|
|
|
|
require(prop.sponsor != address(0), "!sponsor"); /*check proposal has been sponsored*/
|
|
require(state(id) == ProposalState.Ready, "!ready"); /* check proposal is Ready to process */
|
|
|
|
ProposalState prevProposalState = state(prop.prevProposalId);
|
|
require(
|
|
prevProposalState == ProposalState.Processed ||
|
|
prevProposalState == ProposalState.Cancelled ||
|
|
prevProposalState == ProposalState.Defeated ||
|
|
prevProposalState == ProposalState.Unborn,
|
|
"prev!processed"
|
|
);
|
|
|
|
// check that the proposalData matches the stored hash
|
|
require(
|
|
hashOperation(proposalData) == prop.proposalDataHash,
|
|
"incorrect calldata"
|
|
);
|
|
|
|
require(
|
|
prop.baalGas == 0 || gasleft() >= prop.baalGas,
|
|
"not enough gas"
|
|
);
|
|
|
|
prop.status[1] = true; /*Set processed flag to true*/
|
|
bool okToExecute = true; /*Initialize and invalidate if conditions are not met below*/
|
|
|
|
// Make proposal fail if after expiration
|
|
if (prop.expiration != 0 && prop.expiration < block.timestamp)
|
|
okToExecute = false;
|
|
|
|
// Make proposal fail if it didn't pass quorum
|
|
if (okToExecute && prop.yesVotes * 100 < quorumPercent * prop.maxTotalSharesAtSponsor)
|
|
okToExecute = false;
|
|
|
|
// Make proposal fail if the minRetentionPercent is exceeded
|
|
if (
|
|
okToExecute &&
|
|
(totalSupply()) <
|
|
(prop.maxTotalSharesAndLootAtVote * minRetentionPercent) / 100 /*Check for dilution since high water mark during voting*/
|
|
) {
|
|
okToExecute = false;
|
|
}
|
|
|
|
/*check if `proposal` approved by simple majority of members*/
|
|
if (okToExecute) {
|
|
prop.status[2] = true; /*flag that proposal passed - allows baal-like extensions*/
|
|
bool success = processActionProposal(proposalData); /*execute 'action'*/
|
|
if (!success) {
|
|
prop.status[3] = true;
|
|
}
|
|
}
|
|
|
|
emit ProcessProposal(id, prop.status[2], prop.status[3]); /*emit event reflecting that given proposal processed*/
|
|
}
|
|
|
|
/// @notice Internal function to process 'action'[0] proposal.
|
|
/// @param proposalData Packed multisend data to execute via Gnosis multisend library
|
|
/// @return success Success or failure of execution
|
|
function processActionProposal(bytes memory proposalData)
|
|
private
|
|
returns (bool success)
|
|
{
|
|
success = exec(
|
|
multisendLibrary,
|
|
0,
|
|
proposalData,
|
|
Enum.Operation.DelegateCall
|
|
);
|
|
}
|
|
|
|
/// @notice Cancel proposal prior to execution
|
|
/// @dev Cancellable if proposal is during voting, sender is sponsor, governor, or if sponsor has fallen below threshold
|
|
/// @param id Number of proposal in `proposals` mapping to process for execution.
|
|
function cancelProposal(uint32 id) external nonReentrant {
|
|
Proposal storage prop = proposals[id];
|
|
require(state(id) == ProposalState.Voting, "!voting");
|
|
require(
|
|
_msgSender() == prop.sponsor ||
|
|
sharesToken.getPastVotes(prop.sponsor, block.timestamp - 1) <
|
|
sponsorThreshold ||
|
|
isGovernor(_msgSender()),
|
|
"!cancellable"
|
|
);
|
|
prop.status[0] = true;
|
|
emit CancelProposal(id);
|
|
}
|
|
|
|
/// @dev Function to Execute arbitrary code as baal - useful if funds are accidentally sent here
|
|
/// @notice Can only be called by the avatar which means this can only be called if passed by another
|
|
/// proposal or by a delegated signer on the Safe
|
|
/// @param _to address to call
|
|
/// @param _value value to include in wei
|
|
/// @param _data arbitrary transaction data
|
|
function executeAsBaal(
|
|
address _to,
|
|
uint256 _value,
|
|
bytes calldata _data
|
|
) external baalOnly {
|
|
(bool success, ) = _to.call{value: _value}(_data);
|
|
require(success, "call failure execute");
|
|
}
|
|
|
|
// ****************
|
|
// MEMBER FUNCTIONS
|
|
// ****************
|
|
|
|
/// @notice Process member burn of `shares` and/or `loot` to claim 'fair share' of specified `tokens`
|
|
/// @param to Account that receives 'fair share'.
|
|
/// @param lootToBurn Baal pure economic weight to burn.
|
|
/// @param sharesToBurn Baal voting weight to burn.
|
|
/// @param tokens Array of tokens to include in rage quit calculation
|
|
function ragequit(
|
|
address to,
|
|
uint256 sharesToBurn,
|
|
uint256 lootToBurn,
|
|
address[] calldata tokens
|
|
) external nonReentrant {
|
|
for (uint256 i = 1; i < tokens.length; i++) {
|
|
require(tokens[i] > tokens[i - 1], "!order");
|
|
}
|
|
|
|
_ragequit(to, sharesToBurn, lootToBurn, tokens);
|
|
}
|
|
|
|
/// @notice Internal execution of rage quite
|
|
/// @param to Account that receives 'fair share'.
|
|
/// @param lootToBurn Baal pure economic weight to burn.
|
|
/// @param sharesToBurn Baal voting weight to burn.
|
|
/// @param tokens Array of tokens to include in rage quit calculation
|
|
function _ragequit(
|
|
address to,
|
|
uint256 sharesToBurn,
|
|
uint256 lootToBurn,
|
|
address[] memory tokens
|
|
) internal {
|
|
uint256 _totalSupply = totalSupply();
|
|
|
|
if (lootToBurn != 0) {
|
|
/*gas optimization*/
|
|
_burnLoot(_msgSender(), lootToBurn); /*subtract `loot` from user account & Baal totals*/
|
|
}
|
|
|
|
if (sharesToBurn != 0) {
|
|
/*gas optimization*/
|
|
_burnShares(_msgSender(), sharesToBurn); /*subtract `shares` from user account & Baal totals with erc20 accounting*/
|
|
}
|
|
|
|
for (uint256 i = 0; i < tokens.length; i++) {
|
|
uint256 balance;
|
|
if(tokens[i] == ETH) {
|
|
balance = address(target).balance;
|
|
} else {
|
|
(, bytes memory balanceData) = tokens[i].staticcall(
|
|
abi.encodeWithSelector(0x70a08231, address(target))
|
|
); /*get Baal token balances - 'balanceOf(address)'*/
|
|
balance = abi.decode(balanceData, (uint256));
|
|
}
|
|
|
|
uint256 amountToRagequit = ((lootToBurn + sharesToBurn) * balance) /
|
|
_totalSupply; /*calculate 'fair shair' claims*/
|
|
|
|
if (amountToRagequit != 0) {
|
|
/*gas optimization to allow higher maximum token limit*/
|
|
tokens[i] == ETH
|
|
? _safeTransferETH(to, amountToRagequit) /*execute 'safe' ETH transfer*/
|
|
: _safeTransfer(tokens[i], to, amountToRagequit); /*execute 'safe' token transfer*/
|
|
}
|
|
}
|
|
|
|
emit Ragequit(_msgSender(), to, lootToBurn, sharesToBurn, tokens); /*event reflects claims made against Baal*/
|
|
}
|
|
|
|
/*******************
|
|
GUILD MGMT FUNCTIONS
|
|
*******************/
|
|
/// @notice Baal-only function to set shaman status.
|
|
/// @param _shamans Addresses of shaman contracts
|
|
/// @param _permissions Permission level of each shaman in _shamans
|
|
function setShamans(
|
|
address[] calldata _shamans,
|
|
uint256[] calldata _permissions
|
|
) external baalOnly {
|
|
require(_shamans.length == _permissions.length, "!array parity"); /*check array lengths match*/
|
|
for (uint256 i = 0; i < _shamans.length; i++) {
|
|
uint256 permission = _permissions[i];
|
|
if (adminLock)
|
|
require(
|
|
permission != 1 &&
|
|
permission != 3 &&
|
|
permission != 5 &&
|
|
permission != 7,
|
|
"admin lock"
|
|
);
|
|
if (managerLock)
|
|
require(
|
|
permission != 2 &&
|
|
permission != 3 &&
|
|
permission != 6 &&
|
|
permission != 7,
|
|
"manager lock"
|
|
);
|
|
if (governorLock)
|
|
require(
|
|
permission != 4 &&
|
|
permission != 5 &&
|
|
permission != 6 &&
|
|
permission != 7,
|
|
"governor lock"
|
|
);
|
|
shamans[_shamans[i]] = permission;
|
|
emit ShamanSet(_shamans[i], permission);
|
|
}
|
|
}
|
|
|
|
/// @notice Lock admin so setShamans cannot be called with admin changes
|
|
function lockAdmin() external baalOnly {
|
|
adminLock = true;
|
|
|
|
emit LockAdmin(adminLock);
|
|
}
|
|
|
|
/// @notice Lock manager so setShamans cannot be called with manager changes
|
|
function lockManager() external baalOnly {
|
|
managerLock = true;
|
|
|
|
emit LockManager(managerLock);
|
|
}
|
|
|
|
/// @notice Lock governor so setShamans cannot be called with governor changes
|
|
function lockGovernor() external baalOnly {
|
|
governorLock = true;
|
|
|
|
emit LockGovernor(governorLock);
|
|
}
|
|
|
|
// ****************
|
|
// SHAMAN FUNCTIONS
|
|
// ****************
|
|
/// @notice Baal-or-admin-only function to set admin config (pause/unpause shares/loot) and call function on token
|
|
/// @param pauseShares Turn share transfers on or off
|
|
/// @param pauseLoot Turn loot transfers on or off
|
|
function setAdminConfig(bool pauseShares, bool pauseLoot)
|
|
external
|
|
baalOrAdminOnly
|
|
{
|
|
|
|
if(pauseShares && !sharesToken.paused()){
|
|
sharesToken.pause();
|
|
emit SharesPaused(true);
|
|
} else if(!pauseShares && sharesToken.paused()){
|
|
sharesToken.unpause();
|
|
emit SharesPaused(false);
|
|
}
|
|
|
|
if(pauseLoot && !lootToken.paused()){
|
|
lootToken.pause();
|
|
emit LootPaused(true);
|
|
} else if(!pauseLoot && lootToken.paused()){
|
|
lootToken.unpause();
|
|
emit LootPaused(false);
|
|
}
|
|
}
|
|
|
|
/// @notice Baal-or-manager-only function to mint shares.
|
|
/// @param to Array of addresses to receive shares
|
|
/// @param amount Array of amounts to mint
|
|
function mintShares(address[] calldata to, uint256[] calldata amount)
|
|
external
|
|
baalOrManagerOnly
|
|
{
|
|
require(to.length == amount.length, "!array parity"); /*check array lengths match*/
|
|
for (uint256 i = 0; i < to.length; i++) {
|
|
_mintShares(to[i], amount[i]); /*grant `to` `amount` `shares`*/
|
|
}
|
|
}
|
|
|
|
/// @notice Minting function for Baal `shares`.
|
|
/// @param to Address to receive shares
|
|
/// @param shares Amount to mint
|
|
function _mintShares(address to, uint256 shares) private {
|
|
sharesToken.mint(to, shares);
|
|
}
|
|
|
|
/// @notice Baal-or-manager-only function to burn shares.
|
|
/// @param from Array of addresses to lose shares
|
|
/// @param amount Array of amounts to burn
|
|
function burnShares(address[] calldata from, uint256[] calldata amount)
|
|
external
|
|
baalOrManagerOnly
|
|
{
|
|
require(from.length == amount.length, "!array parity"); /*check array lengths match*/
|
|
for (uint256 i = 0; i < from.length; i++) {
|
|
_burnShares(from[i], amount[i]); /*grant `to` `amount` `shares`*/
|
|
}
|
|
}
|
|
|
|
/// @notice Burn function for Baal `shares`.
|
|
/// @param from Address to lose shares
|
|
/// @param shares Amount to burn
|
|
function _burnShares(address from, uint256 shares) private {
|
|
sharesToken.burn(from, shares);
|
|
}
|
|
|
|
/// @notice Baal-or-manager-only function to mint loot.
|
|
/// @param to Array of addresses to mint loot
|
|
/// @param amount Array of amounts to mint
|
|
function mintLoot(address[] calldata to, uint256[] calldata amount)
|
|
external
|
|
baalOrManagerOnly
|
|
{
|
|
require(to.length == amount.length, "!array parity"); /*check array lengths match*/
|
|
for (uint256 i = 0; i < to.length; i++) {
|
|
_mintLoot(to[i], amount[i]); /*grant `to` `amount` `shares`*/
|
|
}
|
|
}
|
|
|
|
/// @notice Minting function for Baal `loot`.
|
|
/// @param to Address to mint loot
|
|
/// @param loot Amount to mint
|
|
function _mintLoot(address to, uint256 loot) private {
|
|
lootToken.mint(to, loot);
|
|
}
|
|
|
|
/// @notice Baal-or-manager-only function to burn loot.
|
|
/// @param from Array of addresses to lose loot
|
|
/// @param amount Array of amounts to burn
|
|
function burnLoot(address[] calldata from, uint256[] calldata amount)
|
|
external
|
|
baalOrManagerOnly
|
|
{
|
|
require(from.length == amount.length, "!array parity"); /*check array lengths match*/
|
|
for (uint256 i = 0; i < from.length; i++) {
|
|
_burnLoot(from[i], amount[i]); /*grant `to` `amount` `shares`*/
|
|
}
|
|
}
|
|
|
|
/// @notice Burn function for Baal `loot`.
|
|
/// @param from Address to lose loot
|
|
/// @param loot Amount to burn
|
|
function _burnLoot(address from, uint256 loot) private {
|
|
lootToken.burn(from, loot);
|
|
}
|
|
|
|
/// @notice Baal-or-governance-only function to change periods.
|
|
/// @param _governanceConfig Encoded configuration parameters voting, grace period, tribute, quorum, sponsor threshold, retention bound
|
|
function setGovernanceConfig(bytes memory _governanceConfig)
|
|
external
|
|
baalOrGovernorOnly
|
|
{
|
|
(
|
|
uint32 voting,
|
|
uint32 grace,
|
|
uint256 newOffering,
|
|
uint256 quorum,
|
|
uint256 sponsor,
|
|
uint256 minRetention
|
|
) = abi.decode(
|
|
_governanceConfig,
|
|
(uint32, uint32, uint256, uint256, uint256, uint256)
|
|
);
|
|
require(quorum >= 0 && minRetention <= 100, 'bad quorum');
|
|
require(minRetention >= 0 && minRetention <= 100, 'bad minRetention');
|
|
|
|
// on initialization of governance config, there is no shares token
|
|
// skip this check on initialization of governance config.
|
|
if (sponsorThreshold > 0 && address(sharesToken) != address(0)) {
|
|
require(sponsor <= totalShares(), 'sponsor > sharesSupply');
|
|
}
|
|
|
|
if (voting != 0) votingPeriod = voting; /*if positive, reset min. voting periods to first `value`*/
|
|
if (grace != 0) gracePeriod = grace; /*if positive, reset grace period to second `value`*/
|
|
proposalOffering = newOffering; /*set new proposal offering amount */
|
|
quorumPercent = quorum;
|
|
sponsorThreshold = sponsor;
|
|
minRetentionPercent = minRetention;
|
|
|
|
emit GovernanceConfigSet(
|
|
voting,
|
|
grace,
|
|
newOffering,
|
|
quorum,
|
|
sponsor,
|
|
minRetention
|
|
);
|
|
}
|
|
|
|
/// @notice Baal-or-governance only function to set trusted forwarder for meta-transactions.
|
|
/// @param _trustedForwarderAddress Trusted forwarder's address
|
|
function setTrustedForwarder(address _trustedForwarderAddress)
|
|
external
|
|
baalOrGovernorOnly
|
|
{
|
|
_setTrustedForwarder(_trustedForwarderAddress);
|
|
emit SetTrustedForwarder(_trustedForwarderAddress);
|
|
}
|
|
|
|
/***************
|
|
GETTER FUNCTIONS
|
|
***************/
|
|
/// @notice State helper to determine proposal state
|
|
/// @param id Number of proposal in proposals
|
|
/// @return Unborn -> Submitted -> Voting -> Grace -> Ready -> Processed
|
|
/// \-> Cancelled \-> Defeated
|
|
function state(uint32 id) public view returns (ProposalState) {
|
|
Proposal memory prop = proposals[id];
|
|
if (prop.id == 0) {
|
|
/*Uninitialized state*/
|
|
return ProposalState.Unborn;
|
|
} else if (
|
|
prop.status[0] /* cancelled */
|
|
) {
|
|
return ProposalState.Cancelled;
|
|
} else if (
|
|
prop.votingStarts == 0 /*Voting has not started*/
|
|
) {
|
|
return ProposalState.Submitted;
|
|
} else if (
|
|
block.timestamp <= prop.votingEnds /*Voting in progress*/
|
|
) {
|
|
return ProposalState.Voting;
|
|
} else if (
|
|
block.timestamp <= prop.graceEnds /*Proposal in grace period*/
|
|
) {
|
|
return ProposalState.Grace;
|
|
} else if (
|
|
prop.noVotes >= prop.yesVotes /*Voting has concluded and failed to pass*/
|
|
) {
|
|
return ProposalState.Defeated;
|
|
} else if (
|
|
prop.status[1] /* processed */
|
|
) {
|
|
return ProposalState.Processed;
|
|
}
|
|
/* Proposal is ready to be processed*/
|
|
else {
|
|
return ProposalState.Ready;
|
|
}
|
|
}
|
|
|
|
/// @notice Helper to get recorded proposal flags
|
|
/// @param id Number of proposal in proposals
|
|
/// @return [cancelled, processed, passed, actionFailed]
|
|
function getProposalStatus(uint32 id)
|
|
external
|
|
view
|
|
returns (bool[4] memory)
|
|
{
|
|
return proposals[id].status;
|
|
}
|
|
|
|
/// @notice Helper to check if shaman permission contains admin capabilities
|
|
/// @param shaman Address attempting to execute admin permissioned functions
|
|
function isAdmin(address shaman) public view returns (bool) {
|
|
uint256 permission = shamans[shaman];
|
|
return (permission == 1 ||
|
|
permission == 3 ||
|
|
permission == 5 ||
|
|
permission == 7);
|
|
}
|
|
|
|
/// @notice Helper to check if shaman permission contains manager capabilities
|
|
/// @param shaman Address attempting to execute manager permissioned functions
|
|
function isManager(address shaman) public view returns (bool) {
|
|
uint256 permission = shamans[shaman];
|
|
return (permission == 2 ||
|
|
permission == 3 ||
|
|
permission == 6 ||
|
|
permission == 7);
|
|
}
|
|
|
|
/// @notice Helper to check if shaman permission contains governor capabilities
|
|
/// @param shaman Address attempting to execute governor permissioned functions
|
|
function isGovernor(address shaman) public view returns (bool) {
|
|
uint256 permission = shamans[shaman];
|
|
return (permission == 4 ||
|
|
permission == 5 ||
|
|
permission == 6 ||
|
|
permission == 7);
|
|
}
|
|
|
|
/// @notice Helper to check total supply of child loot contract
|
|
function totalLoot() public view returns (uint256) {
|
|
return lootToken.totalSupply();
|
|
}
|
|
|
|
/// @notice Helper to check total supply of child shares contract
|
|
function totalShares() public view returns (uint256) {
|
|
return sharesToken.totalSupply();
|
|
}
|
|
|
|
/// @notice Helper to check total supply of loot and shares
|
|
function totalSupply() public view returns (uint256) {
|
|
return totalLoot() + totalShares();
|
|
}
|
|
|
|
/***************
|
|
HELPER FUNCTIONS
|
|
***************/
|
|
/// @notice Returns the keccak256 hash of calldata
|
|
function hashOperation(bytes memory _transactions)
|
|
public
|
|
pure
|
|
virtual
|
|
returns (bytes32 hash)
|
|
{
|
|
return keccak256(abi.encode(_transactions));
|
|
}
|
|
|
|
/// @notice Provides 'safe' {transfer} for ETH.
|
|
function _safeTransferETH(address to, uint256 amount) internal {
|
|
// transfer eth from target
|
|
(bool success, ) = execAndReturnData(
|
|
to,
|
|
amount,
|
|
"",
|
|
Enum.Operation.Call
|
|
);
|
|
|
|
require(success, "ETH_TRANSFER_FAILED");
|
|
}
|
|
|
|
/// @notice Provides 'safe' {transfer} for tokens that do not consistently return 'true/false'.
|
|
function _safeTransfer(
|
|
address token,
|
|
address to,
|
|
uint256 amount
|
|
) private {
|
|
(bool success, bytes memory data) = execAndReturnData(
|
|
token,
|
|
0,
|
|
abi.encodeWithSelector(0xa9059cbb, to, amount),
|
|
Enum.Operation.Call
|
|
); /*'transfer(address,uint)'*/
|
|
require(
|
|
success && (data.length == 0 || abi.decode(data, (bool))),
|
|
"transfer failed"
|
|
); /*checks success & allows non-conforming transfers*/
|
|
}
|
|
|
|
/// @notice Provides access to message sender of a meta transaction (EIP-2771)
|
|
function _msgSender() internal view override(ContextUpgradeable, BaseRelayRecipient)
|
|
returns (address sender) {
|
|
sender = BaseRelayRecipient._msgSender();
|
|
}
|
|
|
|
/// @notice Provides access to message data of a meta transaction (EIP-2771)
|
|
function _msgData() internal view override(ContextUpgradeable, BaseRelayRecipient)
|
|
returns (bytes calldata) {
|
|
return BaseRelayRecipient._msgData();
|
|
}
|
|
}
|