diff --git a/Scarb.lock b/Scarb.lock index b048132..6ba0fec 100644 --- a/Scarb.lock +++ b/Scarb.lock @@ -1,6 +1,14 @@ # Code generated by scarb DO NOT EDIT. version = 1 +[[package]] +name = "cubit" +version = "1.2.0" +source = "git+https://github.com/raphaeldkhn/cubit#e6331ebf98c5d5f442a0e5edefe0b367c8e270d9" + [[package]] name = "governance" version = "0.1.0" +dependencies = [ + "cubit", +] diff --git a/Scarb.toml b/Scarb.toml index 4399535..e99dbb2 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -6,6 +6,7 @@ homepage = "https://ekubo.org" cairo-version = "2.3.0" [dependencies] +cubit = { git = "https://github.com/raphaeldkhn/cubit" } starknet = "=2.3.0" [[target.starknet-contract]] diff --git a/src/lib.cairo b/src/lib.cairo index a7321db..14e299e 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -3,6 +3,8 @@ mod governor; mod governance_token; mod timelock; mod call_trait; +mod token_vrgda; +mod vrgda; #[cfg(test)] mod tests; diff --git a/src/tests.cairo b/src/tests.cairo index 91004bf..6a6f838 100644 --- a/src/tests.cairo +++ b/src/tests.cairo @@ -10,3 +10,5 @@ mod timelock_test; mod call_trait_test; #[cfg(test)] mod utils; +#[cfg(test)] +mod vrgda_test; diff --git a/src/tests/vrgda_test.cairo b/src/tests/vrgda_test.cairo new file mode 100644 index 0000000..f7eab0e --- /dev/null +++ b/src/tests/vrgda_test.cairo @@ -0,0 +1,114 @@ +use cubit::f128::{Fixed, FixedTrait}; +use governance::vrgda::{VRGDAParameters, VRGDAParametersTrait}; +use debug::{PrintTrait}; + + +#[test] +#[available_gas(30000000)] +fn test_decay_constant() { + assert( + VRGDAParameters { + target_price: FixedTrait::ONE(), + num_sold_per_time_unit: 1, + price_decay_percent: FixedTrait::ONE() / FixedTrait::new_unscaled(2, false) + } + .decay_constant() == FixedTrait::new(12786309186476892720, true), + 'decay_constant' + ); +} + +#[test] +#[available_gas(30000000)] +fn test_p_at_time_zero() { + assert( + VRGDAParameters { + target_price: FixedTrait::ONE(), + num_sold_per_time_unit: 1, + price_decay_percent: FixedTrait::ONE() / FixedTrait::new_unscaled(2, false) + } + .p( + time_units_since_start: FixedTrait::ZERO(), sold: 0 + ) == FixedTrait::new(0x17154754c6a1bf740, false), + 'p' + ); +} + +#[test] +#[available_gas(30000000)] +fn test_p_at_time_one() { + assert( + VRGDAParameters { + target_price: FixedTrait::ONE(), + num_sold_per_time_unit: 1, + price_decay_percent: FixedTrait::ONE() / FixedTrait::new_unscaled(2, false) + } + .p( + time_units_since_start: FixedTrait::ONE(), sold: 0 + ) == FixedTrait::new(0xb8aa3aa6350dfba0, false), + 'p' + ); +} + +#[test] +#[available_gas(30000000)] +fn test_p_time_one_more_sold_per_time_unit() { + assert( + VRGDAParameters { + target_price: FixedTrait::ONE(), + num_sold_per_time_unit: 1000, + price_decay_percent: FixedTrait::ONE() / FixedTrait::new_unscaled(2, false) + } + .p( + time_units_since_start: FixedTrait::ONE(), sold: 0 + ) == FixedTrait::new(0x8009f8be9ba94b6f, false), + 'p' + ); +} + +#[test] +#[available_gas(30000000)] +fn test_p_time_zero_many_sold() { + assert( + VRGDAParameters { + target_price: FixedTrait::ONE(), + num_sold_per_time_unit: 1000, + price_decay_percent: FixedTrait::ONE() / FixedTrait::new_unscaled(2, false) + } + .p( + time_units_since_start: FixedTrait::ZERO(), sold: 1000 + ) == FixedTrait::new(0x20032fd0c57d40b42, false), + 'p' + ); +} + +#[test] +#[available_gas(30000000)] +fn test_p_sold_on_schedule() { + assert( + VRGDAParameters { + target_price: FixedTrait::ONE(), + num_sold_per_time_unit: 1000, + price_decay_percent: FixedTrait::ONE() / FixedTrait::new_unscaled(2, false) + } + .p( + time_units_since_start: FixedTrait::ZERO(), sold: 1000 + ) == FixedTrait::new(0x20032fd0c57d40b42, false), + 'p' + ); +} + +#[test] +#[available_gas(30000000)] +fn test_quote_batch_sold_example_schedule() { + assert( + VRGDAParameters { + target_price: FixedTrait::ONE(), + num_sold_per_time_unit: 1000, + price_decay_percent: FixedTrait::ONE() / FixedTrait::new_unscaled(2, false) + } + .quote_batch( + time_units_since_start: FixedTrait::ZERO(), sold: 0, amount: 10 + ) == FixedTrait::new(185108235227361442154, false), // ~= 10.0347 + 'p' + ); +} diff --git a/src/token_vrgda.cairo b/src/token_vrgda.cairo new file mode 100644 index 0000000..f76cd2c --- /dev/null +++ b/src/token_vrgda.cairo @@ -0,0 +1,141 @@ +use starknet::{ContractAddress}; +use governance::vrgda::{VRGDAParameters, VRGDAParametersTrait}; + +#[starknet::interface] +trait ITokenVRGDA { + // Buy the token with the ETH transferred to this contract, receiving at least min_amount_out + // The result is transferred to the recipient + fn buy(ref self: TContractState, buy_amount: u64, max_amount_in: u128) -> u128; + + // Withdraw the proceeds, must be called by the benefactor + fn withdraw_proceeds(ref self: TContractState); +} + +#[starknet::interface] +trait IERC20 { + fn balanceOf(self: @TContractState, account: ContractAddress) -> u256; + fn transfer(ref self: TContractState, recipient: ContractAddress, amount: u256) -> bool; +} + +// The buyer must transfer this token to the VRGDA contract before calling buy +#[starknet::interface] +trait IOptionToken { + fn burn(ref self: TContractState, amount: u128); +} + +#[starknet::contract] +mod TokenVRGDA { + use super::{ + ContractAddress, ITokenVRGDA, IERC20Dispatcher, IERC20DispatcherTrait, + IOptionTokenDispatcher, IOptionTokenDispatcherTrait, VRGDAParameters, VRGDAParametersTrait + }; + use cubit::f128::{Fixed, FixedTrait}; + use starknet::{get_caller_address, get_contract_address, contract_address_const}; + + #[storage] + struct Storage { + // The token that is used to buy the token + payment_token: IERC20Dispatcher, + // The token that gives the user the right to purchase the sold token + option_token: IOptionTokenDispatcher, + // The token that is sold + sold_token: IERC20Dispatcher, + // the configuration of the vrgda + vrgda_parameters: VRGDAParameters, + // The address that receives the payment token from the sale of the tokens + benefactor: ContractAddress, + // The amount of tokens that have been used to purchase the sold token and have not been withdrawn + // Used to compute how much was paid + reserves: u128, + // The number of sold tokens thus far + amount_sold: u128, + } + + #[derive(starknet::Event, Drop)] + struct Buy { + buyer: ContractAddress, + paid: u128, + sold: u128, + } + + #[derive(starknet::Event, Drop)] + #[event] + enum Event { + Buy: Buy, + } + + #[constructor] + fn constructor( + ref self: ContractState, + payment_token: IERC20Dispatcher, + option_token: IOptionTokenDispatcher, + sold_token: IERC20Dispatcher, + vrgda_parameters: VRGDAParameters, + benefactor: ContractAddress + ) { + self.payment_token.write(payment_token); + self.option_token.write(option_token); + self.sold_token.write(sold_token); + self.vrgda_parameters.write(vrgda_parameters); + self.benefactor.write(benefactor); + } + + #[external(v0)] + impl TokenVRGDAImpl of ITokenVRGDA { + fn buy(ref self: ContractState, buy_amount: u64, max_amount_in: u128) -> u128 { + let payment_token = self.payment_token.read(); + let option_token = self.option_token.read(); + let sold_token = self.sold_token.read(); + + let reserves = self.reserves.read(); + + let payment_balance: u128 = payment_token + .balanceOf(get_contract_address()) + .try_into() + .expect('PAID_OVERFLOW'); + + let paid: u128 = payment_balance - reserves; + + self.reserves.write(payment_balance); + + let amount_sold = self.amount_sold.read(); + + let quote = self + .vrgda_parameters + .read() + .quote_batch( + time_units_since_start: FixedTrait::new(0, false), + sold: amount_sold.try_into().unwrap(), + amount: buy_amount + ); + let sold: u128 = 0; + + // assert(quote >= max_amount_in, 'PURCHASED'); + + // Account for the newly sold amount before transferring anything + self.amount_sold.write(amount_sold + sold); + + // assert( + // sold + // .into() <= (amount_sold.into() + // + option_token.balanceOf(get_contract_address())), + // 'INSUFFICIENT_OPTION_TOKENS' + // ); + + self.emit(Buy { buyer: get_caller_address(), paid, sold }); + + sold_token.transfer(get_caller_address(), sold.into()); + + sold + } + + fn withdraw_proceeds(ref self: ContractState) { + let benefactor = self.benefactor.read(); + assert(get_caller_address() == benefactor, 'BENEFACTOR_ONLY'); + let reserves = self.reserves.read(); + self.reserves.write(0); + self.payment_token.read().transfer(benefactor, reserves.into()); + } + } +} + diff --git a/src/vrgda.cairo b/src/vrgda.cairo new file mode 100644 index 0000000..e8879fa --- /dev/null +++ b/src/vrgda.cairo @@ -0,0 +1,41 @@ +use cubit::f128::{Fixed, FixedTrait}; + +#[derive(Drop, Copy, Serde, starknet::Store)] +struct VRGDAParameters { + // The price at which the VRGDA should aim to sell the tokens + target_price: Fixed, + // How many tokens should be sold per time unit, in combination with the target price determines the rate + num_sold_per_time_unit: u64, + // How the price decays per time unit, if none are sold + price_decay_percent: Fixed, +} + +#[generate_trait] +impl VRGDAParametersTraitImpl of VRGDAParametersTrait { + fn decay_constant(self: VRGDAParameters) -> Fixed { + (FixedTrait::ONE() - self.price_decay_percent).ln() + } + + fn p_integral(self: VRGDAParameters, time_units_since_start: Fixed, sold: u64) -> Fixed { + -(self.target_price + * FixedTrait::new_unscaled(self.num_sold_per_time_unit.into(), false) + * (FixedTrait::ONE() - self.price_decay_percent) + .pow( + time_units_since_start + - (FixedTrait::new_unscaled(sold.into(), false) + / FixedTrait::new_unscaled(self.num_sold_per_time_unit.into(), false)) + ) + / self.decay_constant()) + } + + fn quote_batch( + self: VRGDAParameters, time_units_since_start: Fixed, sold: u64, amount: u64 + ) -> Fixed { + self.p_integral(time_units_since_start, sold + amount) + - self.p_integral(time_units_since_start, sold) + } + + fn p(self: VRGDAParameters, time_units_since_start: Fixed, sold: u64) -> Fixed { + self.quote_batch(time_units_since_start, sold, amount: 1) + } +}