diff --git a/AGENTS.md b/AGENTS.md index af335a3..00a07f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,48 +1,19 @@ # Repository Guidelines ## Project Structure & Module Organization - -- Source contracts live under `src/`; `contract.cairo` is the TicketMaster entrypoint, `interfaces.cairo` exposes the Starknet ABI, and `utils.cairo` holds TWAMM and fee utilities. -- Shared constants sit in `src/constants.cairo`; unit-style specs live alongside their modules (for example, TWAMM helper tests remain in `src/utils.cairo`). -- End-to-end and fork tests belong in `tests/`, consumable by Starknet Foundry. Keep build outputs (`target/`, `.snfoundry_cache/`) untracked. +Keep contract sources under `src/`. `contract.cairo` serves as the TicketMaster entrypoint, `interfaces.cairo` exposes the Starknet ABI, and `utils.cairo` hosts TWAMM and fee helpers. Shared values live in `src/constants.cairo`. Unit-style specs stay beside their modules, while end-to-end and fork tests belong in `tests/` for Starknet Foundry (`snforge`) to discover. Build artifacts such as `target/` and `.snfoundry_cache/` remain untracked. ## Build, Test, and Development Commands - -- `scarb build` — compile the Cairo package and verify dependency locks resolve. -- `snforge test` — run the full Foundry suite; use `snforge test util_tests::` when iterating on time helpers. -- `snforge test --detailed-resources` — capture gas/resource deltas before merging. -- `scarb fmt` — format all Cairo modules to the project standard. +Use `scarb build` to compile the Cairo package and confirm lockfiles resolve. Run `snforge test` for the full suite, or narrow with targets like `snforge test util_tests::` while iterating on helpers. Capture resource deltas before merging via `snforge test --detailed-resources`. Format all Cairo modules with `scarb fmt` before committing. Deployment scripts reside in `scripts/` and expect environment variables sourced from the provided `.env.*` files. ## Coding Style & Naming Conventions - -- Favor four-space indentation and grouped imports; always run `scarb fmt` before committing. -- Functions and modules use `snake_case`; structs and type aliases use `UpperCamelCase`; constants follow `SCREAMING_SNAKE_CASE` per `src/constants.cairo`. -- Document public ABIs with triple-slash comments so tooling surfaces them. +Adopt four-space indentation, grouped imports, and snake_case for functions/modules. Structs and type aliases use UpperCamelCase, while constants follow SCREAMING_SNAKE_CASE (see `src/constants.cairo`). Document public ABI functions with triple-slash comments so downstream tooling surfaces them. Always run `scarb fmt` and address formatter feedback instead of manual spacing tweaks. ## Testing Guidelines - -- Write positive and guard-path assertions for new behavior; regressions need reproducing tests. -- Keep unit helpers alongside their modules (for example, TWAMM helpers stay in `src/utils.cairo`); scenario tests stay in `tests/`. -- Name tests `test__` for searchable failures. -- Leverage Foundry fuzzing, storage inspection, and forking when edge cases demand it. - -## Feature Notes - -- TicketMaster now tracks a low-issuance mode controlled by oracle pricing. Enter the mode when the three-day average price is below `issuance_reduction_price_x128`, and exit only after the average rises back above the threshold. -- Reducing issuance relies on `issuance_reduction_bips`; validate inputs remain `< BIPS_BASIS` and ensure tests cover both entry and exit paths. -- Constructor calls must supply the Ekubo oracle address plus issuance reduction parameters, and tests in `tests/test_contract.cairo` set expectations for the new getters (`is_low_issuance_mode`, `get_low_issuance_returned_tokens`, etc.). -- Constructor now also accepts the veLords revenue recipient address; deployment tooling and tests must thread this parameter and leverage `set_velords_address` for post-deploy rotations. -- Constructor now also requires the address of the position NFT contract; make sure helpers and deployment scripts thread this through whenever the positions deployment differs from the NFT address. -- `init_distribution_pool` now accepts a single distribution tick (owner-only), persists it, and returns the pool identifier; `provide_initial_liquidity` likewise takes payment/dungeon/minimum amounts and is owner-only. Deployment tooling should derive the distribution tick after the contract address is known and pass the liquidity values to this step. -- proceeds distribution splits payment token balances 20/80 between the stored veLords address and TWAMM buybacks; use the new setter when rotating recipients. +Favor positive and guard-path assertions for any new logic; regressions need reproducing tests. Unit helpers should live with their modules (e.g., TWAMM helpers in `src/utils.cairo`), while end-to-end scenarios stay under `tests/`. Name tests `test__` to keep failure output searchable. For fork tests, annotate with `#[fork("")]` and verify prerequisites such as approvals or oracle mocks. Leverage Foundry fuzzing and storage inspection when defending edge cases like low-issuance mode transitions. When adding new storage variables or getters (e.g., `liquidity_position_id`), ensure corresponding getter tests verify storage consistency after relevant state transitions. ## Commit & Pull Request Guidelines - -- Follow Conventional Commits (`feat:`, `fix:`, `refactor:`, `docs:`); keep subjects under ~70 characters. -- Squash fixups locally and avoid merge commits in feature branches. -- PRs should outline context, cite `snforge` commands run, link issues, and flag security-sensitive changes. +Follow Conventional Commits (`feat:`, `fix:`, `refactor:`, `docs:`) with subjects under ~70 characters. Squash fixups locally and avoid merge commits in feature branches. PR descriptions should summarize context, cite the `snforge` commands run, link relevant issues, and highlight security-sensitive touchpoints (reentrancy, access control, math safety). Before requesting review, ensure formatting is clean, fork tests are green, and new parameters (e.g., oracle, veLords, position NFT addresses) are wired through constructors and deployment helpers. ## Security & Review Focus - -- Treat reentrancy, access control, and math safety as audit priorities; justify deviations from established patterns. -- Prefer incremental, tested changes; validate risky modifications locally before requesting review. +Validate oracle-driven low-issuance flows (`enable_low_issuance_mode`, `disable_low_issuance_mode`) with both entry and exit tests. Treat proceeds distribution, TWAMM interactions, and owner-only pathways as audit priorities—justify any departure from existing patterns. Confirm external calls (registries, Ekubo dispatchers, ERC20/ERC721 withdrawals) respect access control and cannot strand tokens. When touching deployment scripts, thread new addresses and ticks end-to-end to prevent misconfigured mainnet operations. diff --git a/CLAUDE.md b/CLAUDE.md index 7b32cfa..03daa9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,8 @@ The contract implements sophisticated time alignment for TWAMM compatibility: contract owner. The distribution tick should be derived off-chain after the contract address is known. - `provide_initial_liquidity` likewise accepts payment- and dungeon-token amounts plus a minimum liquidity threshold, and only the owner may call it. Deployment scripts must forward the desired - liquidity configuration at invocation time. + liquidity configuration at invocation time. The function stores the returned liquidity position ID + in contract storage, accessible via `get_liquidity_position_id()` for tracking the initial liquidity NFT. ### Issuance Throttling diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..aecb58f --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,76 @@ +# TicketMaster Cairo Project + +## Project Overview + +This project implements a TicketMaster smart contract on the Starknet blockchain using the Cairo language. It leverages Ekubo's TWAMM (Time-Weighted Average Market Maker) extension to create a sophisticated, demand-based pricing mechanism for "Dungeon Tickets". The contract is an extension of OpenZeppelin's ERC20 component and includes a state machine to manage the lifecycle of token distribution. + +The core functionalities include: +- **Automated Market Making:** Initializes a TWAMM pool on Ekubo to facilitate the sale of Dungeon Tickets. +- **Dynamic Pricing:** The price of tickets is determined by market demand through the TWAMM. +- **Issuance Throttling:** The contract can reduce the rate of ticket issuance if the market price falls below a configurable threshold, and resume when the price recovers. +- **Proceeds Distribution:** Proceeds from ticket sales are split between a treasury and a buyback mechanism for a different token. + +## Building and Running + +The project uses Scarb for dependency management and Starknet Foundry for testing. + +### Prerequisites + +- [Scarb](https://docs.swmansion.com/scarb/) v2.12.2+ +- [Starknet Foundry](https://foundry-rs.github.io/starknet-foundry/) v0.49.0+ + +### Setup + +1. **Clone the repository:** + ```bash + git clone + cd ticket-master + ``` + +2. **Install dependencies:** + ```bash + scarb build + ``` + +### Build + +To compile the contract, run: + +```bash +scarb build +``` + +### Testing + +The project includes a comprehensive test suite using Starknet Foundry. + +- **Run all tests:** + ```bash + snforge test + ``` + +- **Run tests with forking:** + The tests can be run against a forked mainnet or sepolia environment. + ```bash + snforge test --fork mainnet + snforge test --fork sepolia + ``` + +## Development Conventions + +### Code Style + +The project follows the standard Cairo formatting guidelines. Use `scarb fmt` to format the code. + +### Testing Practices + +- **Unit Tests:** Located alongside the source code (e.g., `src/utils.cairo`). +- **Integration Tests:** Located in the `tests/` directory, covering the full contract lifecycle. +- **Fork Testing:** Tests are run against forked environments to ensure correct integration with external contracts like Ekubo. + +### Contribution Guidelines + +1. Format code with `scarb fmt`. +2. Run all tests, including fork tests. +3. Update documentation and tests along with code changes. +4. Follow Conventional Commits for git history (e.g., `feat:`, `fix:`, `refactor:`). diff --git a/README.md b/README.md index 21c318d..f6b7a4a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ The contract follows a strict four-phase deployment sequence: - Mints `dungeon_amount` of dungeon tickets directly to Ekubo Positions contract - Calls Ekubo's `mint_and_deposit_and_clear_both` with symmetric bounds to establish the initial liquidity position - Validates that the resulting liquidity meets the `min_liquidity` threshold + - Stores the liquidity position ID for future reference - Advances state to `2 (LiquidityProvided)` 3. **Start Token Distribution** – `start_token_distribution()` (callable by anyone once liquidity is provided) @@ -100,21 +101,22 @@ The contract follows a strict four-phase deployment sequence: - Caches the position NFT token ID and advances state to `3 (DistributionStarted)` 4. **Recycle Proceeds** – Ongoing operations after distribution starts: - - `claim_proceeds()`: Withdraws realized payment token sales from the distribution TWAMM order, then splits proceeds 80% to treasury and 20% for buybacks - - `distribute_proceeds()`: Creates a new TWAMM buyback order using the accumulated 20% share, converting payment tokens back to buyback tokens over time - - `claim_and_distribute_buybacks(limit)`: Iterates through matured buyback orders, withdrawing completed buyback tokens and forwarding them to the veLords address + - `claim_proceeds()`: Withdraws realized payment token sales from the distribution TWAMM order + - `distribute_proceeds(end_time)`: Splits proceeds 80% to buybacks and 20% to veLords, then creates a new TWAMM buyback order that starts immediately and runs until `end_time` + - `claim_and_distribute_buybacks(limit)`: Iterates through matured buyback orders, withdrawing completed buyback tokens and forwarding them to the treasury address ### Buyback Order Configuration The constructor accepts a `BuybackOrderConfig` structure that defines constraints for buyback TWAMM orders created during proceeds distribution. This configuration includes: -- `min_delay` / `max_delay`: Valid time window (in seconds) between claiming proceeds and when the buyback order can start +- `min_delay` / `max_delay`: *(Currently unused - buyback orders always start immediately)* Reserved for future use - `min_duration` / `max_duration`: Valid duration range (in seconds) for the buyback order execution - `fee`: The pool fee tier to use for buyback orders (encoded as a Q128 value) These constraints ensure buyback orders are created with parameters that match the protocol's operational requirements -and prevent invalid TWAMM order configurations. +and prevent invalid TWAMM order configurations. Note that all buyback orders start immediately (`start_time = 0`) +upon calling `distribute_proceeds(end_time)`. ### Issuance Reduction Guard @@ -124,33 +126,38 @@ The contract implements dynamic issuance throttling that responds to market cond `issuance_reduction_price_duration` in seconds, owner-configurable) returned by Ekubo's on-chain oracle. When the average price drops below the configured Q128 threshold (`issuance_reduction_price_x128`), the function reduces the active distribution sale rate by `issuance_reduction_bips` (basis points) and holds - the reclaimed tokens on the contract. + the reclaimed tokens on the contract. Returns the number of tokens returned from the TWAMM order. - `disable_low_issuance_mode()` performs the inverse operation: once the average price climbs back above the threshold over the same lookback period (querying Ekubo's on-chain oracle), the stored tokens are - re-supplied to the TWAMM position and the original sale rate is restored. + re-supplied to the TWAMM position and the original sale rate is restored. Returns the new distribution rate. +- `force_enable_low_issuance_mode()` and `force_disable_low_issuance_mode()` (owner-only) allow emergency + override of the oracle-driven checks, enabling manual control during oracle failures or extreme market conditions. - The contract exposes `is_low_issuance_mode()`, `get_low_issuance_returned_tokens()`, `get_issuance_reduction_price_x128()`, `get_issuance_reduction_price_duration()`, and `get_issuance_reduction_bips()` so off-chain monitoring can track throttle state and configuration. This mechanism protects against oversupply during unfavorable market conditions while maintaining flexibility to -resume normal distribution rates when conditions improve. +resume normal distribution rates when conditions improve. The force functions provide emergency controls for the +contract owner to respond to unforeseen circumstances. ### Public Interface Highlights The on-chain interface (`ITicketMaster`) exposes: - **Lifecycle actions**: `init_distribution_pool`, `provide_initial_liquidity`, `start_token_distribution`, - `claim_proceeds`, `claim_and_distribute_buybacks`, `distribute_proceeds`, - `enable_low_issuance_mode`, `disable_low_issuance_mode` + `claim_proceeds`, `claim_and_distribute_buybacks`, `distribute_proceeds` +- **Issuance controls**: `enable_low_issuance_mode`, `disable_low_issuance_mode`, + `force_enable_low_issuance_mode`, `force_disable_low_issuance_mode` - **Pool & order metadata**: `get_distribution_pool_key`, `get_distribution_pool_key_hash`, `get_distribution_order_key`, `get_pool_id`, `get_distribution_fee`, `get_buyback_order_config`, - `get_position_token_id` + `get_position_token_id`, `get_liquidity_position_id` - **Distribution telemetry**: `get_token_distribution_rate`, `get_tokens_for_distribution`, - `get_distribution_end_time`, `get_distribution_initial_tick`, `get_lords_price_x128`, - `get_dungeon_ticket_price_x128`, `get_survivor_price_x128` -- **Issuance controls**: `is_low_issuance_mode`, `get_low_issuance_returned_tokens`, - `get_issuance_reduction_price_x128`, `get_issuance_reduction_price_duration`, `get_issuance_reduction_bips` -- **Administrative controls**: `set_treasury_address`, `set_velords_address`, `set_issuance_reduction_price_duration`, + `get_distribution_end_time`, `get_distribution_initial_tick`, `get_dungeon_ticket_price_x128` +- **Issuance telemetry**: `is_low_issuance_mode`, `get_issuance_reduction_price_x128`, + `get_issuance_reduction_price_duration`, `get_issuance_reduction_bips` +- **Administrative controls**: `set_treasury_address`, `set_velords_address`, + `set_issuance_reduction_price_x128`, `set_issuance_reduction_price_duration`, + `set_issuance_reduction_bips`, `set_buyback_order_config`, `withdraw_erc721`, `withdraw_erc20` - **Deployment helpers**: `get_deployed_at`, `get_payment_token`, `get_buyback_token`, `get_extension_address`, `get_core_dispatcher`, `get_positions_dispatcher`, diff --git a/src/constants.cairo b/src/constants.cairo index 62073f8..032577c 100644 --- a/src/constants.cairo +++ b/src/constants.cairo @@ -22,6 +22,8 @@ pub mod Errors { pub const NO_TICKETS_AVAILABLE: felt252 = 'no tickets available'; pub const REDUCTION_BIPS_TOO_LARGE: felt252 = 'reduction bips too large'; pub const REDUCTION_DURATION_NOT_SET: felt252 = 'reduction duration not set'; + pub const LOW_ISSUANCE_CRITERIA_NOT_MET: felt252 = 'low issuance criteria not met'; + pub const DISABLE_LOW_ISSUANCE_CRITERIA_NOT_MET: felt252 = 'disable criteria not met'; } // Mathematical constants diff --git a/src/contract.cairo b/src/contract.cairo index 38e43d6..48e4d4c 100644 --- a/src/contract.cairo +++ b/src/contract.cairo @@ -3,7 +3,6 @@ pub mod TicketMaster { use core::array::ArrayTrait; use core::cmp::max; use core::hash::{HashStateExTrait, HashStateTrait}; - use core::num::traits::Zero; use core::poseidon::PoseidonTrait; use ekubo::interfaces::core::{ICoreDispatcher, ICoreDispatcherTrait}; use ekubo::interfaces::erc20::IERC20Dispatcher as IERC20DispatcherEkubo; @@ -56,6 +55,7 @@ pub mod TicketMaster { pub distribution_end_time: u64, pub distribution_pool_fee: u128, pub distribution_initial_tick: i129, + pub liquidity_position_id: u64, pub low_issuance_mode_active: bool, pub extension_address: ContractAddress, pub payment_token: ContractAddress, @@ -263,30 +263,31 @@ pub mod TicketMaster { // Step 1: Transfer payment tokens from caller to Ekubo's Positions contract let payment_token = self.payment_token.read(); - let positions_address = self.positions_dispatcher.read().contract_address; + let positions_dispatcher = self.positions_dispatcher.read(); let caller = starknet::get_caller_address(); let payment_token_dispatcher = IERC20Dispatcher { contract_address: payment_token }; let payment_amount_u256: u256 = payment_token_amount.into(); - payment_token_dispatcher.transfer_from(caller, positions_address, payment_amount_u256); + payment_token_dispatcher + .transfer_from(caller, positions_dispatcher.contract_address, payment_amount_u256); // Step 2: Mint new tokens directly to Ekubo's Positions contract - self.erc20.mint(positions_address, dungeon_ticket_amount_u256); + self.erc20.mint(positions_dispatcher.contract_address, dungeon_ticket_amount_u256); // Step 3: Create new position using distribution pool key let pool_key = _get_distribution_pool_key(@self); - let (position_id, liquidity, cleared_payment, cleared_our) = self - .positions_dispatcher - .read() + let (position_id, liquidity, cleared_payment, cleared_our) = positions_dispatcher .mint_and_deposit_and_clear_both(pool_key, TWAMM_BOUNDS, minimum_liquidity); // Transition to state 2 == liquidity provided self.deployment_state.write(2); // update the amount of tokens for distribution - let tokens_for_distribution = self.tokens_for_distribution.read(); let remaining_supply = tokens_for_distribution - dungeon_ticket_amount_u256; self.tokens_for_distribution.write(remaining_supply); + // store liquidity position id + self.liquidity_position_id.write(position_id); + // Return the position ID and cleared amounts (position_id, liquidity, cleared_payment, cleared_our) } @@ -333,37 +334,18 @@ pub mod TicketMaster { /// @notice Distributes the proceeds from selling tokens to the veLords and buybacks /// @dev This function should be called periodically to distribute proceeds - /// @param start_time The start time of the order + /// @dev start time for the order is 0 which will start order immediately /// @param end_time The end time of the order - fn distribute_proceeds(ref self: ContractState, start_time: u64, end_time: u64) { + fn distribute_proceeds(ref self: ContractState, end_time: u64) { assert(self.deployment_state.read() == 3, Errors::DISTRIBUTION_NOT_STARTED); - assert(self.pool_id.read() != 0, Errors::DISTRIBUTION_POOL_NOT_INITIALIZED); - assert(self.position_token_id.read() != 0, Errors::TOKEN_DISTRIBUTION_NOT_STARTED); let buyback_order_config = _get_buyback_order_config(@self); - let current_time = starknet::get_block_timestamp(); - assert(end_time > start_time, 'Invalid start or end time'); - let actual_start = max(current_time, start_time); - assert(end_time > actual_start, 'End time expired'); - let duration = end_time - actual_start; + assert(end_time > current_time, 'End time must be in the future'); + + let duration = end_time - current_time; assert(duration >= buyback_order_config.min_duration, 'Duration too short'); assert(duration <= buyback_order_config.max_duration, 'Duration too long'); - // Enforce the order starts within the min/max delay - if (buyback_order_config.min_delay.is_non_zero()) { - assert( - start_time > current_time - && (start_time - current_time) >= buyback_order_config.min_delay, - 'Order must start > min delay', - ); - } - // if it starts in the future, make sure it's not too far in the future - if (start_time > current_time) { - assert( - (start_time - current_time) < buyback_order_config.max_delay, - 'Order must start < max delay', - ); - } // get the amount of payment tokens in the contract let payment_token = self.payment_token.read(); @@ -386,7 +368,7 @@ pub mod TicketMaster { .transfer(positions_dispatcher.contract_address, amount_to_buybacks); let position_token_id = self.position_token_id.read(); - let order_key = _get_buyback_order_key(@self, start_time, end_time); + let order_key = _get_buyback_order_key(@self, 0, end_time); let sale_rate_increase = positions_dispatcher .increase_sell_amount( position_token_id, order_key, amount_to_buybacks.try_into().unwrap(), @@ -448,6 +430,9 @@ pub mod TicketMaster { let positions_dispatcher = self.positions_dispatcher.read(); let current_time = starknet::get_block_timestamp(); + // get a single mutable order key + let mut order_key = _retrieve_buyback_order_key(@self, 0); + while order_number < max_index { // get the end time of the order let order_key_end_time = self.buyback_order_key_end_time.read(order_number); @@ -458,7 +443,8 @@ pub mod TicketMaster { break; } - let order_key = _retrieve_buyback_order_key(@self, order_key_end_time); + // only difference between the orders is the end time + order_key.end_time = order_key_end_time; // otherwise, withdraw proceeds from the order total_proceeds += positions_dispatcher @@ -476,86 +462,32 @@ pub mod TicketMaster { (total_proceeds, order_number) } + /// @notice Forces the enabling of low issuance mode + /// @dev Only callable by the owner + fn force_enable_low_issuance_mode(ref self: ContractState) -> u128 { + self.ownable.assert_only_owner(); + _enable_low_issuance_mode(ref self) + } + /// @notice Reduces ticket issuance rate when pricing falls below set threshold + /// @dev Only callable when the low issuance criteria are met fn enable_low_issuance_mode(ref self: ContractState) -> u128 { - assert(self.deployment_state.read() == 3, Errors::DISTRIBUTION_NOT_STARTED); - assert(self.position_token_id.read() != 0, Errors::TOKEN_DISTRIBUTION_NOT_STARTED); - assert(!self.low_issuance_mode_active.read(), Errors::LOW_ISSUANCE_ALREADY_ACTIVE); - - let reduction_price = self.issuance_reduction_price_x128.read(); - assert(reduction_price > 0, Errors::REDUCTION_PRICE_NOT_SET); - - let reduction_bips = self.issuance_reduction_bips.read(); - assert(reduction_bips > 0, Errors::REDUCTION_BIPS_NOT_SET); - - let reduction_duration = self.issuance_reduction_price_duration.read(); - assert(reduction_duration > 0, Errors::REDUCTION_DURATION_NOT_SET); - - let average_price = _get_dungeon_ticket_price_x128(@self, reduction_duration); - assert(average_price < reduction_price, Errors::PRICE_NOT_BELOW_REDUCTION_THRESHOLD); - - let current_rate = self.token_distribution_rate.read(); - assert(current_rate > 0, Errors::TOKEN_DISTRIBUTION_NOT_STARTED); - - let rate_delta = (current_rate * reduction_bips) / BIPS_BASIS; - assert(rate_delta > 0, Errors::REDUCTION_RATE_TOO_SMALL); - assert(rate_delta <= current_rate, Errors::REDUCTION_RATE_TOO_LARGE); - - let positions_dispatcher = self.positions_dispatcher.read(); - let position_token_id = self.position_token_id.read(); - let order_key = _get_distribution_order_key(@self); - - let returned_tokens = positions_dispatcher - .decrease_sale_rate_to_self(position_token_id, order_key, rate_delta); - assert(returned_tokens > 0, Errors::LOW_ISSUANCE_TOKENS_MISSING); - - self.token_distribution_rate.write(current_rate - rate_delta); - self.low_issuance_mode_active.write(true); + _assert_enable_low_issuance_criteria_met(@self); + _enable_low_issuance_mode(ref self) + } - returned_tokens + /// @notice Forces the disabling of low issuance mode + /// @dev Only callable by the owner + fn force_disable_low_issuance_mode(ref self: ContractState) -> u128 { + self.ownable.assert_only_owner(); + _disable_low_issuance_mode(ref self) } /// @notice Restores the ticket issuance rate when pricing conditions recover - fn disable_low_issuance_mode(ref self: ContractState) { - assert(self.low_issuance_mode_active.read(), Errors::LOW_ISSUANCE_NOT_ACTIVE); - - let reduction_price = self.issuance_reduction_price_x128.read(); - assert(reduction_price > 0, Errors::REDUCTION_PRICE_NOT_SET); - - let reduction_duration = self.issuance_reduction_price_duration.read(); - assert(reduction_duration > 0, Errors::REDUCTION_DURATION_NOT_SET); - - let average_price = _get_dungeon_ticket_price_x128(@self, reduction_duration); - assert(average_price > reduction_price, Errors::PRICE_NOT_ABOVE_REDUCTION_THRESHOLD); - - // get all dungeon tickets in the contract - let tickets_in_contract = self.erc20.balance_of(get_contract_address()); - assert(tickets_in_contract > 0, Errors::NO_TICKETS_AVAILABLE); - - // transfer available tickets to positions contract - let positions_dispatcher = self.positions_dispatcher.read(); - self - .erc20 - ._transfer( - get_contract_address(), - positions_dispatcher.contract_address, - tickets_in_contract, - ); - - // increase the sale rate of the position token - let previous_order_key = _get_distribution_order_key(@self); - let position_token_id = self.position_token_id.read(); - let sale_rate_increase = positions_dispatcher - .increase_sell_amount( - position_token_id, previous_order_key, tickets_in_contract.try_into().unwrap(), - ); - - // update the token distribution rate - let current_rate = self.token_distribution_rate.read(); - self.token_distribution_rate.write(current_rate + sale_rate_increase); - - // disable low issuance mode - self.low_issuance_mode_active.write(false); + /// @dev Only callable when the low issuance criteria are met + fn disable_low_issuance_mode(ref self: ContractState) -> u128 { + _assert_disable_low_issuance_criteria_met(@self); + _disable_low_issuance_mode(ref self) } /// @notice Sets the reduction duration for the low issuance mode @@ -712,6 +644,12 @@ pub mod TicketMaster { _get_position_token_id(self) } + /// @notice Returns the ID of the liquidity position + /// @return u64 The ID of the liquidity position + fn get_liquidity_position_id(self: @ContractState) -> u64 { + _get_liquidity_position_id(self) + } + /// @notice Returns the address of the payment token /// @return ContractAddress The address of the payment token fn get_payment_token(self: @ContractState) -> ContractAddress { @@ -903,6 +841,11 @@ pub mod TicketMaster { self.position_token_id.read() } + #[inline(always)] + fn _get_liquidity_position_id(self: @ContractState) -> u64 { + self.liquidity_position_id.read() + } + #[inline(always)] fn _get_payment_token(self: @ContractState) -> ContractAddress { self.payment_token.read() @@ -963,6 +906,104 @@ pub mod TicketMaster { self.low_issuance_mode_active.read() } + #[inline(always)] + fn _enable_low_issuance_criteria_met(self: @ContractState) -> bool { + let reduction_price = self.issuance_reduction_price_x128.read(); + assert(reduction_price > 0, Errors::REDUCTION_PRICE_NOT_SET); + + let reduction_duration = self.issuance_reduction_price_duration.read(); + assert(reduction_duration > 0, Errors::REDUCTION_DURATION_NOT_SET); + + let average_price = _get_dungeon_ticket_price_x128(self, reduction_duration); + average_price < reduction_price + } + + #[inline(always)] + fn _assert_enable_low_issuance_criteria_met(self: @ContractState) { + assert(_enable_low_issuance_criteria_met(self), Errors::LOW_ISSUANCE_CRITERIA_NOT_MET); + } + + #[inline(always)] + fn _enable_low_issuance_mode(ref self: ContractState) -> u128 { + assert(self.deployment_state.read() == 3, Errors::DISTRIBUTION_NOT_STARTED); + assert(self.position_token_id.read() != 0, Errors::TOKEN_DISTRIBUTION_NOT_STARTED); + assert(!self.low_issuance_mode_active.read(), Errors::LOW_ISSUANCE_ALREADY_ACTIVE); + + let reduction_bips = self.issuance_reduction_bips.read(); + assert(reduction_bips > 0, Errors::REDUCTION_BIPS_NOT_SET); + + let current_rate = self.token_distribution_rate.read(); + assert(current_rate > 0, Errors::TOKEN_DISTRIBUTION_NOT_STARTED); + + let rate_delta = (current_rate * reduction_bips) / BIPS_BASIS; + assert(rate_delta > 0, Errors::REDUCTION_RATE_TOO_SMALL); + assert(rate_delta <= current_rate, Errors::REDUCTION_RATE_TOO_LARGE); + + let positions_dispatcher = self.positions_dispatcher.read(); + let position_token_id = self.position_token_id.read(); + let order_key = _get_distribution_order_key(@self); + let returned_tokens = positions_dispatcher + .decrease_sale_rate_to_self(position_token_id, order_key, rate_delta); + + assert(returned_tokens > 0, Errors::LOW_ISSUANCE_TOKENS_MISSING); + + self.token_distribution_rate.write(current_rate - rate_delta); + self.low_issuance_mode_active.write(true); + + returned_tokens + } + + fn _disable_low_issuance_mode_criteria_met(self: @ContractState) -> bool { + let reduction_price = self.issuance_reduction_price_x128.read(); + assert(reduction_price > 0, Errors::REDUCTION_PRICE_NOT_SET); + + let reduction_duration = self.issuance_reduction_price_duration.read(); + assert(reduction_duration > 0, Errors::REDUCTION_DURATION_NOT_SET); + + let average_price = _get_dungeon_ticket_price_x128(self, reduction_duration); + average_price > reduction_price + } + + fn _assert_disable_low_issuance_criteria_met(self: @ContractState) { + assert( + _disable_low_issuance_mode_criteria_met(self), + Errors::DISABLE_LOW_ISSUANCE_CRITERIA_NOT_MET, + ); + } + + fn _disable_low_issuance_mode(ref self: ContractState) -> u128 { + assert(self.low_issuance_mode_active.read(), Errors::LOW_ISSUANCE_NOT_ACTIVE); + + let tickets_in_contract = self.erc20.balance_of(get_contract_address()); + assert(tickets_in_contract > 0, Errors::NO_TICKETS_AVAILABLE); + + // transfer available tickets to positions contract + let positions_dispatcher = self.positions_dispatcher.read(); + self + .erc20 + ._transfer( + get_contract_address(), positions_dispatcher.contract_address, tickets_in_contract, + ); + + // increase the sale rate of the position token + let previous_order_key = _get_distribution_order_key(@self); + let position_token_id = self.position_token_id.read(); + let sale_rate_increase = positions_dispatcher + .increase_sell_amount( + position_token_id, previous_order_key, tickets_in_contract.try_into().unwrap(), + ); + + // update the token distribution rate + let current_rate = self.token_distribution_rate.read(); + let new_rate = current_rate + sale_rate_increase; + self.token_distribution_rate.write(new_rate); + + // disable low issuance mode + self.low_issuance_mode_active.write(false); + + new_rate + } + fn _get_dungeon_ticket_price_x128(self: @ContractState, duration: u64) -> u256 { let oracle_dispatcher = self.oracle_address.read(); oracle_dispatcher @@ -1175,7 +1216,7 @@ pub mod TicketMaster { let registry_dispatcher = self.registry_dispatcher.read(); let erc20_dispatcher = IERC20DispatcherEkubo { contract_address: get_contract_address() }; - // mint one token to theregistry contract + // mint one token to the registry contract self.erc20.mint(registry_dispatcher.contract_address, ERC20_UNIT.into()); // call register_token on the registry contract @@ -1207,17 +1248,18 @@ pub mod TicketMaster { // Ensure recipient is not zero address assert(recipient != zero_address, 'Invalid recipient address'); - // Update total and check it doesn't exceed supply - total_distributed += amount; - assert(total_distributed <= total_supply.into(), 'Distribution exceeds supply'); - // Mint tokens directly to recipient self.erc20.mint(recipient, amount); + // Update total and check it doesn't exceed supply + total_distributed += amount; + i += 1; }; } + assert(total_distributed <= total_supply.into(), 'Distribution exceeds supply'); + total_distributed } diff --git a/src/interfaces.cairo b/src/interfaces.cairo index 2c59fa0..5e2745c 100644 --- a/src/interfaces.cairo +++ b/src/interfaces.cairo @@ -19,9 +19,11 @@ pub trait ITicketMaster { fn start_token_distribution(ref self: TContractState) -> (u64, u128); fn claim_proceeds(ref self: TContractState) -> u128; fn claim_and_distribute_buybacks(ref self: TContractState, limit: u16) -> (u128, u128); - fn distribute_proceeds(ref self: TContractState, start_time: u64, end_time: u64); + fn distribute_proceeds(ref self: TContractState, end_time: u64); fn enable_low_issuance_mode(ref self: TContractState) -> u128; - fn disable_low_issuance_mode(ref self: TContractState); + fn force_enable_low_issuance_mode(ref self: TContractState) -> u128; + fn disable_low_issuance_mode(ref self: TContractState) -> u128; + fn force_disable_low_issuance_mode(ref self: TContractState) -> u128; fn burn(ref self: TContractState, amount: u256); fn burn_from(ref self: TContractState, from: ContractAddress, amount: u256); fn set_buyback_order_config(ref self: TContractState, config: BuybackOrderConfig); @@ -50,6 +52,7 @@ pub trait ITicketMaster { fn get_buyback_order_config(self: @TContractState) -> BuybackOrderConfig; fn get_pool_id(self: @TContractState) -> u256; fn get_position_token_id(self: @TContractState) -> u64; + fn get_liquidity_position_id(self: @TContractState) -> u64; fn get_payment_token(self: @TContractState) -> ContractAddress; fn get_extension_address(self: @TContractState) -> ContractAddress; fn get_buyback_token(self: @TContractState) -> ContractAddress; diff --git a/tests/test_contract.cairo b/tests/test_contract.cairo index f614b56..8978a43 100644 --- a/tests/test_contract.cairo +++ b/tests/test_contract.cairo @@ -2658,6 +2658,7 @@ fn enable_low_issuance_mode_rejects_before_distribution_started() { ); // Try to enable low issuance mode before distribution starts (should fail) + mock_call(EKUBO_ORACLE_MAINNET, selector!("get_price_x128_over_last"), 100_u256, 1); ticket_master_dispatcher.enable_low_issuance_mode(); } @@ -2703,7 +2704,7 @@ fn enable_low_issuance_mode_rejects_when_already_active() { // Enable low issuance mode once (mock price below threshold) let low_price: u256 = ISSUANCE_REDUCTION_PRICE_X128 - 1; - mock_call(EKUBO_ORACLE_MAINNET, selector!("get_price_x128_over_last"), low_price, 1); + mock_call(EKUBO_ORACLE_MAINNET, selector!("get_price_x128_over_last"), low_price, 2); mock_call(MOCK_POSITIONS_ADDRESS, selector!("decrease_sale_rate_to_self"), 100_u128, 1); ticket_master_dispatcher.enable_low_issuance_mode(); @@ -2712,7 +2713,7 @@ fn enable_low_issuance_mode_rejects_when_already_active() { } #[test] -#[should_panic(expected: 'price not below threshold')] +#[should_panic(expected: 'low issuance criteria not met')] fn enable_low_issuance_mode_rejects_when_price_not_below_threshold() { start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); mock_ekubo_core(1_u256); @@ -2760,7 +2761,7 @@ fn enable_low_issuance_mode_rejects_when_price_not_below_threshold() { } #[test] -#[should_panic(expected: 'price not below threshold')] +#[should_panic(expected: 'low issuance criteria not met')] fn enable_low_issuance_mode_rejects_when_price_equals_threshold() { start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); mock_ekubo_core(1_u256); @@ -2867,7 +2868,7 @@ fn enable_low_issuance_mode_succeeds_when_price_below_threshold() { } #[test] -#[should_panic(expected: 'low issuance not active')] +#[should_panic(expected: 'disable criteria not met')] fn disable_low_issuance_mode_rejects_when_not_active() { start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); mock_ekubo_core(1_u256); @@ -2907,11 +2908,12 @@ fn disable_low_issuance_mode_rejects_when_not_active() { ticket_master_dispatcher.start_token_distribution(); // Try to disable when not active (should fail) + mock_call(EKUBO_ORACLE_MAINNET, selector!("get_price_x128_over_last"), 100_u256, 1); ticket_master_dispatcher.disable_low_issuance_mode(); } #[test] -#[should_panic(expected: 'price not above threshold')] +#[should_panic(expected: 'disable criteria not met')] fn disable_low_issuance_mode_rejects_when_price_not_above_threshold() { start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); mock_ekubo_core(1_u256); @@ -2964,7 +2966,7 @@ fn disable_low_issuance_mode_rejects_when_price_not_above_threshold() { } #[test] -#[should_panic(expected: 'price not above threshold')] +#[should_panic(expected: 'disable criteria not met')] fn disable_low_issuance_mode_rejects_when_price_equals_threshold() { start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); mock_ekubo_core(1_u256); @@ -3165,6 +3167,237 @@ fn disable_low_issuance_mode_succeeds_when_price_above_threshold() { assert!(!ticket_master_dispatcher.is_low_issuance_mode()); } +#[test] +fn force_enable_low_issuance_mode_succeeds_for_owner() { + start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); + mock_ekubo_core(1_u256); + + let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( + MOCK_CORE_ADDRESS, + MOCK_POSITIONS_ADDRESS, + MOCK_POSITION_NFT_ADDRESS, + MOCK_TWAMM_EXTENSION_ADDRESS, + MOCK_REGISTRY_ADDRESS, + EKUBO_ORACLE_MAINNET, + MOCK_VELORDS_ADDRESS, + ISSUANCE_REDUCTION_PRICE_X128, + ISSUANCE_REDUCTION_PRICE_DURATION, + ISSUANCE_REDUCTION_BIPS, + MOCK_TREASURY, + ); + + // Full setup + ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); + mock_call(payment_token_dispatcher.contract_address, selector!("transfer_from"), true, 1); + mock_call( + MOCK_POSITIONS_ADDRESS, + selector!("mint_and_deposit_and_clear_both"), + (10_u64, 100_u128, 0_u256, 0_u256), + 1, + ); + ticket_master_dispatcher + .provide_initial_liquidity( + INITIAL_LIQUIDITY_PAYMENT_TOKEN, + INITIAL_LIQUIDITY_DUNGEON_TICKETS, + INITIAL_LIQUIDITY_MIN_LIQUIDITY, + ); + mock_call( + MOCK_POSITIONS_ADDRESS, selector!("mint_and_increase_sell_amount"), (77_u64, 888_u128), 1, + ); + ticket_master_dispatcher.start_token_distribution(); + + let initial_rate = ticket_master_dispatcher.get_token_distribution_rate(); + + // Mock decrease_sale_rate_to_self to return tokens + mock_call(MOCK_POSITIONS_ADDRESS, selector!("decrease_sale_rate_to_self"), 100_u128, 1); + + // Force enable should succeed regardless of price (owner only) + let returned_tokens = ticket_master_dispatcher.force_enable_low_issuance_mode(); + + // Verify state changes + assert!(ticket_master_dispatcher.is_low_issuance_mode()); + assert!(returned_tokens > 0); + assert!(ticket_master_dispatcher.get_token_distribution_rate() < initial_rate); +} + +#[test] +#[should_panic(expected: ('Caller is not the owner',))] +fn force_enable_low_issuance_mode_rejects_non_owner() { + start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); + mock_ekubo_core(1_u256); + + let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( + MOCK_CORE_ADDRESS, + MOCK_POSITIONS_ADDRESS, + MOCK_POSITION_NFT_ADDRESS, + MOCK_TWAMM_EXTENSION_ADDRESS, + MOCK_REGISTRY_ADDRESS, + EKUBO_ORACLE_MAINNET, + MOCK_VELORDS_ADDRESS, + ISSUANCE_REDUCTION_PRICE_X128, + ISSUANCE_REDUCTION_PRICE_DURATION, + ISSUANCE_REDUCTION_BIPS, + MOCK_TREASURY, + ); + + // Full setup + ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); + mock_call(payment_token_dispatcher.contract_address, selector!("transfer_from"), true, 1); + mock_call( + MOCK_POSITIONS_ADDRESS, + selector!("mint_and_deposit_and_clear_both"), + (10_u64, 100_u128, 0_u256, 0_u256), + 1, + ); + ticket_master_dispatcher + .provide_initial_liquidity( + INITIAL_LIQUIDITY_PAYMENT_TOKEN, + INITIAL_LIQUIDITY_DUNGEON_TICKETS, + INITIAL_LIQUIDITY_MIN_LIQUIDITY, + ); + mock_call( + MOCK_POSITIONS_ADDRESS, selector!("mint_and_increase_sell_amount"), (77_u64, 888_u128), 1, + ); + ticket_master_dispatcher.start_token_distribution(); + + // Change caller to non-owner + stop_cheat_caller_address(ticket_master_dispatcher.contract_address); + let non_owner: ContractAddress = 'non_owner'.try_into().unwrap(); + start_cheat_caller_address(ticket_master_dispatcher.contract_address, non_owner); + + // Should panic with 'Caller is not the owner' + ticket_master_dispatcher.force_enable_low_issuance_mode(); +} + +#[test] +fn force_disable_low_issuance_mode_succeeds_for_owner() { + start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); + mock_ekubo_core(1_u256); + + let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( + MOCK_CORE_ADDRESS, + MOCK_POSITIONS_ADDRESS, + MOCK_POSITION_NFT_ADDRESS, + MOCK_TWAMM_EXTENSION_ADDRESS, + MOCK_REGISTRY_ADDRESS, + EKUBO_ORACLE_MAINNET, + MOCK_VELORDS_ADDRESS, + ISSUANCE_REDUCTION_PRICE_X128, + ISSUANCE_REDUCTION_PRICE_DURATION, + ISSUANCE_REDUCTION_BIPS, + MOCK_TREASURY, + ); + + // Full setup + ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); + mock_call(payment_token_dispatcher.contract_address, selector!("transfer_from"), true, 1); + mock_call( + MOCK_POSITIONS_ADDRESS, + selector!("mint_and_deposit_and_clear_both"), + (10_u64, 100_u128, 0_u256, 0_u256), + 1, + ); + ticket_master_dispatcher + .provide_initial_liquidity( + INITIAL_LIQUIDITY_PAYMENT_TOKEN, + INITIAL_LIQUIDITY_DUNGEON_TICKETS, + INITIAL_LIQUIDITY_MIN_LIQUIDITY, + ); + mock_call( + MOCK_POSITIONS_ADDRESS, selector!("mint_and_increase_sell_amount"), (77_u64, 888_u128), 1, + ); + ticket_master_dispatcher.start_token_distribution(); + + // Enable low issuance mode first + let low_price: u256 = ISSUANCE_REDUCTION_PRICE_X128 - 1000; + mock_call(EKUBO_ORACLE_MAINNET, selector!("get_price_x128_over_last"), low_price, 1); + let returned_tokens = 100_u128; + mock_call(MOCK_POSITIONS_ADDRESS, selector!("decrease_sale_rate_to_self"), returned_tokens, 1); + ticket_master_dispatcher.enable_low_issuance_mode(); + + // Verify it's enabled + assert!(ticket_master_dispatcher.is_low_issuance_mode()); + + // Transfer returned tokens to contract (simulating what decrease_sale_rate_to_self does) + start_cheat_caller_address(ticket_master_dispatcher.contract_address, MOCK_POSITIONS_ADDRESS); + let dungeon_ticket_dispatcher = IERC20Dispatcher { + contract_address: ticket_master_dispatcher.contract_address, + }; + dungeon_ticket_dispatcher + .transfer(ticket_master_dispatcher.contract_address, returned_tokens.into()); + stop_cheat_caller_address(ticket_master_dispatcher.contract_address); + start_cheat_caller_address(ticket_master_dispatcher.contract_address, DEPLOYER_ADDRESS); + + // Mock necessary calls for disable + mock_call(MOCK_POSITIONS_ADDRESS, selector!("withdraw_proceeds_from_sale_to_self"), 0_u128, 1); + mock_call(MOCK_POSITIONS_ADDRESS, selector!("increase_sell_amount"), returned_tokens, 1); + + // Force disable should succeed regardless of price (owner only) + ticket_master_dispatcher.force_disable_low_issuance_mode(); + + // Verify it's disabled + assert!(!ticket_master_dispatcher.is_low_issuance_mode()); +} + +#[test] +#[should_panic(expected: ('Caller is not the owner',))] +fn force_disable_low_issuance_mode_rejects_non_owner() { + start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); + mock_ekubo_core(1_u256); + + let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( + MOCK_CORE_ADDRESS, + MOCK_POSITIONS_ADDRESS, + MOCK_POSITION_NFT_ADDRESS, + MOCK_TWAMM_EXTENSION_ADDRESS, + MOCK_REGISTRY_ADDRESS, + EKUBO_ORACLE_MAINNET, + MOCK_VELORDS_ADDRESS, + ISSUANCE_REDUCTION_PRICE_X128, + ISSUANCE_REDUCTION_PRICE_DURATION, + ISSUANCE_REDUCTION_BIPS, + MOCK_TREASURY, + ); + + // Full setup + ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); + mock_call(payment_token_dispatcher.contract_address, selector!("transfer_from"), true, 1); + mock_call( + MOCK_POSITIONS_ADDRESS, + selector!("mint_and_deposit_and_clear_both"), + (10_u64, 100_u128, 0_u256, 0_u256), + 1, + ); + ticket_master_dispatcher + .provide_initial_liquidity( + INITIAL_LIQUIDITY_PAYMENT_TOKEN, + INITIAL_LIQUIDITY_DUNGEON_TICKETS, + INITIAL_LIQUIDITY_MIN_LIQUIDITY, + ); + mock_call( + MOCK_POSITIONS_ADDRESS, selector!("mint_and_increase_sell_amount"), (77_u64, 888_u128), 1, + ); + ticket_master_dispatcher.start_token_distribution(); + + // Enable low issuance mode first + let low_price: u256 = ISSUANCE_REDUCTION_PRICE_X128 - 1000; + mock_call(EKUBO_ORACLE_MAINNET, selector!("get_price_x128_over_last"), low_price, 1); + let returned_tokens = 100_u128; + mock_call(MOCK_POSITIONS_ADDRESS, selector!("decrease_sale_rate_to_self"), returned_tokens, 1); + ticket_master_dispatcher.enable_low_issuance_mode(); + + // Verify it's enabled + assert!(ticket_master_dispatcher.is_low_issuance_mode()); + + // Change caller to non-owner + stop_cheat_caller_address(ticket_master_dispatcher.contract_address); + let non_owner: ContractAddress = 'non_owner'.try_into().unwrap(); + start_cheat_caller_address(ticket_master_dispatcher.contract_address, non_owner); + + // Should panic with 'Caller is not the owner' + ticket_master_dispatcher.force_disable_low_issuance_mode(); +} + // Administrative Functions Tests #[test] @@ -3611,7 +3844,7 @@ fn distribute_proceeds_before_pool_initialized() { MOCK_TREASURY, ); - ticket_master_dispatcher.distribute_proceeds(1, 5); + ticket_master_dispatcher.distribute_proceeds(5); } #[test] @@ -3636,7 +3869,7 @@ fn distribute_proceeds_before_distribution_started() { let _ = ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); - ticket_master_dispatcher.distribute_proceeds(1, 5); + ticket_master_dispatcher.distribute_proceeds(5); } // ================================ @@ -3644,51 +3877,7 @@ fn distribute_proceeds_before_distribution_started() { // ================================ #[test] -#[should_panic(expected: 'Invalid start or end time')] -fn distribute_proceeds_rejects_end_before_start() { - start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); - mock_ekubo_core(1_u256); - - let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( - MOCK_CORE_ADDRESS, - MOCK_POSITIONS_ADDRESS, - MOCK_POSITION_NFT_ADDRESS, - MOCK_TWAMM_EXTENSION_ADDRESS, - MOCK_REGISTRY_ADDRESS, - EKUBO_ORACLE_MAINNET, - MOCK_VELORDS_ADDRESS, - ISSUANCE_REDUCTION_PRICE_X128, - ISSUANCE_REDUCTION_PRICE_DURATION, - ISSUANCE_REDUCTION_BIPS, - MOCK_TREASURY, - ); - - // Setup through distribution - ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); - mock_call(payment_token_dispatcher.contract_address, selector!("transfer_from"), true, 1); - mock_call( - MOCK_POSITIONS_ADDRESS, - selector!("mint_and_deposit_and_clear_both"), - (10_u64, 100_u128, 0_u256, 0_u256), - 1, - ); - ticket_master_dispatcher - .provide_initial_liquidity( - INITIAL_LIQUIDITY_PAYMENT_TOKEN, - INITIAL_LIQUIDITY_DUNGEON_TICKETS, - INITIAL_LIQUIDITY_MIN_LIQUIDITY, - ); - mock_call( - MOCK_POSITIONS_ADDRESS, selector!("mint_and_increase_sell_amount"), (77_u64, 888_u128), 1, - ); - ticket_master_dispatcher.start_token_distribution(); - - // Try to distribute with end_time <= start_time - ticket_master_dispatcher.distribute_proceeds(100_u64, 100_u64); -} - -#[test] -#[should_panic(expected: 'End time expired')] +#[should_panic(expected: 'End time must be in the future')] fn distribute_proceeds_rejects_expired_end_time() { start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); mock_ekubo_core(1_u256); @@ -3732,7 +3921,7 @@ fn distribute_proceeds_rejects_expired_end_time() { start_cheat_block_timestamp_global(future_time); // Try to distribute with end_time in the past (start_time in past, end_time also in past) - ticket_master_dispatcher.distribute_proceeds(100_u64, 200_u64); + ticket_master_dispatcher.distribute_proceeds(200_u64); } #[test] @@ -3780,7 +3969,7 @@ fn distribute_proceeds_rejects_duration_too_short() { // Try to distribute with duration < min_duration let short_duration = config.min_duration - 1; - ticket_master_dispatcher.distribute_proceeds(current_time, current_time + short_duration); + ticket_master_dispatcher.distribute_proceeds(current_time + short_duration); } #[test] @@ -3828,172 +4017,7 @@ fn distribute_proceeds_rejects_duration_too_long() { // Try to distribute with duration > max_duration let long_duration = config.max_duration + 1; - ticket_master_dispatcher.distribute_proceeds(current_time, current_time + long_duration); -} - -#[test] -#[should_panic(expected: 'Order must start < max delay')] -fn distribute_proceeds_rejects_delay_exceeds_max() { - start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); - mock_ekubo_core(1_u256); - - let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( - MOCK_CORE_ADDRESS, - MOCK_POSITIONS_ADDRESS, - MOCK_POSITION_NFT_ADDRESS, - MOCK_TWAMM_EXTENSION_ADDRESS, - MOCK_REGISTRY_ADDRESS, - EKUBO_ORACLE_MAINNET, - MOCK_VELORDS_ADDRESS, - ISSUANCE_REDUCTION_PRICE_X128, - ISSUANCE_REDUCTION_PRICE_DURATION, - ISSUANCE_REDUCTION_BIPS, - MOCK_TREASURY, - ); - - // Setup through distribution - ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); - mock_call(payment_token_dispatcher.contract_address, selector!("transfer_from"), true, 1); - mock_call( - MOCK_POSITIONS_ADDRESS, - selector!("mint_and_deposit_and_clear_both"), - (10_u64, 100_u128, 0_u256, 0_u256), - 1, - ); - ticket_master_dispatcher - .provide_initial_liquidity( - INITIAL_LIQUIDITY_PAYMENT_TOKEN, - INITIAL_LIQUIDITY_DUNGEON_TICKETS, - INITIAL_LIQUIDITY_MIN_LIQUIDITY, - ); - mock_call( - MOCK_POSITIONS_ADDRESS, selector!("mint_and_increase_sell_amount"), (77_u64, 888_u128), 1, - ); - ticket_master_dispatcher.start_token_distribution(); - - // Give contract some payment token balance - mock_call(payment_token_dispatcher.contract_address, selector!("balance_of"), 1000_u256, 1); - - let current_time = starknet::get_block_timestamp(); - let config = ticket_master_dispatcher.get_buyback_order_config(); - - // Try to distribute with delay >= max_delay (should fail) - let start_time = current_time + config.max_delay; - let end_time = start_time + config.min_duration; - ticket_master_dispatcher.distribute_proceeds(start_time, end_time); -} - -#[test] -fn distribute_proceeds_accepts_delay_just_under_max() { - start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); - mock_ekubo_core(1_u256); - - let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( - MOCK_CORE_ADDRESS, - MOCK_POSITIONS_ADDRESS, - MOCK_POSITION_NFT_ADDRESS, - MOCK_TWAMM_EXTENSION_ADDRESS, - MOCK_REGISTRY_ADDRESS, - EKUBO_ORACLE_MAINNET, - MOCK_VELORDS_ADDRESS, - ISSUANCE_REDUCTION_PRICE_X128, - ISSUANCE_REDUCTION_PRICE_DURATION, - ISSUANCE_REDUCTION_BIPS, - MOCK_TREASURY, - ); - - // Setup through distribution - ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); - mock_call(payment_token_dispatcher.contract_address, selector!("transfer_from"), true, 1); - mock_call( - MOCK_POSITIONS_ADDRESS, - selector!("mint_and_deposit_and_clear_both"), - (10_u64, 100_u128, 0_u256, 0_u256), - 1, - ); - ticket_master_dispatcher - .provide_initial_liquidity( - INITIAL_LIQUIDITY_PAYMENT_TOKEN, - INITIAL_LIQUIDITY_DUNGEON_TICKETS, - INITIAL_LIQUIDITY_MIN_LIQUIDITY, - ); - mock_call( - MOCK_POSITIONS_ADDRESS, selector!("mint_and_increase_sell_amount"), (77_u64, 888_u128), 1, - ); - ticket_master_dispatcher.start_token_distribution(); - - // Transfer payment tokens to the contract (as proceeds) - start_cheat_caller_address(payment_token_dispatcher.contract_address, DEPLOYER_ADDRESS); - payment_token_dispatcher.transfer(ticket_master_dispatcher.contract_address, 1000_u256); - stop_cheat_caller_address(payment_token_dispatcher.contract_address); - - // Mock TWAMM position increase - mock_call(MOCK_POSITIONS_ADDRESS, selector!("increase_sell_amount"), 888_u128, 1); - - let current_time = starknet::get_block_timestamp(); - let config = ticket_master_dispatcher.get_buyback_order_config(); - - // Distribute with delay just under max_delay (should succeed) - let start_time = current_time + config.max_delay - 1; - let end_time = start_time + config.min_duration; - ticket_master_dispatcher.distribute_proceeds(start_time, end_time); -} - -#[test] -fn distribute_proceeds_accepts_delay_in_valid_range() { - start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0); - mock_ekubo_core(1_u256); - - let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( - MOCK_CORE_ADDRESS, - MOCK_POSITIONS_ADDRESS, - MOCK_POSITION_NFT_ADDRESS, - MOCK_TWAMM_EXTENSION_ADDRESS, - MOCK_REGISTRY_ADDRESS, - EKUBO_ORACLE_MAINNET, - MOCK_VELORDS_ADDRESS, - ISSUANCE_REDUCTION_PRICE_X128, - ISSUANCE_REDUCTION_PRICE_DURATION, - ISSUANCE_REDUCTION_BIPS, - MOCK_TREASURY, - ); - - // Setup through distribution - ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); - mock_call(payment_token_dispatcher.contract_address, selector!("transfer_from"), true, 1); - mock_call( - MOCK_POSITIONS_ADDRESS, - selector!("mint_and_deposit_and_clear_both"), - (10_u64, 100_u128, 0_u256, 0_u256), - 1, - ); - ticket_master_dispatcher - .provide_initial_liquidity( - INITIAL_LIQUIDITY_PAYMENT_TOKEN, - INITIAL_LIQUIDITY_DUNGEON_TICKETS, - INITIAL_LIQUIDITY_MIN_LIQUIDITY, - ); - mock_call( - MOCK_POSITIONS_ADDRESS, selector!("mint_and_increase_sell_amount"), (77_u64, 888_u128), 1, - ); - ticket_master_dispatcher.start_token_distribution(); - - // Transfer payment tokens to the contract (as proceeds) - start_cheat_caller_address(payment_token_dispatcher.contract_address, DEPLOYER_ADDRESS); - payment_token_dispatcher.transfer(ticket_master_dispatcher.contract_address, 1000_u256); - stop_cheat_caller_address(payment_token_dispatcher.contract_address); - - // Mock TWAMM position increase - mock_call(MOCK_POSITIONS_ADDRESS, selector!("increase_sell_amount"), 888_u128, 1); - - let current_time = starknet::get_block_timestamp(); - let config = ticket_master_dispatcher.get_buyback_order_config(); - - // Distribute with delay in middle of valid range (should succeed) - let delay = config.max_delay / 2; - let start_time = current_time + delay; - let end_time = start_time + config.min_duration; - ticket_master_dispatcher.distribute_proceeds(start_time, end_time); + ticket_master_dispatcher.distribute_proceeds(current_time + long_duration); } #[test] @@ -4040,7 +4064,7 @@ fn distribute_proceeds_rejects_when_no_balance() { let config = ticket_master_dispatcher.get_buyback_order_config(); // Try to distribute with no proceeds in contract (balance_of returns 0 by default) - ticket_master_dispatcher.distribute_proceeds(current_time, current_time + config.min_duration); + ticket_master_dispatcher.distribute_proceeds(current_time + config.min_duration); } #[test] @@ -6099,6 +6123,51 @@ fn mainnet_full() { assert!(!ticket_master_dispatcher.is_low_issuance_mode(), "Low issuance should be inactive"); } +#[test] +#[fork("mainnet")] +fn test_get_liquidity_position_id_matches_provide_initial_liquidity() { + let (ticket_master_dispatcher, payment_token_dispatcher, _) = setup( + MAINNET_CORE_ADDRESS, + MAINNET_POSITIONS_ADDRESS, + MAINNET_POSITION_NFT_ADDRESS, + MAINNET_TWAMM_EXTENSION_ADDRESS, + MAINNET_REGISTRY_ADDRESS, + EKUBO_ORACLE_MAINNET, + MOCK_VELORDS_ADDRESS, + ISSUANCE_REDUCTION_PRICE_X128, + ISSUANCE_REDUCTION_PRICE_DURATION, + ISSUANCE_REDUCTION_BIPS, + MAINNET_TREASURY, + ); + + ticket_master_dispatcher.init_distribution_pool(DISTRIBUTION_INITIAL_TICK); + + start_cheat_caller_address(payment_token_dispatcher.contract_address, DEPLOYER_ADDRESS); + payment_token_dispatcher + .approve( + ticket_master_dispatcher.contract_address, + INITIAL_LIQUIDITY_PAYMENT_TOKEN.into() * 1000, + ); + payment_token_dispatcher + .approve(MAINNET_POSITIONS_ADDRESS, INITIAL_LIQUIDITY_PAYMENT_TOKEN.into() * 1000); + stop_cheat_caller_address(payment_token_dispatcher.contract_address); + + cheat_caller_address( + ticket_master_dispatcher.contract_address, DEPLOYER_ADDRESS, CheatSpan::TargetCalls(1), + ); + let (position_id, _, _, _) = ticket_master_dispatcher + .provide_initial_liquidity( + INITIAL_LIQUIDITY_PAYMENT_TOKEN, + INITIAL_LIQUIDITY_DUNGEON_TICKETS, + INITIAL_LIQUIDITY_MIN_LIQUIDITY, + ); + + let stored_position_id = ticket_master_dispatcher.get_liquidity_position_id(); + assert_eq!( + position_id, stored_position_id, "liquidity position id mismatch after initial liquidity", + ); +} + #[test] fn mock_simple_flow() { start_mock_call(MOCK_REGISTRY_ADDRESS, selector!("register_token"), 0);