Skip to content

Commit

Permalink
refactor: distribute initial tokens only after launch and buy them fr…
Browse files Browse the repository at this point in the history
…om ekubo pool (if applicable) (#184)

# PR - Distribute tokens only after launch

PR to prevent malicious interactions with ekubo by creating pools
without any counterparty and risking the supply to dry up.

## Memecoin
- Holders before launch is no longer a concept. The initial holders
receive their tokens (for “free” ) once the coin is launched. As such,
`pre_launch_holders_count` does not need to be tracked and enforcement
of prelaunch holder count no longer makes sense.
- Allocation of team supply has moved from **memecoin.cairo** to
**factory.cairo**
- All the initial tokens are minted to the factory
- The amount for the team allocation is written to the memecoin by the
factory when finalising the launch.

## Factory
- Factorized the assertions upon launch to avoid code duplication.
- Introduced the calculation of the supply held by the team, capped to
10%, by iterating through each allocation of the initial holders.
- The `launch` functions now take as argument the `initial_holders` and
`initial_holders_amount`. The MAX_HOLDERS_LAUNCH const is set to 10.
- The `launch` function calculate the total team allocation, and supply
liquidity equal to the `total_supply - team_allocation`, and after
launch distribute the team_allocation to the initial holders
- <!> IMPORTANT
- The Ekubo launch flow has been entirely reworked to avoid
vulnerabilities. It now works as follows:
- The owner of the memecoin will need to provide liquidity upon launch
to cover for its initial allocation. For example, if it launches a coin
at a price of 0.01ETH / MEME and the team is allocated 10% of the total
supply, he needs to provide `quote_tokens = 0.1 * total_supply) * 0.01`.
    - The liquidity providing is done in two steps:
- 1. Put the `team_allocation` in the LP, between bounds [initial_tick,
initial_tick +1]
- 2. Put the `lp_supply`(public liquidity) between bounds [initial_tick,
+inf]
- This ensures that the team can get its allocation from the pool at a
price corresponding to the interval [starting_tick, starting_tick+1]
- Once the LP is provided, the funds sent to the factory are used to buy
the initial holders tokens using an exact output swap. Considering the
tick spacing and fees, it is recommended to send 1.02 * quote_tokens to
ensure that the swap passes - this needs to be tested more



# Utils
- fn sum() to sum the value of an array
- Ekubo util functions

# Tests to implement

Some of these tests are already partially implemented - and the way they
are implemented is not necessarily 100% compatible with the current
architecture (e.g. the reserve in quote tokens after a swap-in/swap-out
is not 0 but not also contains the liquidity the team used to buy).

## Unit tests
- Fix failing unit tests. 
- Test the distribute_team_allocation function

## Ekubo

Test the launch flow for these scenarios.

- Memecoin is token0 in the pool, with a price MEME/ETH < 1
- Memecoin is token0 in the pool, with a price MEME/ETH > 1
- Memecoin is token1 in the pool, with a price MEME/ETH < 1
- Memecoin is token1 in the pool, with a price MEME/ETH > 1
- Test a launch with a pool with a 1% fee parameter
Each one of these tests will perform the following
- Launch the memecoin
- Assert that the reserve in quote tokens corresponds roughly (~ 0.5%
uncertainty) to the amount sent by the team to buy tokens, which itself
corresponds to the % of tokens to buy * the price to buy the tokens
at.
Example: The team allocates itself 10% of the total supply. The
price is 0.01ETH/MEME. Thus, the team must send an amount of roughly 0.1
* total_supply * 0.01. Add an uncertainty of the size of the tick space
and fee, because the execution will not happen at the launch price
precisely. I observed that 1.2% should be enough So if `total_supply =
21M & price=0.01ETH/MEME`, send ~ `1.012*0.1*21M*0.01`
- Verify that the reserve in meme tokens is in the interval [0.99 *
lp_tokens, 0.995 * lp_tokens] where lp_tokens is the total_supply of the
coin - the amount allocated to the team
- Check that the LP position is tracked in the launcher
- Check that the initial holders have been allocated the correct amount
of tokens.
- Check that events were emitted correctly
- Check that swaps work correctly
    - Can do a swap in / swap out
    - Withdraw fees
- The amount collected of quote fees must be roughly pool_fee *
amount_swapped
        - The amount of collected memecoin fee must be 0
- Important: test that if someone initialises an Ekubo pool with the
wrong initial_price, the person that launches can still add liauidity to
the pool at the right price
  • Loading branch information
enitrat authored Feb 3, 2024
1 parent 03eae7a commit 80aba25
Show file tree
Hide file tree
Showing 22 changed files with 934 additions and 754 deletions.
1 change: 1 addition & 0 deletions contracts/src/exchanges.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ trait ExchangeAdapter<A, R> {
exchange_address: ContractAddress,
token_address: ContractAddress,
quote_address: ContractAddress,
lp_supply: u256,
additional_parameters: A,
) -> R;
}
1 change: 1 addition & 0 deletions contracts/src/exchanges/ekubo.cairo
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod ekubo_adapter;
mod errors;
mod helpers;
mod interfaces;
mod launcher;
119 changes: 106 additions & 13 deletions contracts/src/exchanges/ekubo/ekubo_adapter.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ use array::ArrayTrait;
use core::option::OptionTrait;
use core::traits::TryInto;
use debug::PrintTrait;
use ekubo::components::clear::{IClearDispatcher, IClearDispatcherTrait};
use ekubo::interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait};
use ekubo::interfaces::router::{Depth, Delta, RouteNode, TokenAmount};
use ekubo::interfaces::router::{IRouterDispatcher, IRouterDispatcherTrait};
use ekubo::types::bounds::Bounds;
use ekubo::types::i129::i129;
use ekubo::types::keys::PoolKey;
use openzeppelin::token::erc20::interface::{
IERC20, IERC20Metadata, ERC20ABIDispatcher, ERC20ABIDispatcherTrait
};
Expand All @@ -15,13 +21,15 @@ use unruggable::token::interface::{
IUnruggableMemecoinDispatcher, IUnruggableMemecoinDispatcherTrait,
};
use unruggable::utils::math::PercentageMath;
use unruggable::utils::sort_tokens;


