diff --git a/README.md b/README.md index 85dafd8..1add3ee 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Even the token contract can be migrated, if necessary, by deploying a new contra - Compute a merkle root by computing a list of amounts and recipients, hashing them, and arranging them into a merkle binary tree - Deploy the airdrop with the root and the token address - Transfer the total amount of tokens to the `Airdrop` contract +- `Factory` allows creating the entire set of contracts with one call ## Testing diff --git a/src/airdrop.cairo b/src/airdrop.cairo index 00d12c2..dfcc58c 100644 --- a/src/airdrop.cairo +++ b/src/airdrop.cairo @@ -1,3 +1,4 @@ +use governance::interfaces::erc20::{IERC20Dispatcher}; use starknet::{ContractAddress}; use array::{Array}; @@ -11,12 +12,21 @@ struct Claim { trait IAirdrop { // Claims the given allotment of tokens fn claim(ref self: TStorage, claim: Claim, proof: Array::); + + // Return the root of the airdrop + fn get_root(self: @TStorage) -> felt252; + + // Return the token being dropped + fn get_token(self: @TStorage) -> IERC20Dispatcher; + + // Return whether the claim has been claimed (always false for invalid claims) + fn is_claimed(self: @TStorage, claim: Claim) -> bool; } #[starknet::contract] mod Airdrop { - use super::{IAirdrop, ContractAddress, Claim}; - use governance::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; + use super::{IAirdrop, ContractAddress, Claim, IERC20Dispatcher}; + use governance::interfaces::erc20::{IERC20DispatcherTrait}; use hash::{LegacyHash}; use array::{ArrayTrait, SpanTrait}; use starknet::{ContractAddressIntoFelt252}; @@ -81,5 +91,17 @@ mod Airdrop { self.emit(Claimed { claim }); } + + fn get_root(self: @ContractState) -> felt252 { + self.root.read() + } + + fn get_token(self: @ContractState) -> IERC20Dispatcher { + self.token.read() + } + + fn is_claimed(self: @ContractState, claim: Claim) -> bool { + self.claimed.read(LegacyHash::hash(0, claim)) + } } } diff --git a/src/factory.cairo b/src/factory.cairo new file mode 100644 index 0000000..e90a44c --- /dev/null +++ b/src/factory.cairo @@ -0,0 +1,156 @@ +use starknet::{ContractAddress}; +use governance::governor::{Config as GovernorConfig}; +use governance::governor::{IGovernorDispatcher}; +use governance::governance_token::{IGovernanceTokenDispatcher}; +use governance::airdrop::{IAirdropDispatcher}; +use governance::timelock::{ITimelockDispatcher}; + +#[derive(Copy, Drop, Serde)] +struct AirdropConfig { + root: felt252, + total: u128, +} + +#[derive(Copy, Drop, Serde)] +struct TimelockConfig { + delay: u64, + window: u64, +} + +#[derive(Copy, Drop, Serde)] +struct DeploymentParameters { + name: felt252, + symbol: felt252, + total_supply: u128, + governor_config: GovernorConfig, + timelock_config: TimelockConfig, + airdrop_config: Option, +} + +#[derive(Copy, Drop, Serde)] +struct DeploymentResult { + token: IGovernanceTokenDispatcher, + governor: IGovernorDispatcher, + timelock: ITimelockDispatcher, + airdrop: Option, +} + +// This contract makes it easy to deploy a set of governance contracts from a block explorer just by specifying parameters +#[starknet::interface] +trait IFactory { + fn deploy(self: @TContractState, params: DeploymentParameters) -> DeploymentResult; +} + +#[starknet::contract] +mod Factory { + use super::{ + IFactory, DeploymentParameters, DeploymentResult, ContractAddress, + IGovernanceTokenDispatcher, IAirdropDispatcher, IGovernorDispatcher, ITimelockDispatcher + }; + use core::result::{ResultTrait}; + use starknet::{ClassHash, deploy_syscall, get_caller_address}; + use governance::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; + + #[storage] + struct Storage { + governance_token: ClassHash, + airdrop: ClassHash, + governor: ClassHash, + timelock: ClassHash, + } + + #[constructor] + fn constructor( + ref self: ContractState, + governance_token: ClassHash, + airdrop: ClassHash, + governor: ClassHash, + timelock: ClassHash + ) { + self.governance_token.write(governance_token); + self.airdrop.write(airdrop); + self.governor.write(governor); + self.timelock.write(timelock); + } + + #[external(v0)] + impl FactoryImpl of IFactory { + fn deploy(self: @ContractState, params: DeploymentParameters) -> DeploymentResult { + let mut token_constructor_args: Array = ArrayTrait::new(); + Serde::serialize( + @(params.name, params.symbol, params.total_supply), ref token_constructor_args + ); + + let (token_address, _) = deploy_syscall( + class_hash: self.governance_token.read(), + contract_address_salt: 0, + calldata: token_constructor_args.span(), + deploy_from_zero: false, + ) + .unwrap(); + + let erc20 = IERC20Dispatcher { contract_address: token_address }; + + let mut governor_constructor_args: Array = ArrayTrait::new(); + Serde::serialize( + @(token_address, params.governor_config), ref governor_constructor_args + ); + + let (governor_address, _) = deploy_syscall( + class_hash: self.governor.read(), + contract_address_salt: 0, + calldata: governor_constructor_args.span(), + deploy_from_zero: false, + ) + .unwrap(); + + let (airdrop, remaining_amount) = match params.airdrop_config { + Option::Some(config) => { + let mut airdrop_constructor_args: Array = ArrayTrait::new(); + Serde::serialize(@(token_address, config.root), ref airdrop_constructor_args); + + let (airdrop_address, _) = deploy_syscall( + class_hash: self.airdrop.read(), + contract_address_salt: 0, + calldata: airdrop_constructor_args.span(), + deploy_from_zero: false, + ) + .unwrap(); + + assert(config.total <= params.total_supply, 'AIRDROP_GT_SUPPLY'); + + erc20.transfer(airdrop_address, config.total.into()); + + ( + Option::Some(IAirdropDispatcher { contract_address: airdrop_address }), + params.total_supply - config.total + ) + }, + Option::None => { (Option::None, params.total_supply) } + }; + + erc20.transfer(get_caller_address(), remaining_amount.into()); + + let mut timelock_constructor_args: Array = ArrayTrait::new(); + Serde::serialize( + @(governor_address, params.timelock_config.delay, params.timelock_config.window), + ref timelock_constructor_args + ); + + let (timelock_address, _) = deploy_syscall( + class_hash: self.timelock.read(), + contract_address_salt: 0, + calldata: timelock_constructor_args.span(), + deploy_from_zero: false, + ) + .unwrap(); + + DeploymentResult { + token: IGovernanceTokenDispatcher { contract_address: token_address }, + airdrop, + governor: IGovernorDispatcher { contract_address: governor_address }, + timelock: ITimelockDispatcher { contract_address: timelock_address } + } + } + } +} diff --git a/src/factory_test.cairo b/src/factory_test.cairo new file mode 100644 index 0000000..7380e23 --- /dev/null +++ b/src/factory_test.cairo @@ -0,0 +1,104 @@ +use array::{ArrayTrait}; +use debug::PrintTrait; +use governance::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; +use governance::governor::{Config as GovernorConfig}; +use governance::factory::{ + IFactoryDispatcher, IFactoryDispatcherTrait, Factory, DeploymentParameters, AirdropConfig, + TimelockConfig, +}; +use governance::governance_token::{GovernanceToken}; +use governance::governor::{Governor}; +use governance::timelock::{Timelock}; +use governance::airdrop::{Airdrop}; +use starknet::{ + get_contract_address, deploy_syscall, ClassHash, contract_address_const, ContractAddress, +}; +use starknet::class_hash::{Felt252TryIntoClassHash}; +use starknet::testing::{set_contract_address, set_block_timestamp, pop_log}; +use traits::{TryInto}; + +use governance::governor::{IGovernorDispatcherTrait}; +use governance::governance_token::{IGovernanceTokenDispatcherTrait}; +use governance::airdrop::{IAirdropDispatcherTrait}; +use governance::timelock::{ITimelockDispatcherTrait}; + +use result::{Result, ResultTrait}; +use option::{OptionTrait}; + +fn deploy() -> IFactoryDispatcher { + let mut constructor_args: Array = ArrayTrait::new(); + Serde::serialize( + @( + GovernanceToken::TEST_CLASS_HASH, + Airdrop::TEST_CLASS_HASH, + Governor::TEST_CLASS_HASH, + Timelock::TEST_CLASS_HASH + ), + ref constructor_args + ); + + let (address, _) = deploy_syscall( + class_hash: Factory::TEST_CLASS_HASH.try_into().unwrap(), + contract_address_salt: 0, + calldata: constructor_args.span(), + deploy_from_zero: true + ) + .expect('DEPLOY_FAILED'); + return IFactoryDispatcher { contract_address: address }; +} + + +#[test] +#[available_gas(30000000)] +fn test_deploy() { + let factory = deploy(); + + let result = factory + .deploy( + DeploymentParameters { + name: 'token', + symbol: 'tk', + total_supply: 5678, + airdrop_config: Option::Some(AirdropConfig { root: 'root', total: 1111 }), + governor_config: GovernorConfig { + voting_start_delay: 0, + voting_period: 180, + voting_weight_smoothing_duration: 30, + quorum: 1000, + proposal_creation_threshold: 100, + }, + timelock_config: TimelockConfig { delay: 320, window: 60, } + } + ); + + let erc20 = IERC20Dispatcher { contract_address: result.token.contract_address }; + + assert(erc20.name() == 'token', 'name'); + assert(erc20.symbol() == 'tk', 'symbol'); + assert(erc20.decimals() == 18, 'decimals'); + assert(erc20.totalSupply() == 5678, 'totalSupply'); + assert(erc20.balance_of(get_contract_address()) == 5678 - 1111, 'deployer balance'); + assert(erc20.balance_of(result.airdrop.unwrap().contract_address) == 1111, 'airdrop balance'); + + let drop = result.airdrop.unwrap(); + assert(drop.get_root() == 'root', 'airdrop root'); + assert(drop.get_token().contract_address == result.token.contract_address, 'airdrop token'); + + assert( + result.governor.get_voting_token().contract_address == result.token.contract_address, + 'voting_token' + ); + assert( + result + .governor + .get_config() == GovernorConfig { + voting_start_delay: 0, + voting_period: 180, + voting_weight_smoothing_duration: 30, + quorum: 1000, + proposal_creation_threshold: 100, + }, + 'governor.config' + ); + assert(result.timelock.get_configuration() == (320, 60), 'timelock config'); +} diff --git a/src/governor.cairo b/src/governor.cairo index 83e8ee2..45877a9 100644 --- a/src/governor.cairo +++ b/src/governor.cairo @@ -41,10 +41,8 @@ struct ProposalInfo { nay: u128, } -#[derive(Copy, Drop, Serde, starknet::Store)] +#[derive(Copy, Drop, Serde, starknet::Store, PartialEq)] struct Config { - // the token used for voting - voting_token: IGovernanceTokenDispatcher, // how long after a proposal is created does voting start voting_start_delay: u64, // the period during which votes are collected @@ -71,6 +69,9 @@ trait IGovernor { // Execute the given proposal. fn execute(ref self: TStorage, call: Call) -> Span; + // Get the configuration for this governor contract. + fn get_voting_token(self: @TStorage) -> IGovernanceTokenDispatcher; + // Get the configuration for this governor contract. fn get_config(self: @TStorage) -> Config; @@ -92,13 +93,17 @@ mod Governor { #[storage] struct Storage { + voting_token: IGovernanceTokenDispatcher, config: Config, proposals: LegacyMap, voted: LegacyMap<(ContractAddress, felt252), bool>, } #[constructor] - fn constructor(ref self: ContractState, config: Config) { + fn constructor( + ref self: ContractState, voting_token: IGovernanceTokenDispatcher, config: Config + ) { + self.voting_token.write(voting_token); self.config.write(config); } @@ -116,8 +121,9 @@ mod Governor { let proposer = get_caller_address(); assert( - config + self .voting_token + .read() .get_average_delegated_over_last( delegate: proposer, period: config.voting_weight_smoothing_duration ) >= config @@ -154,8 +160,9 @@ mod Governor { assert(timestamp_current < (voting_start_time + config.voting_period), 'VOTING_ENDED'); assert(!voted, 'ALREADY_VOTED'); - let weight = config + let weight = self .voting_token + .read() .get_average_delegated( delegate: voter, start: voting_start_time - config.voting_weight_smoothing_duration, @@ -174,6 +181,7 @@ mod Governor { fn cancel(ref self: ContractState, id: felt252) { let config = self.config.read(); + let voting_token = self.voting_token.read(); let mut proposal = self.proposals.read(id); assert(proposal.proposer.is_non_zero(), 'DOES_NOT_EXIST'); @@ -183,8 +191,7 @@ mod Governor { if (proposal.proposer != get_caller_address()) { // if at any point the average voting weight is below the proposal_creation_threshold for the proposer, it can be canceled assert( - config - .voting_token + voting_token .get_average_delegated_over_last( delegate: proposal.proposer, period: config.voting_weight_smoothing_duration @@ -251,6 +258,10 @@ mod Governor { self.config.read() } + fn get_voting_token(self: @ContractState) -> IGovernanceTokenDispatcher { + self.voting_token.read() + } + fn get_proposal(self: @ContractState, id: felt252) -> ProposalInfo { self.proposals.read(id) } diff --git a/src/governor_test.cairo b/src/governor_test.cairo index f3c8558..bb57120 100644 --- a/src/governor_test.cairo +++ b/src/governor_test.cairo @@ -26,9 +26,9 @@ use serde::Serde; use zeroable::{Zeroable}; -fn deploy(config: Config) -> IGovernorDispatcher { +fn deploy(voting_token: IGovernanceTokenDispatcher, config: Config) -> IGovernorDispatcher { let mut constructor_args: Array = ArrayTrait::new(); - Serde::serialize(@config, ref constructor_args); + Serde::serialize(@(voting_token, config), ref constructor_args); let (address, _) = deploy_syscall( Governor::TEST_CLASS_HASH.try_into().unwrap(), 0, constructor_args.span(), true @@ -62,8 +62,8 @@ fn create_proposal(governance: IGovernorDispatcher, token: IGovernanceTokenDispa fn test_governance_deploy() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -72,8 +72,8 @@ fn test_governance_deploy() { } ); + assert(governance.get_voting_token().contract_address == token.contract_address, 'token'); let config = governance.get_config(); - assert(config.voting_token.contract_address == token.contract_address, 'token'); assert(config.voting_start_delay == 3600, 'voting_start_delay'); assert(config.voting_period == 60, 'voting_period'); assert(config.voting_weight_smoothing_duration == 30, 'smoothing'); @@ -89,8 +89,8 @@ fn test_governance_deploy() { fn test_propose() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -121,8 +121,8 @@ fn test_propose() { fn test_propose_already_exists_should_fail() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -141,8 +141,8 @@ fn test_propose_already_exists_should_fail() { fn test_propose_below_threshold_should_fail() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -172,8 +172,8 @@ fn test_propose_below_threshold_should_fail() { fn test_vote_yes() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -209,8 +209,8 @@ fn test_vote_yes() { fn test_vote_no() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -248,8 +248,8 @@ fn test_vote_before_voting_start_should_fail() { // Initial setup similar to propose test let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -275,8 +275,8 @@ fn test_vote_before_voting_start_should_fail() { fn test_vote_already_voted_should_fail() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -308,8 +308,8 @@ fn test_vote_already_voted_should_fail() { fn test_vote_after_voting_period_should_fail() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -338,8 +338,8 @@ fn test_vote_after_voting_period_should_fail() { fn test_cancel_by_proposer() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -373,8 +373,8 @@ fn test_cancel_by_proposer() { fn test_cancel_by_non_proposer() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -419,8 +419,8 @@ fn test_cancel_by_non_proposer() { fn test_cancel_by_non_proposer_threshold_not_breached_should_fail() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -446,8 +446,8 @@ fn test_cancel_by_non_proposer_threshold_not_breached_should_fail() { fn test_cancel_after_voting_end_should_fail() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -476,8 +476,8 @@ fn test_cancel_after_voting_end_should_fail() { fn test_execute_valid_proposal() { let (token, erc20) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -517,8 +517,8 @@ fn test_execute_valid_proposal() { fn test_execute_before_voting_ends_should_fail() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -544,8 +544,8 @@ fn test_execute_before_voting_ends_should_fail() { fn test_execute_quorum_not_met_should_fail() { let (token, _) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -572,8 +572,8 @@ fn test_execute_no_majority_should_fail() { let deployer = get_contract_address(); let (token, erc20) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -633,8 +633,8 @@ fn test_verify_votes_are_counted_over_voting_weight_smoothing_duration_from_star let deployer = get_contract_address(); let (token, erc20) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -699,8 +699,8 @@ fn test_verify_votes_are_counted_over_voting_weight_smoothing_duration_from_star fn test_execute_already_executed_should_fail() { let (token, erc20) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, @@ -752,8 +752,8 @@ fn queue_with_timelock_call(timelock: ITimelockDispatcher, calls: Span) -> fn test_proposal_e2e() { let (token, erc20) = deploy_token('Governor', 'GT', 1000); let governance = deploy( - Config { - voting_token: token, + voting_token: token, + config: Config { voting_start_delay: 3600, voting_period: 60, voting_weight_smoothing_duration: 30, diff --git a/src/lib.cairo b/src/lib.cairo index cfbf061..21e58e6 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -1,5 +1,6 @@ mod airdrop; mod call_trait; +mod factory; mod governance_token; mod governor; mod interfaces; @@ -8,12 +9,14 @@ mod timelock; #[cfg(test)] mod airdrop_test; #[cfg(test)] -mod governor_test; +mod call_trait_test; #[cfg(test)] -mod governance_token_test; +mod factory_test; #[cfg(test)] -mod timelock_test; +mod governance_token_test; #[cfg(test)] -mod call_trait_test; +mod governor_test; #[cfg(test)] mod test_utils; +#[cfg(test)] +mod timelock_test;