Skip to content

Commit

Permalink
add crowdsale with fees contract and unit tests for it (#141)
Browse files Browse the repository at this point in the history
* add crowdsale with fees contract and unit tests for it

* update the crowdsale to accept fees and update tests

* log feeCrowdSale address

* drops constructor args
renames fee member
rolls back deployment order
separates fixture scripts

Signed-off-by: stadolf <[email protected]>

* dedicated fee tests
removes unnecessary fee size check
tightens fee rules
some dust test

Signed-off-by: stadolf <[email protected]>

* remove unused variable

* deploy plain crowdsale and update addresses

* remove unused file

---------

Signed-off-by: stadolf <[email protected]>
Co-authored-by: stadolf <[email protected]>
  • Loading branch information
nour-karoui and elmariachi111 authored Oct 26, 2023
1 parent d071498 commit a62eb6e
Show file tree
Hide file tree
Showing 10 changed files with 279 additions and 62 deletions.
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

0 comments on commit a62eb6e

Please sign in to comment.