#[derive(Copy, Drop, Serde)]
struct EkuboLaunchParameters {
owner: ContractAddress,
token_address: ContractAddress,
quote_address: ContractAddress,
lp_supply: u256,
pool_params: EkuboPoolParameters
}

Expand All @@ -30,7 +38,7 @@ struct EkuboPoolParameters {
fee: u128,
tick_spacing: u128,
// the sign of the starting tick is positive (false) if quote/token < 1 and negative (true) otherwise
starting_tick: i129,
starting_price: i129,
// The LP providing bound, upper/lower determined by the address of the LPed tokens
bound: u128,
}
Expand All @@ -42,41 +50,126 @@ impl EkuboAdapterImpl of unruggable::exchanges::ExchangeAdapter<
exchange_address: ContractAddress,
token_address: ContractAddress,
quote_address: ContractAddress,
lp_supply: u256,
additional_parameters: EkuboPoolParameters,
) -> (u64, EkuboLP) {
let ekubo_launch_params = EkuboLaunchParameters {
owner: starknet::get_caller_address(),
token_address: token_address,
quote_address: quote_address,
lp_supply: lp_supply,
pool_params: EkuboPoolParameters {
fee: additional_parameters.fee,
tick_spacing: additional_parameters.tick_spacing,
starting_tick: additional_parameters.starting_tick,
starting_price: additional_parameters.starting_price,
bound: additional_parameters.bound,
}
};

let this = get_contract_address();
let memecoin = IUnruggableMemecoinDispatcher { contract_address: token_address, };
let memecoin_address = memecoin.contract_address;
let quote_token = ERC20ABIDispatcher { contract_address: quote_address, };

let ekubo_launchpad = IEkuboLauncherDispatcher { contract_address: exchange_address };
assert(ekubo_launchpad.contract_address.is_non_zero(), errors::EXCHANGE_ADDRESS_ZERO);

// Transfer the tokens to the launchpad contract.
let memecoin_balance = memecoin.balance_of(this);
memecoin.transfer(ekubo_launchpad.contract_address, memecoin_balance);
// Transfer all tokens to the launchpad contract.
// The team will buyback the tokens from the pool after the LPing operation to earn their initial allocation.
let memecoin = IUnruggableMemecoinDispatcher { contract_address: token_address, };
let this = get_contract_address();
memecoin.transfer(ekubo_launchpad.contract_address, memecoin.balance_of(this));

// Launch the token, which creates two positions: one concentrated at initial_tick
// for the team allocation and one on the range [initial_tick, inf] for the initial LP.
let (id, position) = ekubo_launchpad.launch_token(ekubo_launch_params);

// Ensure that the LPing operation has not returned more than 0.5% of the provided liquidity to the caller.
// Otherwise, there was an error in the LP parameters.
let total_supply = memecoin.total_supply();
let team_alloc = memecoin.get_team_allocation();
let max_returned_tokens = PercentageMath::percent_mul(total_supply - team_alloc, 9950);
let max_returned_tokens = PercentageMath::percent_mul(total_supply, 50);
assert(memecoin.balanceOf(this) < max_returned_tokens, 'ekubo has returned tokens');

// Finally, buy the reserved team tokens from the pool.
// This requires having transferred the quote tokens to the factory before.
// As the pool was created with a fixed price for these n% allocated to the team,
// the required amount should be (%alloc * total_supply) * price.
let (token0, token1) = sort_tokens(token_address, ekubo_launch_params.quote_address);
let pool_key = PoolKey {
token0: token0,
token1: token1,
fee: ekubo_launch_params.pool_params.fee,
tick_spacing: ekubo_launch_params.pool_params.tick_spacing,
extension: 0.try_into().unwrap(),
};
let team_allocation = total_supply - lp_supply;
buy_tokens_from_pool(
ekubo_launchpad, pool_key, team_allocation, token_address, quote_address
);

assert(memecoin.balanceOf(this) >= team_allocation, 'failed buying team tokens');
// Distribution to the holders is done in the next step.

(id, position)
}
}

