diff --git a/foundry.toml b/foundry.toml index 2580422..ee2d708 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,3 +6,4 @@ solc_version = '0.8.17' auto_detect_solc = false optimizer_runs = 1_000 +via-ir = true \ No newline at end of file diff --git a/lib/solbase b/lib/solbase new file mode 160000 index 0000000..e12d00b --- /dev/null +++ b/lib/solbase @@ -0,0 +1 @@ +Subproject commit e12d00b9ff196667e3199d61971907510948583b diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..ec818db --- /dev/null +++ b/remappings.txt @@ -0,0 +1,4 @@ +ds-test/=lib/forge-std/lib/ds-test/src/ +forge-std/=lib/forge-std/src/ +solbase/=lib/solbase/src/ +solmate/=lib/solmate/src/ diff --git a/src/Greeter.sol b/src/Greeter.sol deleted file mode 100644 index 48efa25..0000000 --- a/src/Greeter.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.17; - -import { ERC20 } from "solmate/tokens/ERC20.sol"; - -/// @title Greeter -contract Greeter { - string public greeting; - address public owner; - - // CUSTOMS - error BadGm(); - event GMEverybodyGM(); - - constructor(string memory newGreeting) { - greeting = newGreeting; - owner = msg.sender; - } - - function gm(string memory myGm) external returns(string memory greet) { - if (keccak256(abi.encodePacked((myGm))) != keccak256(abi.encodePacked((greet = greeting)))) revert BadGm(); - emit GMEverybodyGM(); - } - - function setGreeting(string memory newGreeting) external { - greeting = newGreeting; - } -} diff --git a/src/KaliBerger.sol b/src/KaliBerger.sol new file mode 100644 index 0000000..6e28818 --- /dev/null +++ b/src/KaliBerger.sol @@ -0,0 +1,539 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.4; + +import {LibString} from "../lib/solbase/src/utils/LibString.sol"; + +import {IStorage} from "./interface/IStorage.sol"; +import {Storage} from "./Storage.sol"; + +import {KaliDAOfactory} from "./kalidao/KaliDAOfactory.sol"; +import {KaliDAO} from "./kalidao/KaliDAO.sol"; +import {IKaliTokenManager} from "./interface/IKaliTokenManager.sol"; + +import {IERC721} from "../lib/forge-std/src/interfaces/IERC721.sol"; +import {IERC20} from "../lib/forge-std/src/interfaces/IERC20.sol"; + +/// @notice When DAOs use Harberger Tax to sell goods and services and automagically form treasury subDAOs, good things happen! +contract KaliBerger is Storage { + /// ----------------------------------------------------------------------- + /// Custom Error + /// ----------------------------------------------------------------------- + + error NotAuthorized(); + error TransferFailed(); + error InvalidPrice(); + error InvalidExit(); + error NotPatron(); + error NotInitialized(); + error InvalidPurchase(); + + /// ----------------------------------------------------------------------- + /// Constructor + /// ----------------------------------------------------------------------- + + function initialize(address dao, address factory, address impactToken) external { + if (factory != address(0)) { + init(dao, address(0)); + setKaliDaoFactory(factory); + } + } + + /// ----------------------------------------------------------------------- + /// Modifiers + /// ----------------------------------------------------------------------- + + modifier onlyPatron(address target, uint256 value) { + if (!this.isPatron(target, value, msg.sender)) revert NotPatron(); + _; + } + + modifier collectPatronage(address target, uint256 value) { + _collectPatronage(target, value); + _; + } + + modifier initialized() { + if ( + this.getKaliDaoFactory() == address(0) || this.getDao() == address(0) || this.getImpactToken() == address(0) + ) revert NotInitialized(); + _; + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Functions - Confirm Use of Harberger Tax + /// ----------------------------------------------------------------------- + + /// @notice Escrow ERC721 NFT before making it available for purchase. + function escrow(address token, uint256 tokenId, uint256 price) external payable onlyOperator { + if (price == 0) revert InvalidPrice(); + if (IERC721(token).ownerOf(tokenId) != msg.sender) revert NotAuthorized(); + IERC721(token).safeTransferFrom(msg.sender, address(this), tokenId); + this.setPrice(token, tokenId, price); + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Functions - DAO memberships + /// ----------------------------------------------------------------------- + + /// @notice Public function to rebalance an Impact DAO. + function balanceDao(address target, uint256 value) external payable { + // Get address to DAO to manage revenue from Harberger Tax + address payable dao = payable(this.getImpactDao(target, value)); + if (dao == address(0)) revert NotAuthorized(); + + _balance(target, value, dao); + } + + /// @notice Summon an Impact DAO + function summonDao(address target, uint256 value, address creator, address patron) private returns (address) { + address[] memory extensions; + bytes[] memory extensionsData; + + address[] memory voters; + voters[0] = creator; + voters[1] = patron; + + uint256[] memory tokens; + tokens[1] = this.getPatronContribution(target, value, patron); + tokens[0] = tokens[1]; + + uint32[16] memory govSettings; + govSettings = [uint32(300), 0, 60, 20, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]; + + uint256 count = this.getBergerCount(); + address payable dao = payable( + KaliDAOfactory(this.getKaliDaoFactory()).deployKaliDAO( + string.concat("BergerTime #", LibString.toString(count)), + string.concat("BT #", LibString.toString(count)), + " ", + true, + extensions, + extensionsData, + voters, + tokens, + govSettings + ) + ); + + setImpactDao(target, value, dao); + return dao; + } + + /// @notice Update DAO balance when ImpactToken is purchased. + function updateBalances(address target, uint256 value, address patron) internal { + // Get DAO address to manage revenue from Harberger Tax + address dao = this.getImpactDao(target, value); + + if (dao == address(0)) { + // Summon DAO with 50/50 ownership between creator and patron(s). + summonDao(target, value, this.getCreator(target, value), patron); + } else { + // Update DAO balance. + _balance(target, value, dao); + } + + } + + /// @notice Rebalance Impact DAO. + function _balance(address target, uint256 value, address dao) private { + for (uint256 i = 0; i < this.getPatronCount(target, value);) { + // Retrieve patron and patron contribution. + address _patron = this.getPatron(target, value, i); + uint256 contribution = this.getPatronContribution(target, value, _patron); + + // Retrieve KaliDAO balance data. + uint256 _contribution = IERC20(dao).balanceOf(msg.sender); + + // Retrieve creator. + address creator = this.getCreator(target, value); + + if (contribution != _contribution) { + // Determine to mint or burn. + if (contribution > _contribution) { + IKaliTokenManager(dao).mintTokens(creator, contribution - _contribution); + IKaliTokenManager(dao).mintTokens(_patron, contribution - _contribution); + } else if (contribution < _contribution) { + IKaliTokenManager(dao).burnTokens(creator, _contribution - contribution); + IKaliTokenManager(dao).burnTokens(_patron, _contribution - contribution); + } + } + + unchecked { + ++i; + } + } + } + + /// ----------------------------------------------------------------------- + /// Patron Logic + /// ----------------------------------------------------------------------- + + /// @notice Buy ERC721 NFT. + function buyErc(address token, uint256 tokenId, uint256 newPrice, uint256 currentPrice) + external + payable + initialized + collectPatronage(token, tokenId) + { + // Pay currentPrice + deposit to current owner. + address owner = this.getOwner(token, tokenId); + if (owner != address(0)) processPayment(token, tokenId, owner, newPrice, currentPrice); + + // Transfer ERC721 NFT and update price, ownership, and patron data. + transferNft(token, tokenId, owner, msg.sender, newPrice); + + // Balance DAO according to updated contribution. + updateBalances(token, tokenId, msg.sender); + } + + /// @notice Set new price for purchase. + function setPrice(address target, uint256 value, uint256 price) + external + payable + onlyPatron(target, value) + collectPatronage(target, value) + { + if (price == 0) revert InvalidPrice(); + this.setUint(keccak256(abi.encode(target, value, ".price")), price); + } + + /// @notice To make deposit. + function addDeposit(address target, uint256 value, uint256 _deposit) external payable onlyPatron(target, value) { + this.addUint(keccak256(abi.encode(target, value, ".deposit")), _deposit); + } + + /// @notice Withdraw from deposit. + function exit(address target, uint256 value, uint256 amount) + public + collectPatronage(target, value) + onlyPatron(target, value) + { + uint256 deposit = this.getDeposit(target, value); + if (deposit >= amount) revert InvalidExit(); + + (bool success,) = msg.sender.call{value: deposit - amount}(""); + if (!success) revert TransferFailed(); + + _forecloseIfNecessary(target, value, deposit); + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Functions - Setter Logic + /// ----------------------------------------------------------------------- + + /// @param factory The address of dao factory. + function setKaliDaoFactory(address factory) public onlyOperator { + this.setAddress(keccak256(abi.encodePacked("dao.factory")), factory); + } + + function setImpactDao(address target, uint256 value, address dao) public onlyOperator { + this.setAddress(keccak256(abi.encode(target, value, ".dao")), dao); + } + + function setImpactToken(address token) public onlyOperator { + this.setAddress(keccak256(abi.encodePacked("impactToken")), token); + } + + function setTax(address target, uint256 value, uint256 _tax) external payable onlyOperator { + this.setUint(keccak256(abi.encode(target, value, ".tax")), _tax); + } + + function setCreator(address target, uint256 value, address creator) external payable onlyOperator { + this.setAddress(keccak256(abi.encode(target, value, ".creator")), creator); + } + + function setTimeCollected(address target, uint256 value, uint256 timestamp) internal { + this.setUint(keccak256(abi.encode(target, value, ".timeCollected")), timestamp); + } + + function setTimeAcquired(address target, uint256 value, uint256 timestamp) internal { + this.setUint(keccak256(abi.encode(target, value, ".timeAcquired")), timestamp); + } + + function setOwner(address target, uint256 value, address owner) internal { + this.setAddress(keccak256(abi.encode(target, value, ".owner")), owner); + } + + /// @notice + function setPatron(address target, uint256 value, address patron) internal { + incrementPatronId(target, value); + this.setAddress(keccak256(abi.encode(target, value, this.getPatronCount(target, value))), patron); + } + + function setPatronStatus(address target, uint256 value, address patron, bool status) internal { + this.setBool(keccak256(abi.encode(target, value, patron, ".isPatron")), status); + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Functions - Getter Logic + /// ----------------------------------------------------------------------- + + function getKaliDaoFactory() external view returns (address) { + return this.getAddress(keccak256(abi.encodePacked("dao.factory"))); + } + + function getBergerCount() external view returns (uint256) { + return this.getUint(keccak256(abi.encodePacked("bergerTimes.count"))); + } + + function getImpactDao(address target, uint256 value) external view returns (address) { + return this.getAddress(keccak256(abi.encode(target, value, ".dao"))); + } + + function getImpactToken() external view returns (address) { + return this.getAddress(keccak256(abi.encodePacked("impactToken"))); + } + + function getTax(address target, uint256 value) external view returns (uint256 _tax) { + _tax = this.getUint(keccak256(abi.encode(target, value, ".tax"))); + return (_tax == 0) ? _tax = 50 : _tax; // default tax rate is hardcoded at 50% + } + + function getPrice(address target, uint256 value) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(target, value, ".price"))); + } + + function getCreator(address target, uint256 value) external view returns (address) { + return this.getAddress(keccak256(abi.encode(target, value, ".creator"))); + } + + function getDeposit(address target, uint256 value) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(target, value, ".deposit"))); + } + + function getTimeCollected(address target, uint256 value) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(target, value, ".timeCollected"))); + } + + function getTimeAcquired(address target, uint256 value) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(target, value, ".timeAcquired"))); + } + + function getUnclaimed(address user) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(user, ".unclaimed"))); + } + + function getTimeHeld(address user) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(user, ".timeHeld"))); + } + + function getTotalCollected(address target, uint256 value) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(target, value, ".totalCollected"))); + } + + function getOwner(address target, uint256 value) external view returns (address) { + return this.getPatron(target, value, this.getPatronCount(target, value)); + } + + function getPatronCount(address target, uint256 value) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(target, value, ".patronCount"))); + } + + function getPatronId(address target, uint256 value, address patron) external view returns (uint256) { + uint256 count = this.getPatronCount(target, value); + + for (uint256 i = 0; i < count;) { + if (patron == this.getPatron(target, value, i)) return i; + unchecked { + ++i; + } + } + + return 0; + } + + function isPatron(address target, uint256 value, address patron) external view returns (bool) { + return this.getBool(keccak256(abi.encode(target, value, patron, ".isPatron"))); + } + + function getPatron(address target, uint256 value, uint256 patronId) external view returns (address) { + return this.getAddress(keccak256(abi.encode(target, value, patronId))); + } + + function getPatronContribution(address target, uint256 value, address patron) external view returns (uint256) { + return this.getUint(keccak256(abi.encode(target, value, patron))); + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Functions - Add Logic + /// ----------------------------------------------------------------------- + + function addBergerCount() external payable onlyOperator { + this.addUint(keccak256(abi.encodePacked("bergerTimes.count")), 1); + } + + function addUnclaimed(address user, uint256 amount) internal { + this.addUint(keccak256(abi.encode(user, ".unclaimed")), amount); + } + + function addTimeHeld(address user, uint256 time) external { + this.addUint(keccak256(abi.encode(user, ".timeHeld")), time); + } + + function addTotalCollected(address target, uint256 value, uint256 collected) internal { + this.addUint(keccak256(abi.encode(target, value, ".totalCollected")), collected); + } + + function incrementPatronId(address target, uint256 value) internal { + this.addUint(keccak256(abi.encode(target, value, ".patronCount")), 1); + } + + function addPatronContribution(address target, uint256 value, address patron, uint256 amount) internal { + this.addUint(keccak256(abi.encode(target, value, patron)), amount); + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Storage - Delete Logic + /// ----------------------------------------------------------------------- + + function deleteDeposit(address target, uint256 value) internal { + return this.deleteUint(keccak256(abi.encode(target, value, ".deposit"))); + } + + function deleteUnclaimed(address user) internal { + this.deleteUint(keccak256(abi.encode(user, ".unclaimed"))); + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Functions - Collection Logic + /// ----------------------------------------------------------------------- + + // credit: simondlr https://github.com/simondlr/thisartworkisalwaysonsale/blob/master/packages/hardhat/contracts/v1/ArtStewardV2.sol + function patronageToCollect(address target, uint256 value) external view returns (uint256 amount) { + return this.getPrice(target, value) * ((block.timestamp - this.getTimeCollected(target, value)) / 365 days) + * (this.getTax(target, value) / 100); + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Functions - Foreclosure Logic + /// ----------------------------------------------------------------------- + + // credit: simondlr https://github.com/simondlr/thisartworkisalwaysonsale/blob/master/packages/hardhat/contracts/v1/ArtStewardV2.sol + function isForeclosed(address target, uint256 value) external view returns (bool, uint256) { + // returns whether it is in foreclosed state or not + // depending on whether deposit covers patronage due + // useful helper function when price should be zero, but contract doesn't reflect it yet. + uint256 toCollect = this.patronageToCollect(target, value); + uint256 _deposit = this.getDeposit(target, value); + if (toCollect >= _deposit) { + return (true, 0); + } else { + return (false, _deposit - toCollect); + } + } + + // credit: simondlr https://github.com/simondlr/thisartworkisalwaysonsale/blob/master/packages/hardhat/contracts/v1/ArtStewardV2.sol + function foreclosureTime(address target, uint256 value) external view returns (uint256) { + uint256 pps = this.getPrice(target, value) / 365 days * (this.getTax(target, value) / 100); + (, uint256 daw) = this.isForeclosed(target, value); + if (daw > 0) { + return block.timestamp + daw / pps; + } else if (pps > 0) { + // it is still active, but in foreclosure state + // it is block.timestamp or was in the pas + // not active and actively foreclosed (price is zero) + uint256 timeCollected = this.getTimeCollected(target, value); + return timeCollected + + (block.timestamp - timeCollected) * this.getDeposit(target, value) + / this.patronageToCollect(target, value); + } else { + // not active and actively foreclosed (price is zero) + return this.getTimeCollected(target, value); // it has been foreclosed or in foreclosure. + } + } + + function _forecloseIfNecessary(address target, uint256 value, uint256 _deposit) internal { + if (_deposit == 0) { + IERC721(target).safeTransferFrom(IERC721(target).ownerOf(value), address(this), value); + } + } + + // credit: simondlr https://github.com/simondlr/thisartworkisalwaysonsale/blob/master/packages/hardhat/contracts/v1/ArtStewardV2.sol + function _collectPatronage(address target, uint256 value) internal { + uint256 price = this.getPrice(target, value); + uint256 toCollect = this.patronageToCollect(target, value); + uint256 deposit = this.getDeposit(target, value); + + uint256 timeCollected = this.getTimeCollected(target, value); + + if (price != 0) { + // price > 0 == active owned state + if (toCollect >= deposit) { + // foreclosure happened in the past + // up to when was it actually paid for? + // TLC + (time_elapsed)*deposit/toCollect + setTimeCollected(target, value, (block.timestamp - timeCollected) * deposit / toCollect); + toCollect = deposit; // take what's left. + } else { + setTimeCollected(target, value, block.timestamp); + } // normal collection + + deposit -= toCollect; + + // Add to total amount collected. + addTotalCollected(target, value, toCollect); + + // Add to amount collected by patron. + addPatronContribution(target, value, msg.sender, toCollect); + + _forecloseIfNecessary(target, value, deposit); + } + } + + /// ----------------------------------------------------------------------- + /// KaliBerger Functions - NFT Transfer & Payments Logic + /// ----------------------------------------------------------------------- + + /// @notice Internal function to transfer ImpactToken. + // credit: simondlr https://github.com/simondlr/thisartworkisalwaysonsale/blob/master/packages/hardhat/contracts/v1/ArtStewardV2.sol + function transferNft(address token, uint256 tokenId, address currentOwner, address newOwner, uint256 price) + internal + { + // note: it would also tabulate time held in stewardship by smart contract + this.addTimeHeld(currentOwner, this.getTimeCollected(token, tokenId) - this.getTimeAcquired(token, tokenId)); + + // Otherwise transfer ownership. + IERC721(token).safeTransferFrom(currentOwner, newOwner, tokenId); + + // Update new price. + this.setPrice(token, tokenId, price); + + // Update time of acquisition. + setTimeAcquired(token, tokenId, block.timestamp); + + // Add new owner as patron + setPatron(token, tokenId, newOwner); + + // Toggle new owner's patron status + setPatronStatus(token, tokenId, newOwner, true); + } + + /// @notice Internal function to process purchase payment. + /// credit: simondlr https://github.com/simondlr/thisartworkisalwaysonsale/blob/master/packages/hardhat/contracts/v1/ArtStewardV2.sol + function processPayment(address target, uint256 value, address currentOwner, uint256 newPrice, uint256 currentPrice) + internal + { + // Confirm price. + uint256 price = this.getPrice(target, value); + if (price != currentPrice || newPrice == 0 || msg.value != currentPrice) revert InvalidPurchase(); + + // Add purchase price to patron contribution. + addPatronContribution(target, value, msg.sender, price); + + // Retrieve deposit, if any. + uint256 deposit = this.getDeposit(target, value); + + if (price + deposit > 0) { + // this won't execute if KaliBerger owns it. price = 0. deposit = 0. + // pay previous owner their price + deposit back. + (bool success,) = currentOwner.call{value: price + deposit}(""); + if (!success) addUnclaimed(currentOwner, price + deposit); + deleteDeposit(target, value); + } + + // Make deposit, if any. + this.addDeposit(target, value, msg.value - price); + } + + receive() external payable virtual {} +} diff --git a/src/Storage.sol b/src/Storage.sol new file mode 100644 index 0000000..118b945 --- /dev/null +++ b/src/Storage.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity >=0.8.4; + +// import {SafeMulticallable} from "solbase/utils/SafeMulticallable.sol"; +import {IStorage} from "src/interface/IStorage.sol"; + +/// @notice Directory for Quests +/// @author Modified from Kali (https://github.com/kalidao/kali-contracts/blob/main/contracts/access/KaliAccessManager.sol) +/// @author Storage pattern inspired by RocketPool (https://github.com/rocket-pool/rocketpool/blob/6a9dbfd85772900bb192aabeb0c9b8d9f6e019d1/contracts/contract/RocketStorage.sol) + +contract Storage { + /// ----------------------------------------------------------------------- + /// Errors + /// ----------------------------------------------------------------------- + + error NotOperator(); + error NotPlayground(); + error LengthMismatch(); + + /// ----------------------------------------------------------------------- + /// List Storage + /// ----------------------------------------------------------------------- + + mapping(bytes32 => string) public stringStorage; + mapping(bytes32 => address) public addressStorage; + mapping(bytes32 => uint256) public uintStorage; + mapping(bytes32 => bool) public booleanStorage; + + /// ----------------------------------------------------------------------- + /// Constructor + /// ----------------------------------------------------------------------- + + function init(address dao, address target) internal { + addressStorage[keccak256(abi.encodePacked("dao"))] = dao; + if (target != address(0)) booleanStorage[keccak256(abi.encodePacked("playground.", target))] = true; + } + + /// ----------------------------------------------------------------------- + /// Modifier + /// ----------------------------------------------------------------------- + + modifier onlyOperator() { + if (msg.sender != this.getDao() && msg.sender != address(this)) { + revert NotOperator(); + } + _; + } + + modifier playground(address target) { + assert(IStorage(target).getBool(keccak256(abi.encodePacked("playground.", target)))); + _; + } + /// ----------------------------------------------------------------------- + /// General Storage - Setter Logic + /// ----------------------------------------------------------------------- + + /// @param dao The DAO address. + function setDao(address dao) external onlyOperator { + addressStorage[keccak256(abi.encodePacked("dao"))] = dao; + } + + /// @dev Determine if target contract is a Playground contract. + function setPlaygroundContract(address target) external onlyOperator playground(target) { + if (target != address(0)) booleanStorage[keccak256(abi.encodePacked("playground.", target))] = true; + } + + /// @param _key The key for the record. + function setAddress(bytes32 _key, address _value) external onlyOperator { + addressStorage[_key] = _value; + } + + /// @param _key The key for the record. + function setUint(bytes32 _key, uint256 _value) external onlyOperator { + uintStorage[_key] = _value; + } + + /// @param _key The key for the record. + function setString(bytes32 _key, string calldata _value) external onlyOperator { + stringStorage[_key] = _value; + } + + /// @param _key The key for the record. + function setBool(bytes32 _key, bool _value) external onlyOperator { + booleanStorage[_key] = _value; + } + + /// ----------------------------------------------------------------------- + /// General Sotrage - Delete Logic + /// ----------------------------------------------------------------------- + + /// @param _key The key for the record. + function deleteAddress(bytes32 _key) external onlyOperator { + delete addressStorage[_key]; + } + + /// @param _key The key for the record. + function deleteUint(bytes32 _key) external onlyOperator { + delete uintStorage[_key]; + } + + /// @param _key The key for the record. + function deleteString(bytes32 _key) external onlyOperator { + delete stringStorage[_key]; + } + + /// @param _key The key for the record. + function deleteBool(bytes32 _key) external onlyOperator { + delete booleanStorage[_key]; + } + + /// ----------------------------------------------------------------------- + /// Add Logic + /// ----------------------------------------------------------------------- + + /// @param _key The key for the record. + /// @param _amount An amount to add to the record's value + function addUint(bytes32 _key, uint256 _amount) external onlyOperator returns (uint256) { + return uintStorage[_key] = uintStorage[_key] + _amount; + } + + /// @param _key The key for the record. + /// @param _amount An amount to subtract from the record's value + function subUint(bytes32 _key, uint256 _amount) external onlyOperator returns (uint256) { + return uintStorage[_key] = uintStorage[_key] - _amount; + } + + /// ----------------------------------------------------------------------- + /// General Storage - Getter Logic + /// ----------------------------------------------------------------------- + + /// @dev Get the address of DAO. + function getDao() external view returns (address) { + return addressStorage[keccak256(abi.encodePacked("dao"))]; + } + + /// @param _key The key for the record. + function getAddress(bytes32 _key) external view returns (address) { + return addressStorage[_key]; + } + + /// @param _key The key for the record. + function getUint(bytes32 _key) external view returns (uint256) { + return uintStorage[_key]; + } + + /// @param _key The key for the record. + function getString(bytes32 _key) external view returns (string memory) { + return stringStorage[_key]; + } + + /// @param _key The key for the record. + function getBool(bytes32 _key) external view returns (bool) { + return booleanStorage[_key]; + } +} diff --git a/src/interface/IKaliBerger.sol b/src/interface/IKaliBerger.sol new file mode 100644 index 0000000..f50957b --- /dev/null +++ b/src/interface/IKaliBerger.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {KaliBerger} from "src/KaliBerger.sol"; + +interface IKaliBerger { + function getTax(address target, uint256 value) external view returns (uint256 _tax); +} diff --git a/src/interface/IKaliTokenManager.sol b/src/interface/IKaliTokenManager.sol new file mode 100644 index 0000000..6bb1279 --- /dev/null +++ b/src/interface/IKaliTokenManager.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +/// @notice Kali DAO share manager interface +interface IKaliTokenManager { + function mintTokens(address to, uint256 amount) external; + + function burnTokens(address from, uint256 amount) external; + + function balanceOf(address account) external view returns (uint256); +} diff --git a/src/interface/IStorage.sol b/src/interface/IStorage.sol new file mode 100644 index 0000000..4a80d22 --- /dev/null +++ b/src/interface/IStorage.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +interface IStorage { + /// @dev Storage get methods + function getDao() external view returns (address); + function getAddress(bytes32 _key) external view returns (address); + function getMissionsAddress() external view returns (address); + function getQuestsAddress() external view returns (address); + function getBool(bytes32 _key) external view returns (bool); + function getString(bytes32 _key) external view returns (string memory); + function getUint(bytes32 _key) external view returns (uint256); + + /// @dev Storage set methods + function setAddress(bytes32 _key, address _value) external; + function setMissionsAddress(address account) external; + function setQuestsAddress(address account) external; + function setBool(bytes32 _key, bool _value) external; + function setString(bytes32 _key, string memory _value) external; + function setUint(bytes32 _key, uint256 _value) external; + + /// @dev Storage delete methods + function deleteAddress(bytes32 _key) external; + function deleteBool(bytes32 _key) external; + function deleteString(bytes32 _key) external; + function deleteUint(bytes32 _key) external; + + /// @dev Storage arithmetic methods + function addUint(bytes32 _key, uint256 _amount) external; + function subUint(bytes32 _key, uint256 _amount) external; +} diff --git a/src/kalidao/KaliDAO.sol b/src/kalidao/KaliDAO.sol new file mode 100644 index 0000000..b36adb7 --- /dev/null +++ b/src/kalidao/KaliDAO.sol @@ -0,0 +1,1061 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity >=0.8.4; + +/// @notice Modern and gas-optimized ERC-20 + EIP-2612 implementation with COMP-style governance and pausing. +/// @author Modified from Solmate (https://github.com/Rari-Capital/solmate/blob/main/src/erc20/ERC20.sol) +/// License-Identifier: AGPL-3.0-only +abstract contract KaliDAOtoken { + /*/////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event Transfer(address indexed from, address indexed to, uint256 amount); + + event Approval(address indexed owner, address indexed spender, uint256 amount); + + event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate); + + event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); + + event PauseFlipped(bool paused); + + /*/////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + error NoArrayParity(); + + error Paused(); + + error SignatureExpired(); + + error NullAddress(); + + error InvalidNonce(); + + error NotDetermined(); + + error InvalidSignature(); + + error Uint32max(); + + error Uint96max(); + + /*/////////////////////////////////////////////////////////////// + METADATA STORAGE + //////////////////////////////////////////////////////////////*/ + + string public name; + + string public symbol; + + uint8 public constant decimals = 18; + + /*/////////////////////////////////////////////////////////////// + ERC-20 STORAGE + //////////////////////////////////////////////////////////////*/ + + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + + mapping(address => mapping(address => uint256)) public allowance; + + /*/////////////////////////////////////////////////////////////// + EIP-2612 STORAGE + //////////////////////////////////////////////////////////////*/ + + bytes32 public constant PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + uint256 internal INITIAL_CHAIN_ID; + + bytes32 internal INITIAL_DOMAIN_SEPARATOR; + + mapping(address => uint256) public nonces; + + /*/////////////////////////////////////////////////////////////// + DAO STORAGE + //////////////////////////////////////////////////////////////*/ + + bool public paused; + + bytes32 public constant DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 deadline)"); + + mapping(address => address) internal _delegates; + + mapping(address => mapping(uint256 => Checkpoint)) public checkpoints; + + mapping(address => uint256) public numCheckpoints; + + struct Checkpoint { + uint32 fromTimestamp; + uint96 votes; + } + + /*/////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + function _init( + string memory name_, + string memory symbol_, + bool paused_, + address[] memory voters_, + uint256[] memory shares_ + ) internal virtual { + if (voters_.length != shares_.length) revert NoArrayParity(); + + name = name_; + + symbol = symbol_; + + paused = paused_; + + INITIAL_CHAIN_ID = block.chainid; + + INITIAL_DOMAIN_SEPARATOR = _computeDomainSeparator(); + + // cannot realistically overflow on human timescales + unchecked { + for (uint256 i; i < voters_.length; i++) { + _mint(voters_[i], shares_[i]); + } + } + } + + /*/////////////////////////////////////////////////////////////// + ERC-20 LOGIC + //////////////////////////////////////////////////////////////*/ + + function approve(address spender, uint256 amount) public payable virtual returns (bool) { + allowance[msg.sender][spender] = amount; + + emit Approval(msg.sender, spender, amount); + + return true; + } + + function transfer(address to, uint256 amount) public payable virtual notPaused returns (bool) { + balanceOf[msg.sender] -= amount; + + // cannot overflow because the sum of all user + // balances can't exceed the max uint256 value + unchecked { + balanceOf[to] += amount; + } + + _moveDelegates(delegates(msg.sender), delegates(to), amount); + + emit Transfer(msg.sender, to, amount); + + return true; + } + + function transferFrom(address from, address to, uint256 amount) public payable virtual notPaused returns (bool) { + if (allowance[from][msg.sender] != type(uint256).max) { + allowance[from][msg.sender] -= amount; + } + + balanceOf[from] -= amount; + + // cannot overflow because the sum of all user + // balances can't exceed the max uint256 value + unchecked { + balanceOf[to] += amount; + } + + _moveDelegates(delegates(from), delegates(to), amount); + + emit Transfer(from, to, amount); + + return true; + } + + /*/////////////////////////////////////////////////////////////// + EIP-2612 LOGIC + //////////////////////////////////////////////////////////////*/ + + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + public + payable + virtual + { + if (block.timestamp > deadline) revert SignatureExpired(); + + // cannot realistically overflow on human timescales + unchecked { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR(), + keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)) + ) + ); + + address recoveredAddress = ecrecover(digest, v, r, s); + + if (recoveredAddress == address(0) || recoveredAddress != owner) revert InvalidSignature(); + + allowance[recoveredAddress][spender] = value; + } + + emit Approval(owner, spender, value); + } + + function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { + return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : _computeDomainSeparator(); + } + + function _computeDomainSeparator() internal view virtual returns (bytes32) { + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(name)), + keccak256("1"), + block.chainid, + address(this) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + DAO LOGIC + //////////////////////////////////////////////////////////////*/ + + modifier notPaused() { + if (paused) revert Paused(); + + _; + } + + function delegates(address delegator) public view virtual returns (address) { + address current = _delegates[delegator]; + + return current == address(0) ? delegator : current; + } + + function getCurrentVotes(address account) public view virtual returns (uint256) { + // this is safe from underflow because decrement only occurs if `nCheckpoints` is positive + unchecked { + uint256 nCheckpoints = numCheckpoints[account]; + + return nCheckpoints != 0 ? checkpoints[account][nCheckpoints - 1].votes : 0; + } + } + + function delegate(address delegatee) public payable virtual { + _delegate(msg.sender, delegatee); + } + + function delegateBySig(address delegatee, uint256 nonce, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + public + payable + virtual + { + if (block.timestamp > deadline) revert SignatureExpired(); + + bytes32 structHash = keccak256(abi.encode(DELEGATION_TYPEHASH, delegatee, nonce, deadline)); + + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); + + address signatory = ecrecover(digest, v, r, s); + + if (signatory == address(0)) revert NullAddress(); + + // cannot realistically overflow on human timescales + unchecked { + if (nonce != nonces[signatory]++) revert InvalidNonce(); + } + + _delegate(signatory, delegatee); + } + + function getPriorVotes(address account, uint256 timestamp) public view virtual returns (uint96) { + if (block.timestamp <= timestamp) revert NotDetermined(); + + uint256 nCheckpoints = numCheckpoints[account]; + + if (nCheckpoints == 0) return 0; + + // this is safe from underflow because decrement only occurs if `nCheckpoints` is positive + unchecked { + if (checkpoints[account][nCheckpoints - 1].fromTimestamp <= timestamp) { + return checkpoints[account][nCheckpoints - 1].votes; + } + + if (checkpoints[account][0].fromTimestamp > timestamp) return 0; + + uint256 lower; + + // this is safe from underflow because decrement only occurs if `nCheckpoints` is positive + uint256 upper = nCheckpoints - 1; + + while (upper > lower) { + // this is safe from underflow because `upper` ceiling is provided + uint256 center = upper - (upper - lower) / 2; + + Checkpoint memory cp = checkpoints[account][center]; + + if (cp.fromTimestamp == timestamp) { + return cp.votes; + } else if (cp.fromTimestamp < timestamp) { + lower = center; + } else { + upper = center - 1; + } + } + + return checkpoints[account][lower].votes; + } + } + + function _delegate(address delegator, address delegatee) internal virtual { + address currentDelegate = delegates(delegator); + + _delegates[delegator] = delegatee; + + _moveDelegates(currentDelegate, delegatee, balanceOf[delegator]); + + emit DelegateChanged(delegator, currentDelegate, delegatee); + } + + function _moveDelegates(address srcRep, address dstRep, uint256 amount) internal virtual { + if (srcRep != dstRep && amount != 0) { + if (srcRep != address(0)) { + uint256 srcRepNum = numCheckpoints[srcRep]; + + uint256 srcRepOld = srcRepNum != 0 ? checkpoints[srcRep][srcRepNum - 1].votes : 0; + + uint256 srcRepNew = srcRepOld - amount; + + _writeCheckpoint(srcRep, srcRepNum, srcRepOld, srcRepNew); + } + } + + if (dstRep != address(0)) { + uint256 dstRepNum = numCheckpoints[dstRep]; + + uint256 dstRepOld = dstRepNum != 0 ? checkpoints[dstRep][dstRepNum - 1].votes : 0; + + uint256 dstRepNew = dstRepOld + amount; + + _writeCheckpoint(dstRep, dstRepNum, dstRepOld, dstRepNew); + } + } + + function _writeCheckpoint(address delegatee, uint256 nCheckpoints, uint256 oldVotes, uint256 newVotes) + internal + virtual + { + unchecked { + // this is safe from underflow because decrement only occurs if `nCheckpoints` is positive + if (nCheckpoints != 0 && checkpoints[delegatee][nCheckpoints - 1].fromTimestamp == block.timestamp) { + checkpoints[delegatee][nCheckpoints - 1].votes = _safeCastTo96(newVotes); + } else { + checkpoints[delegatee][nCheckpoints] = + Checkpoint(_safeCastTo32(block.timestamp), _safeCastTo96(newVotes)); + + // cannot realistically overflow on human timescales + numCheckpoints[delegatee] = nCheckpoints + 1; + } + } + + emit DelegateVotesChanged(delegatee, oldVotes, newVotes); + } + + /*/////////////////////////////////////////////////////////////// + MINT/BURN LOGIC + //////////////////////////////////////////////////////////////*/ + + function _mint(address to, uint256 amount) internal virtual { + totalSupply += amount; + + // cannot overflow because the sum of all user + // balances can't exceed the max uint256 value + unchecked { + balanceOf[to] += amount; + } + + _moveDelegates(address(0), delegates(to), amount); + + emit Transfer(address(0), to, amount); + } + + function _burn(address from, uint256 amount) internal virtual { + balanceOf[from] -= amount; + + // cannot underflow because a user's balance + // will never be larger than the total supply + unchecked { + totalSupply -= amount; + } + + _moveDelegates(delegates(from), address(0), amount); + + emit Transfer(from, address(0), amount); + } + + function burn(uint256 amount) public payable virtual { + _burn(msg.sender, amount); + } + + function burnFrom(address from, uint256 amount) public payable virtual { + if (allowance[from][msg.sender] != type(uint256).max) { + allowance[from][msg.sender] -= amount; + } + + _burn(from, amount); + } + + /*/////////////////////////////////////////////////////////////// + PAUSE LOGIC + //////////////////////////////////////////////////////////////*/ + + function _flipPause() internal virtual { + paused = !paused; + + emit PauseFlipped(paused); + } + + /*/////////////////////////////////////////////////////////////// + SAFECAST LOGIC + //////////////////////////////////////////////////////////////*/ + + function _safeCastTo32(uint256 x) internal pure virtual returns (uint32) { + if (x > type(uint32).max) revert Uint32max(); + + return uint32(x); + } + + function _safeCastTo96(uint256 x) internal pure virtual returns (uint96) { + if (x > type(uint96).max) revert Uint96max(); + + return uint96(x); + } +} + +/// @notice Helper utility that enables calling multiple local methods in a single call. +/// @author Modified from Uniswap (https://github.com/Uniswap/v3-periphery/blob/main/contracts/base/Multicall.sol) +abstract contract Multicall { + function multicall(bytes[] calldata data) public payable virtual returns (bytes[] memory results) { + results = new bytes[](data.length); + + // cannot realistically overflow on human timescales + unchecked { + for (uint256 i = 0; i < data.length; i++) { + (bool success, bytes memory result) = address(this).delegatecall(data[i]); + + if (!success) { + if (result.length < 68) revert(); + + assembly { + result := add(result, 0x04) + } + + revert(abi.decode(result, (string))); + } + results[i] = result; + } + } + } +} + +/// @notice Helper utility for NFT 'safe' transfers. +abstract contract NFThelper { + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4 sig) { + sig = 0x150b7a02; // 'onERC721Received(address,address,uint256,bytes)' + } + + function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure returns (bytes4 sig) { + sig = 0xf23a6e61; // 'onERC1155Received(address,address,uint256,uint256,bytes)' + } + + function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata) + external + pure + returns (bytes4 sig) + { + sig = 0xbc197c81; // 'onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)' + } +} + +/// @notice Gas-optimized reentrancy protection. +/// @author Modified from OpenZeppelin +/// (https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol) +/// License-Identifier: MIT +abstract contract ReentrancyGuard { + error Reentrancy(); + + uint256 private constant NOT_ENTERED = 1; + + uint256 private constant ENTERED = 2; + + uint256 private status = NOT_ENTERED; + + modifier nonReentrant() { + if (status == ENTERED) revert Reentrancy(); + + status = ENTERED; + + _; + + status = NOT_ENTERED; + } +} + +/// @notice Kali DAO membership extension interface. +interface IKaliDAOextension { + function setExtension(bytes calldata extensionData) external; + + function callExtension(address account, uint256 amount, bytes calldata extensionData) + external + payable + returns (bool mint, uint256 amountOut); +} + +enum ProposalType { + MINT, // add membership + BURN, // revoke membership + CALL, // call contracts + VPERIOD, // set `votingPeriod` + GPERIOD, // set `gracePeriod` + QUORUM, // set `quorum` + SUPERMAJORITY, // set `supermajority` + TYPE, // set `VoteType` to `ProposalType` + PAUSE, // flip membership transferability + EXTENSION, // flip `extensions` whitelisting + ESCAPE, // delete pending proposal in case of revert + DOCS // amend org docs +} + +enum VoteType { + SIMPLE_MAJORITY, + SIMPLE_MAJORITY_QUORUM_REQUIRED, + SUPERMAJORITY, + SUPERMAJORITY_QUORUM_REQUIRED +} + +struct Proposal { + ProposalType proposalType; + string description; + address[] accounts; // member(s) being added/kicked; account(s) receiving payload + uint256[] amounts; // value(s) to be minted/burned/spent; gov setting [0] + bytes[] payloads; // data for CALL proposals + uint256 prevProposal; + uint96 yesVotes; + uint96 noVotes; + uint32 creationTime; + address proposer; +} + +struct ProposalState { + bool passed; + bool processed; +} + +/// @notice Simple gas-optimized Kali DAO core module. +contract KaliDAO is KaliDAOtoken, Multicall, NFThelper, ReentrancyGuard { + /*/////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event NewProposal( + address indexed proposer, + uint256 indexed proposal, + ProposalType indexed proposalType, + string description, + address[] accounts, + uint256[] amounts, + bytes[] payloads + ); + + event ProposalCancelled(address indexed proposer, uint256 indexed proposal); + + event ProposalSponsored(address indexed sponsor, uint256 indexed proposal); + + event VoteCast(address indexed voter, uint256 indexed proposal, bool indexed approve); + + event ProposalProcessed(uint256 indexed proposal, bool indexed didProposalPass); + + /*/////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + error Initialized(); + + error PeriodBounds(); + + error QuorumMax(); + + error SupermajorityBounds(); + + error InitCallFail(); + + error TypeBounds(); + + error NotProposer(); + + error Sponsored(); + + error NotMember(); + + error NotCurrentProposal(); + + error AlreadyVoted(); + + error NotVoteable(); + + error VotingNotEnded(); + + error PrevNotProcessed(); + + error NotExtension(); + + /*/////////////////////////////////////////////////////////////// + DAO STORAGE + //////////////////////////////////////////////////////////////*/ + + string public docs; + + uint256 private currentSponsoredProposal; + + uint256 public proposalCount; + + uint32 public votingPeriod; + + uint32 public gracePeriod; + + uint32 public quorum; // 1-100 + + uint32 public supermajority; // 1-100 + + bytes32 public constant VOTE_HASH = keccak256("SignVote(address signer,uint256 proposal,bool approve)"); + + mapping(address => bool) public extensions; + + mapping(uint256 => Proposal) public proposals; + + mapping(uint256 => ProposalState) public proposalStates; + + mapping(ProposalType => VoteType) public proposalVoteTypes; + + mapping(uint256 => mapping(address => bool)) public voted; + + mapping(address => uint256) public lastYesVote; + + /*/////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + function init( + string memory name_, + string memory symbol_, + string memory docs_, + bool paused_, + address[] memory extensions_, + bytes[] memory extensionsData_, + address[] calldata voters_, + uint256[] calldata shares_, + uint32[16] memory govSettings_ + ) public payable virtual nonReentrant { + if (extensions_.length != extensionsData_.length) revert NoArrayParity(); + + if (votingPeriod != 0) revert Initialized(); + + if (govSettings_[0] == 0 || govSettings_[0] > 365 days) revert PeriodBounds(); + + if (govSettings_[1] > 365 days) revert PeriodBounds(); + + if (govSettings_[2] > 100) revert QuorumMax(); + + if (govSettings_[3] <= 51 || govSettings_[3] > 100) revert SupermajorityBounds(); + + KaliDAOtoken._init(name_, symbol_, paused_, voters_, shares_); + + if (extensions_.length != 0) { + // cannot realistically overflow on human timescales + unchecked { + for (uint256 i; i < extensions_.length; i++) { + extensions[extensions_[i]] = true; + + if (extensionsData_[i].length > 3) { + (bool success,) = extensions_[i].call(extensionsData_[i]); + + if (!success) revert InitCallFail(); + } + } + } + } + + docs = docs_; + + votingPeriod = govSettings_[0]; + + gracePeriod = govSettings_[1]; + + quorum = govSettings_[2]; + + supermajority = govSettings_[3]; + + // set initial vote types + proposalVoteTypes[ProposalType.MINT] = VoteType(govSettings_[4]); + + proposalVoteTypes[ProposalType.BURN] = VoteType(govSettings_[5]); + + proposalVoteTypes[ProposalType.CALL] = VoteType(govSettings_[6]); + + proposalVoteTypes[ProposalType.VPERIOD] = VoteType(govSettings_[7]); + + proposalVoteTypes[ProposalType.GPERIOD] = VoteType(govSettings_[8]); + + proposalVoteTypes[ProposalType.QUORUM] = VoteType(govSettings_[9]); + + proposalVoteTypes[ProposalType.SUPERMAJORITY] = VoteType(govSettings_[10]); + + proposalVoteTypes[ProposalType.TYPE] = VoteType(govSettings_[11]); + + proposalVoteTypes[ProposalType.PAUSE] = VoteType(govSettings_[12]); + + proposalVoteTypes[ProposalType.EXTENSION] = VoteType(govSettings_[13]); + + proposalVoteTypes[ProposalType.ESCAPE] = VoteType(govSettings_[14]); + + proposalVoteTypes[ProposalType.DOCS] = VoteType(govSettings_[15]); + } + + /*/////////////////////////////////////////////////////////////// + PROPOSAL LOGIC + //////////////////////////////////////////////////////////////*/ + + function getProposalArrays(uint256 proposal) + public + view + virtual + returns (address[] memory accounts, uint256[] memory amounts, bytes[] memory payloads) + { + Proposal storage prop = proposals[proposal]; + + (accounts, amounts, payloads) = (prop.accounts, prop.amounts, prop.payloads); + } + + function propose( + ProposalType proposalType, + string calldata description, + address[] calldata accounts, + uint256[] calldata amounts, + bytes[] calldata payloads + ) public payable virtual nonReentrant returns (uint256 proposal) { + if (accounts.length != amounts.length || amounts.length != payloads.length) revert NoArrayParity(); + + if (proposalType == ProposalType.VPERIOD) if (amounts[0] == 0 || amounts[0] > 365 days) revert PeriodBounds(); + + if (proposalType == ProposalType.GPERIOD) if (amounts[0] > 365 days) revert PeriodBounds(); + + if (proposalType == ProposalType.QUORUM) if (amounts[0] > 100) revert QuorumMax(); + + if (proposalType == ProposalType.SUPERMAJORITY) { + if (amounts[0] <= 51 || amounts[0] > 100) revert SupermajorityBounds(); + } + + if (proposalType == ProposalType.TYPE) { + if (amounts[0] > 11 || amounts[1] > 3 || amounts.length != 2) revert TypeBounds(); + } + + bool selfSponsor; + + // if member or extension is making proposal, include sponsorship + if (balanceOf[msg.sender] != 0 || extensions[msg.sender]) selfSponsor = true; + + // cannot realistically overflow on human timescales + unchecked { + proposalCount++; + } + + proposal = proposalCount; + + proposals[proposal] = Proposal({ + proposalType: proposalType, + description: description, + accounts: accounts, + amounts: amounts, + payloads: payloads, + prevProposal: selfSponsor ? currentSponsoredProposal : 0, + yesVotes: 0, + noVotes: 0, + creationTime: selfSponsor ? _safeCastTo32(block.timestamp) : 0, + proposer: msg.sender + }); + + if (selfSponsor) currentSponsoredProposal = proposal; + + emit NewProposal(msg.sender, proposal, proposalType, description, accounts, amounts, payloads); + } + + function cancelProposal(uint256 proposal) public payable virtual nonReentrant { + Proposal storage prop = proposals[proposal]; + + if (msg.sender != prop.proposer) revert NotProposer(); + + if (prop.creationTime != 0) revert Sponsored(); + + delete proposals[proposal]; + + emit ProposalCancelled(msg.sender, proposal); + } + + function sponsorProposal(uint256 proposal) public payable virtual nonReentrant { + Proposal storage prop = proposals[proposal]; + + if (balanceOf[msg.sender] == 0) revert NotMember(); + + if (prop.proposer == address(0)) revert NotCurrentProposal(); + + if (prop.creationTime != 0) revert Sponsored(); + + prop.prevProposal = currentSponsoredProposal; + + currentSponsoredProposal = proposal; + + prop.creationTime = _safeCastTo32(block.timestamp); + + emit ProposalSponsored(msg.sender, proposal); + } + + function vote(uint256 proposal, bool approve) public payable virtual nonReentrant { + _vote(msg.sender, proposal, approve); + } + + function voteBySig(address signer, uint256 proposal, bool approve, uint8 v, bytes32 r, bytes32 s) + public + payable + virtual + nonReentrant + { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", DOMAIN_SEPARATOR(), keccak256(abi.encode(VOTE_HASH, signer, proposal, approve)) + ) + ); + + address recoveredAddress = ecrecover(digest, v, r, s); + + if (recoveredAddress == address(0) || recoveredAddress != signer) revert InvalidSignature(); + + _vote(signer, proposal, approve); + } + + function _vote(address signer, uint256 proposal, bool approve) internal virtual { + Proposal storage prop = proposals[proposal]; + + if (voted[proposal][signer]) revert AlreadyVoted(); + + // this is safe from overflow because `votingPeriod` is capped so it will not combine + // with unix time to exceed the max uint256 value + unchecked { + if (block.timestamp > prop.creationTime + votingPeriod) revert NotVoteable(); + } + + uint96 weight = getPriorVotes(signer, prop.creationTime); + + // this is safe from overflow because `yesVotes` and `noVotes` are capped by `totalSupply` + // which is checked for overflow in `KaliDAOtoken` contract + unchecked { + if (approve) { + prop.yesVotes += weight; + + lastYesVote[signer] = proposal; + } else { + prop.noVotes += weight; + } + } + + voted[proposal][signer] = true; + + emit VoteCast(signer, proposal, approve); + } + + function processProposal(uint256 proposal) + public + payable + virtual + nonReentrant + returns (bool didProposalPass, bytes[] memory results) + { + Proposal storage prop = proposals[proposal]; + + VoteType voteType = proposalVoteTypes[prop.proposalType]; + + if (prop.creationTime == 0) revert NotCurrentProposal(); + + // this is safe from overflow because `votingPeriod` and `gracePeriod` are capped so they will not combine + // with unix time to exceed the max uint256 value + unchecked { + if (block.timestamp <= prop.creationTime + votingPeriod + gracePeriod) revert VotingNotEnded(); + } + + // skip previous proposal processing requirement in case of escape hatch + if (prop.proposalType != ProposalType.ESCAPE) { + if (proposals[prop.prevProposal].creationTime != 0) revert PrevNotProcessed(); + } + + didProposalPass = _countVotes(voteType, prop.yesVotes, prop.noVotes); + + if (didProposalPass) { + // cannot realistically overflow on human timescales + unchecked { + if (prop.proposalType == ProposalType.MINT) { + for (uint256 i; i < prop.accounts.length; i++) { + _mint(prop.accounts[i], prop.amounts[i]); + } + } + + if (prop.proposalType == ProposalType.BURN) { + for (uint256 i; i < prop.accounts.length; i++) { + _burn(prop.accounts[i], prop.amounts[i]); + } + } + + if (prop.proposalType == ProposalType.CALL) { + for (uint256 i; i < prop.accounts.length; i++) { + results = new bytes[](prop.accounts.length); + + (, bytes memory result) = prop.accounts[i].call{value: prop.amounts[i]}(prop.payloads[i]); + + results[i] = result; + } + } + + // governance settings + if (prop.proposalType == ProposalType.VPERIOD) { + if (prop.amounts[0] != 0) votingPeriod = uint32(prop.amounts[0]); + } + + if (prop.proposalType == ProposalType.GPERIOD) { + if (prop.amounts[0] != 0) gracePeriod = uint32(prop.amounts[0]); + } + + if (prop.proposalType == ProposalType.QUORUM) { + if (prop.amounts[0] != 0) quorum = uint32(prop.amounts[0]); + } + + if (prop.proposalType == ProposalType.SUPERMAJORITY) { + if (prop.amounts[0] != 0) supermajority = uint32(prop.amounts[0]); + } + + if (prop.proposalType == ProposalType.TYPE) { + proposalVoteTypes[ProposalType(prop.amounts[0])] = VoteType(prop.amounts[1]); + } + + if (prop.proposalType == ProposalType.PAUSE) { + _flipPause(); + } + + if (prop.proposalType == ProposalType.EXTENSION) { + for (uint256 i; i < prop.accounts.length; i++) { + if (prop.amounts[i] != 0) { + extensions[prop.accounts[i]] = !extensions[prop.accounts[i]]; + } + + if (prop.payloads[i].length > 3) { + IKaliDAOextension(prop.accounts[i]).setExtension(prop.payloads[i]); + } + } + } + + if (prop.proposalType == ProposalType.ESCAPE) { + delete proposals[prop.amounts[0]]; + } + + if (prop.proposalType == ProposalType.DOCS) { + docs = prop.description; + } + + proposalStates[proposal].passed = true; + } + } + + delete proposals[proposal]; + + proposalStates[proposal].processed = true; + + emit ProposalProcessed(proposal, didProposalPass); + } + + function _countVotes(VoteType voteType, uint256 yesVotes, uint256 noVotes) + internal + view + virtual + returns (bool didProposalPass) + { + // fail proposal if no participation + if (yesVotes == 0 && noVotes == 0) return false; + + // rule out any failed quorums + if (voteType == VoteType.SIMPLE_MAJORITY_QUORUM_REQUIRED || voteType == VoteType.SUPERMAJORITY_QUORUM_REQUIRED) + { + uint256 minVotes = (totalSupply * quorum) / 100; + + // this is safe from overflow because `yesVotes` and `noVotes` + // supply are checked in `KaliDAOtoken` contract + unchecked { + uint256 votes = yesVotes + noVotes; + + if (votes < minVotes) return false; + } + } + + // simple majority check + if (voteType == VoteType.SIMPLE_MAJORITY || voteType == VoteType.SIMPLE_MAJORITY_QUORUM_REQUIRED) { + if (yesVotes > noVotes) return true; + // supermajority check + } else { + // example: 7 yes, 2 no, supermajority = 66 + // ((7+2) * 66) / 100 = 5.94; 7 yes will pass + uint256 minYes = ((yesVotes + noVotes) * supermajority) / 100; + + if (yesVotes >= minYes) return true; + } + } + + /*/////////////////////////////////////////////////////////////// + EXTENSIONS + //////////////////////////////////////////////////////////////*/ + + receive() external payable virtual {} + + modifier onlyExtension() { + if (!extensions[msg.sender]) revert NotExtension(); + + _; + } + + function callExtension(address extension, uint256 amount, bytes calldata extensionData) + public + payable + virtual + nonReentrant + returns (bool mint, uint256 amountOut) + { + if (!extensions[extension]) revert NotExtension(); + + (mint, amountOut) = + IKaliDAOextension(extension).callExtension{value: msg.value}(msg.sender, amount, extensionData); + + if (mint) { + if (amountOut != 0) _mint(msg.sender, amountOut); + } else { + if (amountOut != 0) _burn(msg.sender, amount); + } + } + + function mintShares(address to, uint256 amount) public payable virtual onlyExtension { + _mint(to, amount); + } + + function burnShares(address from, uint256 amount) public payable virtual onlyExtension { + _burn(from, amount); + } +} diff --git a/src/kalidao/KaliDAOfactory.sol b/src/kalidao/KaliDAOfactory.sol new file mode 100644 index 0000000..1d428c8 --- /dev/null +++ b/src/kalidao/KaliDAOfactory.sol @@ -0,0 +1,1147 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.8.4; + +/// @notice Modern and gas-optimized ERC-20 + EIP-2612 implementation with COMP-style governance and pausing. +/// @author Modified from Solmate (https://github.com/Rari-Capital/solmate/blob/main/src/erc20/ERC20.sol) +/// License-Identifier: AGPL-3.0-only +abstract contract KaliDAOtoken { + /*/////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event Transfer(address indexed from, address indexed to, uint256 amount); + + event Approval(address indexed owner, address indexed spender, uint256 amount); + + event DelegateChanged(address indexed delegator, address indexed fromDelegate, address indexed toDelegate); + + event DelegateVotesChanged(address indexed delegate, uint256 previousBalance, uint256 newBalance); + + event PauseFlipped(bool paused); + + /*/////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + error NoArrayParity(); + + error Paused(); + + error SignatureExpired(); + + error NullAddress(); + + error InvalidNonce(); + + error NotDetermined(); + + error InvalidSignature(); + + error Uint32max(); + + error Uint96max(); + + /*/////////////////////////////////////////////////////////////// + METADATA STORAGE + //////////////////////////////////////////////////////////////*/ + + string public name; + + string public symbol; + + uint8 public constant decimals = 18; + + /*/////////////////////////////////////////////////////////////// + ERC-20 STORAGE + //////////////////////////////////////////////////////////////*/ + + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + + mapping(address => mapping(address => uint256)) public allowance; + + /*/////////////////////////////////////////////////////////////// + EIP-2612 STORAGE + //////////////////////////////////////////////////////////////*/ + + bytes32 public constant PERMIT_TYPEHASH = + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); + + uint256 internal INITIAL_CHAIN_ID; + + bytes32 internal INITIAL_DOMAIN_SEPARATOR; + + mapping(address => uint256) public nonces; + + /*/////////////////////////////////////////////////////////////// + DAO STORAGE + //////////////////////////////////////////////////////////////*/ + + bool public paused; + + bytes32 public constant DELEGATION_TYPEHASH = + keccak256("Delegation(address delegatee,uint256 nonce,uint256 deadline)"); + + mapping(address => address) internal _delegates; + + mapping(address => mapping(uint256 => Checkpoint)) public checkpoints; + + mapping(address => uint256) public numCheckpoints; + + struct Checkpoint { + uint32 fromTimestamp; + uint96 votes; + } + + /*/////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + function _init( + string memory name_, + string memory symbol_, + bool paused_, + address[] memory voters_, + uint256[] memory shares_ + ) internal virtual { + if (voters_.length != shares_.length) revert NoArrayParity(); + + name = name_; + + symbol = symbol_; + + paused = paused_; + + INITIAL_CHAIN_ID = block.chainid; + + INITIAL_DOMAIN_SEPARATOR = _computeDomainSeparator(); + + // cannot realistically overflow on human timescales + unchecked { + for (uint256 i; i < voters_.length; i++) { + _mint(voters_[i], shares_[i]); + } + } + } + + /*/////////////////////////////////////////////////////////////// + ERC-20 LOGIC + //////////////////////////////////////////////////////////////*/ + + function approve(address spender, uint256 amount) public virtual returns (bool) { + allowance[msg.sender][spender] = amount; + + emit Approval(msg.sender, spender, amount); + + return true; + } + + function transfer(address to, uint256 amount) public virtual notPaused returns (bool) { + balanceOf[msg.sender] -= amount; + + // cannot overflow because the sum of all user + // balances can't exceed the max uint256 value + unchecked { + balanceOf[to] += amount; + } + + _moveDelegates(delegates(msg.sender), delegates(to), amount); + + emit Transfer(msg.sender, to, amount); + + return true; + } + + function transferFrom(address from, address to, uint256 amount) public virtual notPaused returns (bool) { + if (allowance[from][msg.sender] != type(uint256).max) { + allowance[from][msg.sender] -= amount; + } + + balanceOf[from] -= amount; + + // cannot overflow because the sum of all user + // balances can't exceed the max uint256 value + unchecked { + balanceOf[to] += amount; + } + + _moveDelegates(delegates(from), delegates(to), amount); + + emit Transfer(from, to, amount); + + return true; + } + + /*/////////////////////////////////////////////////////////////// + EIP-2612 LOGIC + //////////////////////////////////////////////////////////////*/ + + function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + public + virtual + { + if (block.timestamp > deadline) revert SignatureExpired(); + + // cannot realistically overflow on human timescales + unchecked { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR(), + keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)) + ) + ); + + address recoveredAddress = ecrecover(digest, v, r, s); + + if (recoveredAddress == address(0) || recoveredAddress != owner) revert InvalidSignature(); + + allowance[recoveredAddress][spender] = value; + } + + emit Approval(owner, spender, value); + } + + function DOMAIN_SEPARATOR() public view virtual returns (bytes32) { + return block.chainid == INITIAL_CHAIN_ID ? INITIAL_DOMAIN_SEPARATOR : _computeDomainSeparator(); + } + + function _computeDomainSeparator() internal view virtual returns (bytes32) { + return keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(name)), + keccak256("1"), + block.chainid, + address(this) + ) + ); + } + + /*/////////////////////////////////////////////////////////////// + DAO LOGIC + //////////////////////////////////////////////////////////////*/ + + modifier notPaused() { + if (paused) revert Paused(); + + _; + } + + function delegates(address delegator) public view virtual returns (address) { + address current = _delegates[delegator]; + + return current == address(0) ? delegator : current; + } + + function getCurrentVotes(address account) public view virtual returns (uint256) { + // this is safe from underflow because decrement only occurs if `nCheckpoints` is positive + unchecked { + uint256 nCheckpoints = numCheckpoints[account]; + + return nCheckpoints != 0 ? checkpoints[account][nCheckpoints - 1].votes : 0; + } + } + + function delegate(address delegatee) public virtual { + _delegate(msg.sender, delegatee); + } + + function delegateBySig(address delegatee, uint256 nonce, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + public + virtual + { + if (block.timestamp > deadline) revert SignatureExpired(); + + bytes32 structHash = keccak256(abi.encode(DELEGATION_TYPEHASH, delegatee, nonce, deadline)); + + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); + + address signatory = ecrecover(digest, v, r, s); + + if (signatory == address(0)) revert NullAddress(); + + // cannot realistically overflow on human timescales + unchecked { + if (nonce != nonces[signatory]++) revert InvalidNonce(); + } + + _delegate(signatory, delegatee); + } + + function getPriorVotes(address account, uint256 timestamp) public view virtual returns (uint96) { + if (block.timestamp <= timestamp) revert NotDetermined(); + + uint256 nCheckpoints = numCheckpoints[account]; + + if (nCheckpoints == 0) return 0; + + // this is safe from underflow because decrement only occurs if `nCheckpoints` is positive + unchecked { + if (checkpoints[account][nCheckpoints - 1].fromTimestamp <= timestamp) { + return checkpoints[account][nCheckpoints - 1].votes; + } + + if (checkpoints[account][0].fromTimestamp > timestamp) return 0; + + uint256 lower; + + // this is safe from underflow because decrement only occurs if `nCheckpoints` is positive + uint256 upper = nCheckpoints - 1; + + while (upper > lower) { + // this is safe from underflow because `upper` ceiling is provided + uint256 center = upper - (upper - lower) / 2; + + Checkpoint memory cp = checkpoints[account][center]; + + if (cp.fromTimestamp == timestamp) { + return cp.votes; + } else if (cp.fromTimestamp < timestamp) { + lower = center; + } else { + upper = center - 1; + } + } + + return checkpoints[account][lower].votes; + } + } + + function _delegate(address delegator, address delegatee) internal virtual { + address currentDelegate = delegates(delegator); + + _delegates[delegator] = delegatee; + + _moveDelegates(currentDelegate, delegatee, balanceOf[delegator]); + + emit DelegateChanged(delegator, currentDelegate, delegatee); + } + + function _moveDelegates(address srcRep, address dstRep, uint256 amount) internal virtual { + if (srcRep != dstRep && amount != 0) { + if (srcRep != address(0)) { + uint256 srcRepNum = numCheckpoints[srcRep]; + + uint256 srcRepOld = srcRepNum != 0 ? checkpoints[srcRep][srcRepNum - 1].votes : 0; + + uint256 srcRepNew = srcRepOld - amount; + + _writeCheckpoint(srcRep, srcRepNum, srcRepOld, srcRepNew); + } + } + + if (dstRep != address(0)) { + uint256 dstRepNum = numCheckpoints[dstRep]; + + uint256 dstRepOld = dstRepNum != 0 ? checkpoints[dstRep][dstRepNum - 1].votes : 0; + + uint256 dstRepNew = dstRepOld + amount; + + _writeCheckpoint(dstRep, dstRepNum, dstRepOld, dstRepNew); + } + } + + function _writeCheckpoint(address delegatee, uint256 nCheckpoints, uint256 oldVotes, uint256 newVotes) + internal + virtual + { + unchecked { + // this is safe from underflow because decrement only occurs if `nCheckpoints` is positive + if (nCheckpoints != 0 && checkpoints[delegatee][nCheckpoints - 1].fromTimestamp == block.timestamp) { + checkpoints[delegatee][nCheckpoints - 1].votes = _safeCastTo96(newVotes); + } else { + checkpoints[delegatee][nCheckpoints] = + Checkpoint(_safeCastTo32(block.timestamp), _safeCastTo96(newVotes)); + + // cannot realistically overflow on human timescales + numCheckpoints[delegatee] = nCheckpoints + 1; + } + } + + emit DelegateVotesChanged(delegatee, oldVotes, newVotes); + } + + /*/////////////////////////////////////////////////////////////// + MINT/BURN LOGIC + //////////////////////////////////////////////////////////////*/ + + function _mint(address to, uint256 amount) internal virtual { + totalSupply += amount; + + // cannot overflow because the sum of all user + // balances can't exceed the max uint256 value + unchecked { + balanceOf[to] += amount; + } + + _moveDelegates(address(0), delegates(to), amount); + + emit Transfer(address(0), to, amount); + } + + function _burn(address from, uint256 amount) internal virtual { + balanceOf[from] -= amount; + + // cannot underflow because a user's balance + // will never be larger than the total supply + unchecked { + totalSupply -= amount; + } + + _moveDelegates(delegates(from), address(0), amount); + + emit Transfer(from, address(0), amount); + } + + function burn(uint256 amount) public virtual { + _burn(msg.sender, amount); + } + + function burnFrom(address from, uint256 amount) public virtual { + if (allowance[from][msg.sender] != type(uint256).max) { + allowance[from][msg.sender] -= amount; + } + + _burn(from, amount); + } + + /*/////////////////////////////////////////////////////////////// + PAUSE LOGIC + //////////////////////////////////////////////////////////////*/ + + function _flipPause() internal virtual { + paused = !paused; + + emit PauseFlipped(paused); + } + + /*/////////////////////////////////////////////////////////////// + SAFECAST LOGIC + //////////////////////////////////////////////////////////////*/ + + function _safeCastTo32(uint256 x) internal pure virtual returns (uint32) { + if (x > type(uint32).max) revert Uint32max(); + + return uint32(x); + } + + function _safeCastTo96(uint256 x) internal pure virtual returns (uint96) { + if (x > type(uint96).max) revert Uint96max(); + + return uint96(x); + } +} + +/// @notice Helper utility that enables calling multiple local methods in a single call. +/// @author Modified from Uniswap (https://github.com/Uniswap/v3-periphery/blob/main/contracts/base/Multicall.sol) +abstract contract Multicall { + function multicall(bytes[] calldata data) public virtual returns (bytes[] memory results) { + results = new bytes[](data.length); + + // cannot realistically overflow on human timescales + unchecked { + for (uint256 i = 0; i < data.length; i++) { + (bool success, bytes memory result) = address(this).delegatecall(data[i]); + + if (!success) { + if (result.length < 68) revert(); + + assembly { + result := add(result, 0x04) + } + + revert(abi.decode(result, (string))); + } + results[i] = result; + } + } + } +} + +/// @notice Helper utility for NFT 'safe' transfers. +abstract contract NFThelper { + function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4 sig) { + sig = 0x150b7a02; // 'onERC721Received(address,address,uint256,bytes)' + } + + function onERC1155Received(address, address, uint256, uint256, bytes calldata) external pure returns (bytes4 sig) { + sig = 0xf23a6e61; // 'onERC1155Received(address,address,uint256,uint256,bytes)' + } + + function onERC1155BatchReceived(address, address, uint256[] calldata, uint256[] calldata, bytes calldata) + external + pure + returns (bytes4 sig) + { + sig = 0xbc197c81; // 'onERC1155BatchReceived(address,address,uint256[],uint256[],bytes)' + } +} + +/// @notice Gas-optimized reentrancy protection. +/// @author Modified from OpenZeppelin +/// (https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol) +/// License-Identifier: MIT +abstract contract ReentrancyGuard { + error Reentrancy(); + + uint256 private constant NOT_ENTERED = 1; + + uint256 private constant ENTERED = 2; + + uint256 private status = NOT_ENTERED; + + modifier nonReentrant() { + if (status == ENTERED) revert Reentrancy(); + + status = ENTERED; + + _; + + status = NOT_ENTERED; + } +} + +/// @notice Kali DAO membership extension interface. +interface IKaliDAOextension { + function setExtension(bytes calldata extensionData) external; + + function callExtension(address account, uint256 amount, bytes calldata extensionData) + external + payable + returns (bool mint, uint256 amountOut); +} + +/// @notice Simple gas-optimized Kali DAO core module. +contract KaliDAO is KaliDAOtoken, Multicall, NFThelper, ReentrancyGuard { + /*/////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event NewProposal( + address indexed proposer, + uint256 indexed proposal, + ProposalType indexed proposalType, + string description, + address[] accounts, + uint256[] amounts, + bytes[] payloads + ); + + event ProposalCancelled(address indexed proposer, uint256 indexed proposal); + + event ProposalSponsored(address indexed sponsor, uint256 indexed proposal); + + event VoteCast(address indexed voter, uint256 indexed proposal, bool indexed approve); + + event ProposalProcessed(uint256 indexed proposal, bool indexed didProposalPass); + + /*/////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + error Initialized(); + + error PeriodBounds(); + + error QuorumMax(); + + error SupermajorityBounds(); + + error InitCallFail(); + + error TypeBounds(); + + error NotProposer(); + + error Sponsored(); + + error NotMember(); + + error NotCurrentProposal(); + + error AlreadyVoted(); + + error NotVoteable(); + + error VotingNotEnded(); + + error PrevNotProcessed(); + + error NotExtension(); + + /*/////////////////////////////////////////////////////////////// + DAO STORAGE + //////////////////////////////////////////////////////////////*/ + + string public docs; + + uint256 private currentSponsoredProposal; + + uint256 public proposalCount; + + uint32 public votingPeriod; + + uint32 public gracePeriod; + + uint32 public quorum; // 1-100 + + uint32 public supermajority; // 1-100 + + bytes32 public constant VOTE_HASH = keccak256("SignVote(address signer,uint256 proposal,bool approve)"); + + mapping(address => bool) public extensions; + + mapping(uint256 => Proposal) public proposals; + + mapping(uint256 => ProposalState) public proposalStates; + + mapping(ProposalType => VoteType) public proposalVoteTypes; + + mapping(uint256 => mapping(address => bool)) public voted; + + mapping(address => uint256) public lastYesVote; + + enum ProposalType { + MINT, // add membership + BURN, // revoke membership + CALL, // call contracts + VPERIOD, // set `votingPeriod` + GPERIOD, // set `gracePeriod` + QUORUM, // set `quorum` + SUPERMAJORITY, // set `supermajority` + TYPE, // set `VoteType` to `ProposalType` + PAUSE, // flip membership transferability + EXTENSION, // flip `extensions` whitelisting + ESCAPE, // delete pending proposal in case of revert + DOCS // amend org docs + } + + enum VoteType { + SIMPLE_MAJORITY, + SIMPLE_MAJORITY_QUORUM_REQUIRED, + SUPERMAJORITY, + SUPERMAJORITY_QUORUM_REQUIRED + } + + struct Proposal { + ProposalType proposalType; + string description; + address[] accounts; // member(s) being added/kicked; account(s) receiving payload + uint256[] amounts; // value(s) to be minted/burned/spent; gov setting [0] + bytes[] payloads; // data for CALL proposals + uint256 prevProposal; + uint96 yesVotes; + uint96 noVotes; + uint32 creationTime; + address proposer; + } + + struct ProposalState { + bool passed; + bool processed; + } + + /*/////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + function init( + string memory name_, + string memory symbol_, + string memory docs_, + bool paused_, + address[] memory extensions_, + bytes[] memory extensionsData_, + address[] calldata voters_, + uint256[] calldata shares_, + uint32[16] memory govSettings_ + ) public virtual nonReentrant { + if (extensions_.length != extensionsData_.length) revert NoArrayParity(); + + if (votingPeriod != 0) revert Initialized(); + + if (govSettings_[0] == 0 || govSettings_[0] > 365 days) revert PeriodBounds(); + + if (govSettings_[1] > 365 days) revert PeriodBounds(); + + if (govSettings_[2] > 100) revert QuorumMax(); + + if (govSettings_[3] <= 51 || govSettings_[3] > 100) revert SupermajorityBounds(); + + KaliDAOtoken._init(name_, symbol_, paused_, voters_, shares_); + + if (extensions_.length != 0) { + // cannot realistically overflow on human timescales + unchecked { + for (uint256 i; i < extensions_.length; i++) { + extensions[extensions_[i]] = true; + + if (extensionsData_[i].length > 3) { + (bool success,) = extensions_[i].call(extensionsData_[i]); + + if (!success) revert InitCallFail(); + } + } + } + } + + docs = docs_; + + votingPeriod = govSettings_[0]; + + gracePeriod = govSettings_[1]; + + quorum = govSettings_[2]; + + supermajority = govSettings_[3]; + + // set initial vote types + proposalVoteTypes[ProposalType.MINT] = VoteType(govSettings_[4]); + + proposalVoteTypes[ProposalType.BURN] = VoteType(govSettings_[5]); + + proposalVoteTypes[ProposalType.CALL] = VoteType(govSettings_[6]); + + proposalVoteTypes[ProposalType.VPERIOD] = VoteType(govSettings_[7]); + + proposalVoteTypes[ProposalType.GPERIOD] = VoteType(govSettings_[8]); + + proposalVoteTypes[ProposalType.QUORUM] = VoteType(govSettings_[9]); + + proposalVoteTypes[ProposalType.SUPERMAJORITY] = VoteType(govSettings_[10]); + + proposalVoteTypes[ProposalType.TYPE] = VoteType(govSettings_[11]); + + proposalVoteTypes[ProposalType.PAUSE] = VoteType(govSettings_[12]); + + proposalVoteTypes[ProposalType.EXTENSION] = VoteType(govSettings_[13]); + + proposalVoteTypes[ProposalType.ESCAPE] = VoteType(govSettings_[14]); + + proposalVoteTypes[ProposalType.DOCS] = VoteType(govSettings_[15]); + } + + /*/////////////////////////////////////////////////////////////// + PROPOSAL LOGIC + //////////////////////////////////////////////////////////////*/ + + function getProposalArrays(uint256 proposal) + public + view + virtual + returns (address[] memory accounts, uint256[] memory amounts, bytes[] memory payloads) + { + Proposal storage prop = proposals[proposal]; + + (accounts, amounts, payloads) = (prop.accounts, prop.amounts, prop.payloads); + } + + function propose( + ProposalType proposalType, + string calldata description, + address[] calldata accounts, + uint256[] calldata amounts, + bytes[] calldata payloads + ) public virtual nonReentrant returns (uint256 proposal) { + if (accounts.length != amounts.length || amounts.length != payloads.length) revert NoArrayParity(); + + if (proposalType == ProposalType.VPERIOD) if (amounts[0] == 0 || amounts[0] > 365 days) revert PeriodBounds(); + + if (proposalType == ProposalType.GPERIOD) if (amounts[0] > 365 days) revert PeriodBounds(); + + if (proposalType == ProposalType.QUORUM) if (amounts[0] > 100) revert QuorumMax(); + + if (proposalType == ProposalType.SUPERMAJORITY) { + if (amounts[0] <= 51 || amounts[0] > 100) revert SupermajorityBounds(); + } + + if (proposalType == ProposalType.TYPE) { + if (amounts[0] > 11 || amounts[1] > 3 || amounts.length != 2) revert TypeBounds(); + } + + bool selfSponsor; + + // if member or extension is making proposal, include sponsorship + if (balanceOf[msg.sender] != 0 || extensions[msg.sender]) selfSponsor = true; + + // cannot realistically overflow on human timescales + unchecked { + proposalCount++; + } + + proposal = proposalCount; + + proposals[proposal] = Proposal({ + proposalType: proposalType, + description: description, + accounts: accounts, + amounts: amounts, + payloads: payloads, + prevProposal: selfSponsor ? currentSponsoredProposal : 0, + yesVotes: 0, + noVotes: 0, + creationTime: selfSponsor ? _safeCastTo32(block.timestamp) : 0, + proposer: msg.sender + }); + + if (selfSponsor) currentSponsoredProposal = proposal; + + emit NewProposal(msg.sender, proposal, proposalType, description, accounts, amounts, payloads); + } + + function cancelProposal(uint256 proposal) public virtual nonReentrant { + Proposal storage prop = proposals[proposal]; + + if (msg.sender != prop.proposer) revert NotProposer(); + + if (prop.creationTime != 0) revert Sponsored(); + + delete proposals[proposal]; + + emit ProposalCancelled(msg.sender, proposal); + } + + function sponsorProposal(uint256 proposal) public virtual nonReentrant { + Proposal storage prop = proposals[proposal]; + + if (balanceOf[msg.sender] == 0) revert NotMember(); + + if (prop.proposer == address(0)) revert NotCurrentProposal(); + + if (prop.creationTime != 0) revert Sponsored(); + + prop.prevProposal = currentSponsoredProposal; + + currentSponsoredProposal = proposal; + + prop.creationTime = _safeCastTo32(block.timestamp); + + emit ProposalSponsored(msg.sender, proposal); + } + + function vote(uint256 proposal, bool approve) public virtual nonReentrant { + _vote(msg.sender, proposal, approve); + } + + function voteBySig(address signer, uint256 proposal, bool approve, uint8 v, bytes32 r, bytes32 s) + public + virtual + nonReentrant + { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", DOMAIN_SEPARATOR(), keccak256(abi.encode(VOTE_HASH, signer, proposal, approve)) + ) + ); + + address recoveredAddress = ecrecover(digest, v, r, s); + + if (recoveredAddress == address(0) || recoveredAddress != signer) revert InvalidSignature(); + + _vote(signer, proposal, approve); + } + + function _vote(address signer, uint256 proposal, bool approve) internal virtual { + Proposal storage prop = proposals[proposal]; + + if (balanceOf[signer] == 0) revert NotMember(); + + if (voted[proposal][signer]) revert AlreadyVoted(); + + // this is safe from overflow because `votingPeriod` is capped so it will not combine + // with unix time to exceed the max uint256 value + unchecked { + if (block.timestamp > prop.creationTime + votingPeriod) revert NotVoteable(); + } + + uint96 weight = getPriorVotes(signer, prop.creationTime); + + // this is safe from overflow because `yesVotes` and `noVotes` are capped by `totalSupply` + // which is checked for overflow in `KaliDAOtoken` contract + unchecked { + if (approve) { + prop.yesVotes += weight; + + lastYesVote[signer] = proposal; + } else { + prop.noVotes += weight; + } + } + + voted[proposal][signer] = true; + + emit VoteCast(signer, proposal, approve); + } + + function processProposal(uint256 proposal) + public + virtual + nonReentrant + returns (bool didProposalPass, bytes[] memory results) + { + Proposal storage prop = proposals[proposal]; + + VoteType voteType = proposalVoteTypes[prop.proposalType]; + + if (prop.creationTime == 0) revert NotCurrentProposal(); + + // this is safe from overflow because `votingPeriod` and `gracePeriod` are capped so they will not combine + // with unix time to exceed the max uint256 value + unchecked { + if (block.timestamp <= prop.creationTime + votingPeriod + gracePeriod) revert VotingNotEnded(); + } + + // skip previous proposal processing requirement in case of escape hatch + if (prop.proposalType != ProposalType.ESCAPE) { + if (proposals[prop.prevProposal].creationTime != 0) revert PrevNotProcessed(); + } + + didProposalPass = _countVotes(voteType, prop.yesVotes, prop.noVotes); + + if (didProposalPass) { + // cannot realistically overflow on human timescales + unchecked { + if (prop.proposalType == ProposalType.MINT) { + for (uint256 i; i < prop.accounts.length; i++) { + _mint(prop.accounts[i], prop.amounts[i]); + } + } + + if (prop.proposalType == ProposalType.BURN) { + for (uint256 i; i < prop.accounts.length; i++) { + _burn(prop.accounts[i], prop.amounts[i]); + } + } + + if (prop.proposalType == ProposalType.CALL) { + for (uint256 i; i < prop.accounts.length; i++) { + results = new bytes[](prop.accounts.length); + + (, bytes memory result) = prop.accounts[i].call{value: prop.amounts[i]}(prop.payloads[i]); + + results[i] = result; + } + } + + // governance settings + if (prop.proposalType == ProposalType.VPERIOD) { + if (prop.amounts[0] != 0) votingPeriod = uint32(prop.amounts[0]); + } + + if (prop.proposalType == ProposalType.GPERIOD) { + if (prop.amounts[0] != 0) gracePeriod = uint32(prop.amounts[0]); + } + + if (prop.proposalType == ProposalType.QUORUM) { + if (prop.amounts[0] != 0) quorum = uint32(prop.amounts[0]); + } + + if (prop.proposalType == ProposalType.SUPERMAJORITY) { + if (prop.amounts[0] != 0) supermajority = uint32(prop.amounts[0]); + } + + if (prop.proposalType == ProposalType.TYPE) { + proposalVoteTypes[ProposalType(prop.amounts[0])] = VoteType(prop.amounts[1]); + } + + if (prop.proposalType == ProposalType.PAUSE) { + _flipPause(); + } + + if (prop.proposalType == ProposalType.EXTENSION) { + for (uint256 i; i < prop.accounts.length; i++) { + if (prop.amounts[i] != 0) { + extensions[prop.accounts[i]] = !extensions[prop.accounts[i]]; + } + + if (prop.payloads[i].length > 3) { + IKaliDAOextension(prop.accounts[i]).setExtension(prop.payloads[i]); + } + } + } + + if (prop.proposalType == ProposalType.ESCAPE) { + delete proposals[prop.amounts[0]]; + } + + if (prop.proposalType == ProposalType.DOCS) { + docs = prop.description; + } + + proposalStates[proposal].passed = true; + } + } + + delete proposals[proposal]; + + proposalStates[proposal].processed = true; + + emit ProposalProcessed(proposal, didProposalPass); + } + + function _countVotes(VoteType voteType, uint256 yesVotes, uint256 noVotes) + internal + view + virtual + returns (bool didProposalPass) + { + // fail proposal if no participation + if (yesVotes == 0 && noVotes == 0) return false; + + // rule out any failed quorums + if (voteType == VoteType.SIMPLE_MAJORITY_QUORUM_REQUIRED || voteType == VoteType.SUPERMAJORITY_QUORUM_REQUIRED) + { + uint256 minVotes = (totalSupply * quorum) / 100; + + // this is safe from overflow because `yesVotes` and `noVotes` + // supply are checked in `KaliDAOtoken` contract + unchecked { + uint256 votes = yesVotes + noVotes; + + if (votes < minVotes) return false; + } + } + + // simple majority check + if (voteType == VoteType.SIMPLE_MAJORITY || voteType == VoteType.SIMPLE_MAJORITY_QUORUM_REQUIRED) { + if (yesVotes > noVotes) return true; + // supermajority check + } else { + // example: 7 yes, 2 no, supermajority = 66 + // ((7+2) * 66) / 100 = 5.94; 7 yes will pass + uint256 minYes = ((yesVotes + noVotes) * supermajority) / 100; + + if (yesVotes >= minYes) return true; + } + } + + /*/////////////////////////////////////////////////////////////// + EXTENSIONS + //////////////////////////////////////////////////////////////*/ + + receive() external payable virtual {} + + modifier onlyExtension() { + if (!extensions[msg.sender]) revert NotExtension(); + + _; + } + + function callExtension(address extension, uint256 amount, bytes calldata extensionData) + public + payable + virtual + nonReentrant + returns (bool mint, uint256 amountOut) + { + if (!extensions[extension]) revert NotExtension(); + + (mint, amountOut) = + IKaliDAOextension(extension).callExtension{value: msg.value}(msg.sender, amount, extensionData); + + if (mint) { + if (amountOut != 0) _mint(msg.sender, amountOut); + } else { + if (amountOut != 0) _burn(msg.sender, amount); + } + } + + function mintShares(address to, uint256 amount) public virtual onlyExtension { + _mint(to, amount); + } + + function burnShares(address from, uint256 amount) public virtual onlyExtension { + _burn(from, amount); + } +} + +/// @notice Ricardian LLC formation interface. +interface IRicardianLLC { + function mintLLC(address to) external payable; +} + +/// @notice Factory to deploy Kali DAO. +contract KaliDAOfactory is Multicall { + event DAOdeployed( + KaliDAO indexed kaliDAO, + string name, + string symbol, + string docs, + bool paused, + address[] extensions, + bytes[] extensionsData, + address[] voters, + uint256[] shares, + uint32[16] govSettings + ); + + error NullDeploy(); + + address payable private immutable kaliMaster; + + IRicardianLLC private immutable ricardianLLC; + + constructor(address payable kaliMaster_, IRicardianLLC ricardianLLC_) { + kaliMaster = kaliMaster_; + + ricardianLLC = ricardianLLC_; + } + + function deployKaliDAO( + string memory name_, + string memory symbol_, + string memory docs_, + bool paused_, + address[] memory extensions_, + bytes[] memory extensionsData_, + address[] calldata voters_, + uint256[] calldata shares_, + uint32[16] memory govSettings_ + ) public payable virtual returns (KaliDAO kaliDAO) { + kaliDAO = KaliDAO(_cloneAsMinimalProxy(kaliMaster, name_)); + + kaliDAO.init(name_, symbol_, docs_, paused_, extensions_, extensionsData_, voters_, shares_, govSettings_); + + bytes memory docs = bytes(docs_); + + if (docs.length == 0) { + ricardianLLC.mintLLC{value: msg.value}(address(kaliDAO)); + } + + emit DAOdeployed( + kaliDAO, name_, symbol_, docs_, paused_, extensions_, extensionsData_, voters_, shares_, govSettings_ + ); + } + + /// @dev modified from Aelin (https://github.com/AelinXYZ/aelin/blob/main/contracts/MinimalProxyFactory.sol) + function _cloneAsMinimalProxy(address payable base, string memory name_) + internal + virtual + returns (address payable clone) + { + bytes memory createData = abi.encodePacked( + // constructor + bytes10(0x3d602d80600a3d3981f3), + // proxy code + bytes10(0x363d3d373d3d3d363d73), + base, + bytes15(0x5af43d82803e903d91602b57fd5bf3) + ); + + bytes32 salt = keccak256(bytes(name_)); + + assembly { + clone := + create2( + 0, // no value + add(createData, 0x20), // data + mload(createData), + salt + ) + } + // if CREATE2 fails for some reason, address(0) is returned + if (clone == address(0)) revert NullDeploy(); + } +} diff --git a/test/Greeter.t.sol b/test/Greeter.t.sol deleted file mode 100644 index fb23d3e..0000000 --- a/test/Greeter.t.sol +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.17; - -import "forge-std/Test.sol"; - -import {Greeter} from "src/Greeter.sol"; - -contract GreeterTest is Test { - using stdStorage for StdStorage; - - Greeter greeter; - - event GMEverybodyGM(); - - function setUp() external { - greeter = new Greeter("gm"); - } - - // VM Cheatcodes can be found in ./lib/forge-std/src/Vm.sol - // Or at https://github.com/foundry-rs/forge-std - function testSetGm() external { - // slither-disable-next-line reentrancy-events,reentrancy-benign - greeter.setGreeting("gm gm"); - - // Expect the GMEverybodyGM event to be fired - vm.expectEmit(true, true, true, true); - emit GMEverybodyGM(); - // slither-disable-next-line unused-return - greeter.gm("gm gm"); - - // Expect the gm() call to revert - vm.expectRevert(abi.encodeWithSignature("BadGm()")); - // slither-disable-next-line unused-return - greeter.gm("gm"); - - // We can read slots directly - uint256 slot = stdstore.target(address(greeter)).sig(greeter.owner.selector).find(); - assertEq(slot, 1); - bytes32 owner = vm.load(address(greeter), bytes32(slot)); - assertEq(address(this), address(uint160(uint256(owner)))); - } -} diff --git a/test/KaliBerger.t.sol b/test/KaliBerger.t.sol new file mode 100644 index 0000000..3a6276d --- /dev/null +++ b/test/KaliBerger.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.17; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; + +import {Storage} from "src/Storage.sol"; +import {IStorage} from "src/interface/IStorage.sol"; +import {KaliBerger} from "src/KaliBerger.sol"; + +contract KaliBergerTest is Test { + Storage stor; + KaliBerger kaliBerger; + + IStorage iStorage; + + /// @dev Users. + address public immutable alice = makeAddr("alice"); + address public immutable bob = makeAddr("bob"); + address public immutable charlie = makeAddr("charlie"); + address public immutable dummy = makeAddr("dummy"); + address payable public immutable arm0ry = payable(makeAddr("arm0ry")); + + /// @dev Helpers. + string internal constant description = "TEST"; + bytes32 internal constant name1 = 0x5445535400000000000000000000000000000000000000000000000000000000; + bytes32 internal constant name2 = 0x5445535432000000000000000000000000000000000000000000000000000000; + + /// ----------------------------------------------------------------------- + /// Kali Setup Tests + /// ----------------------------------------------------------------------- + + /// @notice Set up the testing suite. + + function setUp() public payable { + // Deploy contract + kaliBerger = new KaliBerger(); + // missions = new Missions(); + // missions.initialize((address(arm0ry))); + + // Validate global variables + // assertEq(missions.royalties(), 0); + // assertEq(missions_v2.dao(), arm0ry); + + // setupTasksAndMissions(); + } + + function testReceiveETH() public payable { + (bool sent,) = address(kaliBerger).call{value: 5 ether}(""); + assert(sent); + assert(address(kaliBerger).balance == 5 ether); + } +}