Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add crowdsale with fees contract and unit tests for it #141

Merged
merged 9 commits into from
Oct 26, 2023
Merged
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ STAKED_LOCKING_CROWDSALE_ADDRESS=0x9A676e781A523b5d0C0e43731313A708CB607508
USDC6_ADDRESS=0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE
WETH_ADDRESS=0x59b670e9fA9D0A427751Af201D676719a970857b

PLAIN_CROWDSALE_ADDRESS=0x4A679253410272dd5232B3Ff7cF5dbB88f295319

#these are generated when running the fixture scripts
IPTS_ADDRESS=0x1F708C24a0D3A740cD47cC0444E9480899f3dA7D
LOCKED_IPTS_ADDRESS=0x06cd7788D77332cF1156f1E327eBC090B5FF16a3


LOCKED_IPTS_ADDRESS=0x06cd7788D77332cF1156f1E327eBC090B5FF16a3
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Defender Relayer that signs off minting requests from our side:
| SchmackoSwap | [0x67D8ed102E2168A46FA342e39A5f7D16c103Bd0d](https://goerli.etherscan.io/address/0x67D8ed102E2168A46FA342e39A5f7D16c103Bd0d#code) | <a href="https://thirdweb.com/goerli/0x67D8ed102E2168A46FA342e39A5f7D16c103Bd0d?utm_source=contract_badge" target="_blank"><img width="200" height="45" src="https://badges.thirdweb.com/contract?address=0x67D8ed102E2168A46FA342e39A5f7D16c103Bd0d&theme=dark&chainId=5" alt="View contract" /></a> |
| Tokenizer | [0xb12494eeA6B992d0A1Db3C5423BE7a2d2337F58c](https://goerli.etherscan.io/address/0xb12494eeA6B992d0A1Db3C5423BE7a2d2337F58c#code) | <a href="https://thirdweb.com/goerli/0xb12494eeA6B992d0A1Db3C5423BE7a2d2337F58c?utm_source=contract_badge" target="_blank"><img width="200" height="45" src="https://badges.thirdweb.com/contract?address=0xb12494eeA6B992d0A1Db3C5423BE7a2d2337F58c&theme=dark&chainId=5" alt="View contract" /></a> |
| Permissioner | [0xd735d9504cce32F2cd665b779D699B4157686fcd](https://goerli.etherscan.io/address/0xd735d9504cce32F2cd665b779D699B4157686fcd#code) | <a href="https://thirdweb.com/goerli/0xd735d9504cce32F2cd665b779D699B4157686fcd?utm_source=contract_badge" target="_blank"><img width="200" height="45" src="https://badges.thirdweb.com/contract?address=0xd735d9504cce32F2cd665b779D699B4157686fcd&theme=dark&chainId=5" alt="View contract" /></a> |
| Crowdsale | [0x8c83DA72b4591bE526ca8C7cb848bC89c0e23373](https://goerli.etherscan.io/address/0x8c83DA72b4591bE526ca8C7cb848bC89c0e23373#code>) | <a href="https://thirdweb.com/goerli/0x8c83DA72b4591bE526ca8C7cb848bC89c0e23373?utm_source=contract_badge" target="_blank"><img width="200" height="45" src="https://badges.thirdweb.com/contract?address=0x8c83DA72b4591bE526ca8C7cb848bC89c0e23373&theme=dark&chainId=5" alt="View contract" /></a> |
| StakedLockingCrowdSale | [0x46c3369dece07176ad7164906d3593aa4c126d35](https://goerli.etherscan.io/address/0x46c3369dece07176ad7164906d3593aa4c126d35#code) | <a href="https://thirdweb.com/goerli/0x46c3369dece07176ad7164906d3593aa4c126d35?utm_source=contract_badge" target="_blank"><img width="200" height="45" src="https://badges.thirdweb.com/contract?address=0x46c3369dece07176ad7164906d3593aa4c126d35&theme=dark&chainId=5" alt="View contract" /></a> |
| SignedMintAuthorizer | [0x5e555eE24DB66825171Ac63EA614864987CEf1Af](https://goerli.etherscan.io/address/0x5e555eE24DB66825171Ac63EA614864987CEf1Af#code) | <a href="https://thirdweb.com/goerli/0x5e555eE24DB66825171Ac63EA614864987CEf1Af?utm_source=contract_badge" target="_blank"><img width="200" height="45" src="https://badges.thirdweb.com/contract?address=0x5e555eE24DB66825171Ac63EA614864987CEf1Af&theme=dark&chainId=5" alt="View contract" /></a> |

Expand Down
126 changes: 84 additions & 42 deletions script/dev/CrowdSale.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -20,130 +20,172 @@ import { IPToken } from "../../src/IPToken.sol";

import { CommonScript } from "./Common.sol";

contract DeployCrowdSale is CommonScript {
function run() public {
prepareAddresses();
vm.startBroadcast(deployer);
CrowdSale crowdSale = new CrowdSale();
crowdSale.setCurrentFeesBp(1000);

console.log("PLAIN_CROWDSALE_ADDRESS=%s", address(crowdSale));
}
}

/**
* @title deploy crowdSale
* @author
*/
contract DeployCrowdSale is CommonScript {
contract DeployStakedCrowdSale is CommonScript {
function run() public {
prepareAddresses();
vm.startBroadcast(deployer);
StakedLockingCrowdSale stakedLockingCrowdSale = new StakedLockingCrowdSale();

TokenVesting vestedDaoToken = TokenVesting(vm.envAddress("VDAO_TOKEN_ADDRESS"));
vestedDaoToken.grantRole(vestedDaoToken.ROLE_CREATE_SCHEDULE(), address(stakedLockingCrowdSale));
stakedLockingCrowdSale.trustVestingContract(vestedDaoToken);
vm.stopBroadcast();

//console.log("vested molecules Token %s", address(vestedMolToken));
console.log("STAKED_LOCKING_CROWDSALE_ADDRESS=%s", address(stakedLockingCrowdSale));
}
}

/**
* @notice execute Ipnft.s.sol && Fixture.s.sol && Tokenizer.s.sol first
* @notice assumes that bob (hh1) owns IPNFT#1 and has synthesized it
*/
contract FixtureCrowdSale is CommonScript {
FakeERC20 internal usdc;

FakeERC20 daoToken;
TokenVesting vestedDaoToken;

IPToken internal auctionToken;

StakedLockingCrowdSale stakedLockingCrowdSale;
CrowdSale crowdSale;
TermsAcceptedPermissioner permissioner;

function prepareAddresses() internal override {
function prepareAddresses() internal virtual override {
super.prepareAddresses();

usdc = FakeERC20(vm.envAddress("USDC_ADDRESS"));

daoToken = FakeERC20(vm.envAddress("DAO_TOKEN_ADDRESS"));
vestedDaoToken = TokenVesting(vm.envAddress("VDAO_TOKEN_ADDRESS"));
auctionToken = IPToken(vm.envAddress("IPTS_ADDRESS"));

stakedLockingCrowdSale = StakedLockingCrowdSale(vm.envAddress("STAKED_LOCKING_CROWDSALE_ADDRESS"));
permissioner = TermsAcceptedPermissioner(vm.envAddress("TERMS_ACCEPTED_PERMISSIONER_ADDRESS"));
}

function setupVestedMolToken() internal {
vm.startBroadcast(deployer);
auctionToken = IPToken(vm.envAddress("IPTS_ADDRESS"));

vestedDaoToken.grantRole(vestedDaoToken.ROLE_CREATE_SCHEDULE(), address(stakedLockingCrowdSale));
vm.stopBroadcast();
crowdSale = CrowdSale(vm.envAddress("PLAIN_CROWDSALE_ADDRESS"));
permissioner = TermsAcceptedPermissioner(vm.envAddress("TERMS_ACCEPTED_PERMISSIONER_ADDRESS"));
}

function placeBid(address bidder, uint256 amount, uint256 saleId, bytes memory permission) internal {
vm.startBroadcast(bidder);
usdc.approve(address(stakedLockingCrowdSale), amount);
daoToken.approve(address(stakedLockingCrowdSale), amount);
stakedLockingCrowdSale.placeBid(saleId, amount, permission);
usdc.approve(address(crowdSale), amount);
daoToken.approve(address(crowdSale), amount);
crowdSale.placeBid(saleId, amount, permission);
vm.stopBroadcast();
}

function run() public virtual {
prepareAddresses();

setupVestedMolToken();

function prepareRun() internal virtual returns (Sale memory _sale) {
// Deal Charlie ERC20 tokens to bid in crowdsale
dealERC20(alice, 1200 ether, usdc);
dealERC20(charlie, 400 ether, usdc);

// Deal Alice and Charlie DAO tokens to stake in crowdsale
dealERC20(alice, 1200 ether, daoToken);
dealERC20(charlie, 400 ether, daoToken);

Sale memory _sale = Sale({
_sale = Sale({
auctionToken: IERC20Metadata(address(auctionToken)),
biddingToken: IERC20Metadata(address(usdc)),
beneficiary: bob,
fundingGoal: 200 ether,
salesAmount: 400 ether,
closingTime: uint64(block.timestamp + 15),
closingTime: uint64(block.timestamp + 10),
permissioner: permissioner
});

vm.startBroadcast(bob);
auctionToken.approve(address(crowdSale), 400 ether);
vm.stopBroadcast();
}

auctionToken.approve(address(stakedLockingCrowdSale), 400 ether);
uint256 saleId = stakedLockingCrowdSale.startSale(_sale, daoToken, vestedDaoToken, 1e18, 7 days);
TimelockedToken lockedIpt = stakedLockingCrowdSale.lockingContracts(address(auctionToken));
function startSale() internal virtual returns (uint256 saleId) {
Sale memory _sale = prepareRun();
vm.startBroadcast(bob);
saleId = crowdSale.startSale(_sale);
vm.stopBroadcast();
}

function afterRun(uint256 saleId) internal virtual {
console.log("SALE_ID=%s", saleId);
vm.writeFile("SALEID.txt", Strings.toString(saleId));
}

function run() public virtual {
prepareAddresses();

uint256 saleId = startSale();

string memory terms = permissioner.specificTermsV1(auctionToken);

(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, ECDSA.toEthSignedMessageHash(abi.encodePacked(terms)));
placeBid(alice, 600 ether, saleId, abi.encodePacked(r, s, v));
(v, r, s) = vm.sign(charliePk, ECDSA.toEthSignedMessageHash(abi.encodePacked(terms)));
placeBid(charlie, 200 ether, saleId, abi.encodePacked(r, s, v));

afterRun(saleId);
}
}
/**
* @notice execute Ipnft.s.sol && Fixture.s.sol && Tokenizer.s.sol first
* @notice assumes that bob (hh1) owns IPNFT#1 and has synthesized it
*/

contract FixtureStakedCrowdSale is FixtureCrowdSale {
StakedLockingCrowdSale _slCrowdSale;
TokenVesting vestedDaoToken;

function prepareAddresses() internal override {
super.prepareAddresses();
vestedDaoToken = TokenVesting(vm.envAddress("VDAO_TOKEN_ADDRESS"));

_slCrowdSale = StakedLockingCrowdSale(vm.envAddress("STAKED_LOCKING_CROWDSALE_ADDRESS"));
crowdSale = _slCrowdSale;
}

function prepareRun() internal virtual override returns (Sale memory _sale) {
_sale = super.prepareRun();
dealERC20(alice, 1200 ether, daoToken);
dealERC20(charlie, 400 ether, daoToken);
}

function startSale() internal override returns (uint256 saleId) {
Sale memory _sale = prepareRun();
vm.startBroadcast(bob);
saleId = _slCrowdSale.startSale(_sale, daoToken, vestedDaoToken, 1e18, 7 days);
vm.stopBroadcast();
}

function afterRun(uint256 saleId) internal virtual override {
super.afterRun(saleId);

TimelockedToken lockedIpt = _slCrowdSale.lockingContracts(address(auctionToken));
console.log("LOCKED_IPTS_ADDRESS=%s", address(lockedIpt));
console.log("SALE_ID=%s", saleId);
vm.writeFile("SALEID.txt", Strings.toString(saleId));
}
}

contract ClaimSale is CommonScript {
function run() public {
prepareAddresses();
CrowdSale crowdSale = CrowdSale(vm.envAddress("CROWDSALE"));
TermsAcceptedPermissioner permissioner = TermsAcceptedPermissioner(vm.envAddress("TERMS_ACCEPTED_PERMISSIONER_ADDRESS"));
StakedLockingCrowdSale stakedLockingCrowdSale = StakedLockingCrowdSale(vm.envAddress("STAKED_LOCKING_CROWDSALE_ADDRESS"));

IPToken auctionToken = IPToken(vm.envAddress("IPTS_ADDRESS"));
uint256 saleId = SLib.stringToUint(vm.readFile("SALEID.txt"));
vm.removeFile("SALEID.txt");

vm.startBroadcast(anyone);
stakedLockingCrowdSale.settle(saleId);
stakedLockingCrowdSale.claimResults(saleId);
crowdSale.settle(saleId);
crowdSale.claimResults(saleId);
vm.stopBroadcast();

string memory terms = permissioner.specificTermsV1(auctionToken);

(uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, ECDSA.toEthSignedMessageHash(abi.encodePacked(terms)));
vm.startBroadcast(alice);
stakedLockingCrowdSale.claim(saleId, abi.encodePacked(r, s, v));
crowdSale.claim(saleId, abi.encodePacked(r, s, v));
vm.stopBroadcast();

//we don't let charlie claim so we can test upgrades
Expand Down
14 changes: 10 additions & 4 deletions setupLocal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,26 @@ $FSC script/dev/Ipnft.s.sol:DeployIpnftSuite
$FSC script/dev/Tokens.s.sol:DeployTokens
$FSC script/dev/Periphery.s.sol
$FSC script/dev/Tokenizer.s.sol:DeployTokenizer
$FSC script/dev/CrowdSale.s.sol:DeployStakedCrowdSale
$FSC script/dev/Tokens.s.sol:DeployFakeTokens
$FSC script/dev/CrowdSale.s.sol:DeployCrowdSale
$FSC script/dev/Tokens.s.sol:DeployFakeTokens

# optionally: fixtures
if [ "$fixture" -eq "1" ]; then
echo "Running fixture scripts."

$FSC script/dev/Ipnft.s.sol:FixtureIpnft
$FSC script/dev/Tokenizer.s.sol:FixtureTokenizer

$FSC script/dev/CrowdSale.s.sol:FixtureCrowdSale

echo "Waiting 15 seconds until claiming sale..."
echo "Waiting 15 seconds until claiming plain sale..."
sleep 16
cast rpc evm_mine
CROWDSALE=$PLAIN_CROWDSALE_ADDRESS $FSC script/dev/CrowdSale.s.sol:ClaimSale

$FSC script/dev/CrowdSale.s.sol:ClaimSale
$FSC script/dev/CrowdSale.s.sol:FixtureStakedCrowdSale
echo "Waiting 15 seconds until claiming staked sale..."
sleep 16
cast rpc evm_mine
CROWDSALE=$STAKED_LOCKING_CROWDSALE_ADDRESS $FSC script/dev/CrowdSale.s.sol:ClaimSale
fi
43 changes: 37 additions & 6 deletions src/crowdsale/CrowdSale.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pragma solidity 0.8.18;
import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import { ReentrancyGuard } from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
import { FixedPointMathLib } from "solmate/utils/FixedPointMathLib.sol";
import { IPermissioner } from "../Permissioner.sol";
import { IPToken } from "../IPToken.sol";
Expand Down Expand Up @@ -34,6 +35,7 @@ struct SaleInfo {
uint256 total;
uint256 surplus;
bool claimed;
uint16 feeBp;
}

error BadDecimals();
Expand All @@ -47,13 +49,14 @@ error SaleNotFund(uint256);
error SaleNotConcluded();
error BadSaleState(SaleState expected, SaleState actual);
error AlreadyClaimed();
error FeesTooHigh();

/**
* @title CrowdSale
* @author molecule.to
* @notice a fixed price sales base contract
*/
contract CrowdSale is ReentrancyGuard {
contract CrowdSale is ReentrancyGuard, Ownable {
using SafeERC20 for IERC20Metadata;
using FixedPointMathLib for uint256;

Expand All @@ -62,7 +65,12 @@ contract CrowdSale is ReentrancyGuard {

mapping(uint256 => mapping(address => uint256)) internal _contributions;

event Started(uint256 indexed saleId, address indexed issuer, Sale sale);
/**
* @notice currently configured fee cut expressed in basis points (1/10_000)
*/
uint16 public currentFeeBp = 0;

event Started(uint256 indexed saleId, address indexed issuer, Sale sale, uint16 percentageFee);
event Settled(uint256 indexed saleId, uint256 totalBids, uint256 surplus);
/// @notice emitted when participants of the sale claim their tokens
event Claimed(uint256 indexed saleId, address indexed claimer, uint256 claimed, uint256 refunded);
Expand All @@ -75,6 +83,22 @@ contract CrowdSale is ReentrancyGuard {
/// @notice emitted when sales owner / beneficiary claims `salesAmount` `auctionTokens` after a non successful sale
event ClaimedAuctionTokens(uint256 indexed saleId);

event FeesUpdated(uint256 feeBp);

constructor() Ownable() { }

/**
* @notice This will only affect future auctions
* @param newFeeBp uint16 the new fee in basis points. Must be <= 50%
*/
function setCurrentFeesBp(uint16 newFeeBp) public onlyOwner {
if (newFeeBp > 5000) {
revert FeesTooHigh();
}
emit FeesUpdated(newFeeBp);
currentFeeBp = newFeeBp;
}

/**
* @notice bidding tokens can have arbitrary decimals, auctionTokens must be 18 decimals
* if no beneficiary is provided, the beneficiary will be set to msg.sender
Expand Down Expand Up @@ -103,7 +127,7 @@ contract CrowdSale is ReentrancyGuard {
}

_sales[saleId] = sale;
_saleInfo[saleId] = SaleInfo(SaleState.RUNNING, 0, 0, false);
_saleInfo[saleId] = SaleInfo(SaleState.RUNNING, 0, 0, false, currentFeeBp);

sale.auctionToken.safeTransferFrom(msg.sender, address(this), sale.salesAmount);
_afterSaleStarted(saleId);
Expand Down Expand Up @@ -189,7 +213,7 @@ contract CrowdSale is ReentrancyGuard {
* this is callable by anonye
* @param saleId the sale id
*/
function claimResults(uint256 saleId) external virtual {
function claimResults(uint256 saleId) external {
SaleInfo storage saleInfo = _saleInfo[saleId];
if (saleInfo.claimed) {
revert AlreadyClaimed();
Expand All @@ -198,9 +222,16 @@ contract CrowdSale is ReentrancyGuard {

Sale storage sale = _sales[saleId];
if (saleInfo.state == SaleState.SETTLED) {
uint256 claimableAmount = sale.fundingGoal;
if (saleInfo.feeBp > 0) {
uint256 saleFees = (saleInfo.feeBp * sale.fundingGoal) / 10_000;
claimableAmount -= saleFees;
sale.biddingToken.safeTransfer(owner(), saleFees);
}

//transfer funds to issuer / beneficiary
emit ClaimedFundingGoal(saleId);
sale.biddingToken.safeTransfer(sale.beneficiary, sale.fundingGoal);
sale.biddingToken.safeTransfer(sale.beneficiary, claimableAmount);
} else if (saleInfo.state == SaleState.FAILED) {
//return auction tokens
emit ClaimedAuctionTokens(saleId);
Expand Down Expand Up @@ -319,6 +350,6 @@ contract CrowdSale is ReentrancyGuard {
* @dev allows us to emit different events per derived contract
*/
function _afterSaleStarted(uint256 saleId) internal virtual {
emit Started(saleId, msg.sender, _sales[saleId]);
emit Started(saleId, msg.sender, _sales[saleId], _saleInfo[saleId].feeBp);
}
}
4 changes: 1 addition & 3 deletions src/crowdsale/StakedLockingCrowdSale.sol
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ error BadPrice();
* @notice a fixed price sales contract that locks the sold tokens in a configured locking contract and requires vesting another ("dao") token for a certain period of time to participate
* @dev see https://github.com/moleculeprotocol/IPNFT
*/
contract StakedLockingCrowdSale is LockingCrowdSale, Ownable {
contract StakedLockingCrowdSale is LockingCrowdSale {
using SafeERC20 for IERC20Metadata;
using FixedPointMathLib for uint256;

Expand All @@ -56,8 +56,6 @@ contract StakedLockingCrowdSale is LockingCrowdSale, Ownable {
revert UnsupportedInitializer();
}

constructor() Ownable() { }

/**
* [H-01]
* @notice this contract can only vest stakes for contracts that it knows so unknown actors cannot start crowdsales with malicious contracts
Expand Down
Loading