/// Buys tokens from a liquidity pool.
///
/// It first determines the square root price limits for the swap based on whether the quote token is token1 or token0 in the pool.
/// It then creates a route node for the swap, transfers the quote tokens to the router contract,
/// and calls the router's swap function with an exact output amount.
/// Finally, it calls the clearer's clear function for both the token to buy and the quote token.
///
/// # Arguments
///
/// * `ekubo_launchpad` - A dispatcher for the Ekubo launchpad contract.
/// * `pool_key` - The key of the liquidity pool.
/// * `amount` - The amount of tokens to buy.
/// * `token_to_buy` - The address of the token to buy.
/// * `quote_address` - The address of the quote token.
///
fn buy_tokens_from_pool(
ekubo_launchpad: IEkuboLauncherDispatcher,
pool_key: PoolKey,
amount: u256,
token_to_buy: ContractAddress,
quote_address: ContractAddress,
) {
let ekubo_router = IRouterDispatcher {
contract_address: ekubo_launchpad.ekubo_router_address()
};
let ekubo_clearer = IClearDispatcher {
contract_address: ekubo_launchpad.ekubo_router_address()
};

let token_to_buy = IUnruggableMemecoinDispatcher { contract_address: token_to_buy };

let max_sqrt_ratio_limit = 6277100250585753475930931601400621808602321654880405518632;
let min_sqrt_ratio_limit = 18446748437148339061;

let is_token1 = pool_key.token1 == quote_address;
let (sqrt_limit_swap1, sqrt_limit_swap2) = if is_token1 {
(max_sqrt_ratio_limit, min_sqrt_ratio_limit)
} else {
(min_sqrt_ratio_limit, max_sqrt_ratio_limit)
};

let route_node = RouteNode {
pool_key: pool_key, sqrt_ratio_limit: sqrt_limit_swap1, skip_ahead: 0
};

let quote_token = IERC20Dispatcher { contract_address: quote_address };
let this = get_contract_address();
// Buy tokens from the pool, with an exact output amount.
let token_amount = TokenAmount {
token: token_to_buy.contract_address,
amount: i129 { mag: amount.low, sign: true // negative (true) sign is exact output
},
};

// We transfer quote tokens to the swapper contract, which performs the swap
// It then sends back the funds to the caller once cleared.
quote_token.transfer(ekubo_router.contract_address, quote_token.balanceOf(this));
// Swap and clear the tokens to finalize.
ekubo_router.swap(route_node, token_amount);
ekubo_clearer.clear(IERC20Dispatcher { contract_address: token_to_buy.contract_address });
ekubo_clearer
.clear_minimum_to_recipient(
IERC20Dispatcher { contract_address: quote_address }, 0, starknet::get_caller_address()
);
}
83 changes: 83 additions & 0 deletions contracts/src/exchanges/ekubo/helpers.cairo
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use ekubo::types::bounds::Bounds;
use ekubo::types::i129::i129;

/// Calculates the initial tick and bounds for a liquidity pool from the starting price and the magnitude of the single bound delimiting the range [starting_price, upper_bound] or [lower_bound, starting_price].
///
/// # Arguments
///
/// * `starting_price` - The initial price of the token pair.
/// * `bound_mag` - The magintude of bound.
/// * `is_token1_quote` - A boolean indicating whether token1 is the quote currency.
///
/// # Returns
///
/// * A tuple containing the initial tick and the bounds.
///
/// If `is_token1_quote` is true, the initial tick and lower bound are set to the starting price,
/// and the upper bound is set to the provided bound as a positive integer.
///
/// If `is_token1_quote` is false, the initial tick and upper bound are set to the negative of the starting price,
/// and the lower bound is set to the provided bound as a negative integer.
///
/// The sign of the initial tick is reversed if the quote is token0, as the price provided was expressed in token1/token0.
///
fn get_initial_tick_from_starting_price(
starting_price: i129, bound_mag: u128, is_token1_quote: bool
) -> (i129, Bounds) {
let (initial_tick, bounds) = if is_token1_quote {
// the price is always supplied in quote/meme. if token 1 is quote,
// then the upper bound expressed in quote/meme is +inf
// and the lower bound is the starting price.
(
i129 { sign: starting_price.sign, mag: starting_price.mag },
Bounds {
lower: i129 { sign: starting_price.sign, mag: starting_price.mag },
upper: i129 { sign: false, mag: bound_mag }
}
)
} else {
// The initial tick sign is reversed if the quote is token0.
// as the price provided was expressed in token1/token0.
(
i129 { sign: !starting_price.sign, mag: starting_price.mag },
Bounds {
lower: i129 { sign: true, mag: bound_mag },
upper: i129 { sign: !starting_price.sign, mag: starting_price.mag }
}
)
};
(initial_tick, bounds)
}


fn get_next_tick_bounds(starting_price: i129, tick_spacing: u128, is_token1_quote: bool) -> Bounds {
// The sign of the next bound is the same as the sign of the starting tick.
// If the token1 is the quote token, the price is expressed in the correct token1/token 0 order
// and the sign of the starting tick is the same as the sign of the price.
// otherwise, it's flipped.
let bound_sign = if is_token1_quote {
starting_price.sign
} else {
!starting_price.sign
};

// The magnitude of the next bound is the starting tick magnitude plus or minus the tick spacing.
// If the starting sign is negative, then the next bound is the starting tick minus the tick spacing.
// If the starting sign is positive, then the next bound is the starting tick plus the tick spacing.
let bound_mag = if starting_price.sign {
starting_price.mag - tick_spacing
} else {
starting_price.mag + tick_spacing
};

let (lower_mag, upper_mag) = if (is_token1_quote) {
(starting_price.mag, bound_mag)
} else {
(bound_mag, starting_price.mag)
};

Bounds {
lower: i129 { sign: bound_sign, mag: lower_mag },
upper: i129 { sign: bound_sign, mag: upper_mag }
}
}
Loading

0 comments on commit 80aba25

Please sign in to comment.