Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/economy/src/tokenomics/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ pub struct LiquidityConfig {
pub stream_token_amount: u128,
pub paired_token_amount: u128,
pub min_liquidity: u128,
pub liquidity_owner: ContractAddress,
}

pub struct CreateTokenParams {
Expand Down
1 change: 1 addition & 0 deletions packages/economy/src/tokenomics/constants.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub mod Errors {
pub const STREAM_DISTRIBUTIONS_NOT_STARTED: felt252 = 'Distributions not started';
pub const STREAM_INVALID_ORDER_INDEX: felt252 = 'Invalid order index';
pub const STREAM_POSITION_NOT_FOUND: felt252 = 'Position not found';
pub const STREAM_INVALID_LIQUIDITY_OWNER: felt252 = 'Invalid liquidity owner';

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new error constant is added correctly, but there should be a corresponding test in the test_constants.cairo file to verify the error message. While that file isn't modified in this PR, this is a gap in test coverage that should be addressed to maintain consistency with other error constants.

Copilot uses AI. Check for mistakes.
pub const STREAM_INVALID_PREMINT_RECIPIENT: felt252 = 'Invalid premint recipient';
pub const STREAM_INVALID_PREMINT_AMOUNT: felt252 = 'Invalid premint amount';

Expand Down
28 changes: 28 additions & 0 deletions packages/economy/src/tokenomics/stream/stream.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod StreamComponent {
use core::poseidon::PoseidonTrait;
use ekubo::interfaces::core::{ICoreDispatcher, ICoreDispatcherTrait};
use ekubo::interfaces::erc20::IERC20Dispatcher as EkuboIERC20Dispatcher;
use ekubo::interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait};
use ekubo::interfaces::extensions::twamm::OrderKey;
use ekubo::interfaces::positions::{IPositionsDispatcher, IPositionsDispatcherTrait};
use ekubo::lens::token_registry::{ITokenRegistryDispatcher, ITokenRegistryDispatcherTrait};
Expand Down Expand Up @@ -55,6 +56,8 @@ pub mod StreamComponent {
Stream_primary_min_liquidity: u128,
/// Liquidity position ID
Stream_liquidity_position_id: u64,
/// Liquidity owner (receives LP position NFT)
Stream_liquidity_owner: ContractAddress,
/// Primary pool ID
Stream_pool_id: u256,
/// Distribution orders
Expand All @@ -72,6 +75,7 @@ pub mod StreamComponent {
pub enum Event {
PoolInitialized: PoolInitialized,
LiquidityProvided: LiquidityProvided,
LiquidityPositionTransferred: LiquidityPositionTransferred,
DistributionStarted: DistributionStarted,
ProceedsClaimed: ProceedsClaimed,
Preminted: Preminted,
Expand Down Expand Up @@ -102,6 +106,14 @@ pub mod StreamComponent {
pub token1_amount: u256,
}

/// Emitted when the LP position NFT is transferred to the liquidity owner
#[derive(Drop, starknet::Event)]
pub struct LiquidityPositionTransferred {
pub position_id: u64,
#[key]
pub owner: ContractAddress,
}

/// Emitted when a distribution order starts
#[derive(Drop, starknet::Event)]
pub struct DistributionStarted {
Expand Down Expand Up @@ -235,6 +247,10 @@ pub mod StreamComponent {
self.Stream_pool_id.read()
}

fn get_liquidity_owner(self: @ComponentState<TContractState>) -> ContractAddress {
self.Stream_liquidity_owner.read()
}
Comment on lines +250 to +252

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the get_liquidity_owner() getter function. Consider adding a test that verifies this function correctly returns the liquidity owner address that was set during initialization.

Copilot uses AI. Check for mistakes.

fn get_deployment_state(self: @ComponentState<TContractState>) -> u8 {
self.Stream_deployment_state.read()
}
Expand Down Expand Up @@ -290,6 +306,13 @@ pub mod StreamComponent {
},
);

// Transfer LP position NFT to liquidity owner
let nft_address = positions_dispatcher.get_nft_address();
let nft = IERC721Dispatcher { contract_address: nft_address };
let liquidity_owner = self.Stream_liquidity_owner.read();
nft.transfer_from(get_contract_address(), liquidity_owner, position_id.into());
self.emit(LiquidityPositionTransferred { position_id, owner: liquidity_owner });
Comment on lines +309 to +314

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NFT transfer happens after the state write and event emission (lines 296-307). This creates a potential inconsistency if the NFT transfer fails - the contract state would show liquidity was provided (state=1), but the owner wouldn't have received the NFT. Consider restructuring so the state write happens after the NFT transfer completes successfully to ensure atomic state consistency. This would mean moving the state write and LiquidityProvided event emission to after line 314.

Copilot uses AI. Check for mistakes.

(position_id, liquidity, token0_cleared, token1_cleared)
}

Expand Down Expand Up @@ -384,6 +407,10 @@ pub mod StreamComponent {
);
assert(liquidity_config.stream_token_amount > 0, Errors::STREAM_INVALID_STREAM_AMOUNT);
assert(liquidity_config.paired_token_amount > 0, Errors::STREAM_INVALID_PAIRED_AMOUNT);
assert(
liquidity_config.liquidity_owner != zero_address,
Errors::STREAM_INVALID_LIQUIDITY_OWNER,
);

// Store configuration
self.Stream_factory.write(factory);
Expand All @@ -402,6 +429,7 @@ pub mod StreamComponent {
self.Stream_primary_stream_token_amount.write(liquidity_config.stream_token_amount);
self.Stream_primary_paired_token_amount.write(liquidity_config.paired_token_amount);
self.Stream_primary_min_liquidity.write(liquidity_config.min_liquidity);
self.Stream_liquidity_owner.write(liquidity_config.liquidity_owner);

// Store distribution orders
let order_count: u32 = distribution_orders.len();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub fn deploy_stream_token() -> StreamTokenSetup {
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000, // 1000 tokens
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000, // 100 tokens
min_liquidity: 1,
liquidity_owner: OWNER(),
};

// Distribution order - sells stream tokens for buy_token
Expand Down Expand Up @@ -205,6 +206,7 @@ pub fn deploy_stream_token_with_premints(
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000, // 1000 tokens
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000, // 100 tokens
min_liquidity: 1,
liquidity_owner: OWNER(),
};

// Distribution order - sells stream tokens for buy_token
Expand Down
6 changes: 6 additions & 0 deletions packages/economy/src/tokenomics/tests/test_factory.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ fn default_create_params(paired_token: ContractAddress) -> CreateTokenParams {
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000,
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<DistributionOrder> = array![
Expand Down Expand Up @@ -300,6 +301,7 @@ fn test_create_token_no_distribution_orders_fails() {
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000,
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let empty_orders: Array<DistributionOrder> = array![];
Expand Down Expand Up @@ -339,6 +341,7 @@ fn test_create_token_too_many_orders_fails() {
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000,
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

// Create 11 orders (more than max 10)
Expand Down Expand Up @@ -394,6 +397,7 @@ fn test_create_token_supply_too_low_fails() {
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000,
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<DistributionOrder> = array![
Expand Down Expand Up @@ -541,6 +545,7 @@ fn create_params_with_premints(
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000,
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<DistributionOrder> = array![
Expand Down Expand Up @@ -596,6 +601,7 @@ fn test_create_token_with_premints_supply_too_low_fails() {
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000, // 1000 tokens
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<DistributionOrder> = array![
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ fn _create_params_with_strk_and_premints(
stream_token_amount: 1000 * TOKEN_UNIT, // 1000 tokens for LP
paired_token_amount: 100 * TOKEN_UNIT, // 100 STRK for LP
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<DistributionOrder> = array![
Expand Down Expand Up @@ -155,6 +156,7 @@ fn test_factory_rejects_insufficient_supply_with_premints_fork() {
stream_token_amount: 1000 * TOKEN_UNIT, // 1000 tokens for LP
paired_token_amount: 100 * TOKEN_UNIT,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<DistributionOrder> = array![
Expand Down
1 change: 1 addition & 0 deletions packages/economy/src/tokenomics/tests/test_premint.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ fn build_invalid_premint_calldata(params: InvalidPremintTestParams) -> Array<fel
stream_token_amount: 1000 * TOKEN_UNIT,
paired_token_amount: 100 * TOKEN_UNIT,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<game_components_economy::tokenomics::DistributionOrder> = array![
Expand Down
1 change: 1 addition & 0 deletions packages/economy/src/tokenomics/tests/test_stream.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ fn deploy_stream_token_with_orders(order_count: u32) -> StreamTokenSetup {
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000,
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

// Create the specified number of orders
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ fn deploy_stream_token_mainnet() -> (ContractAddress, IStreamTokenDispatcher, IE
stream_token_amount: 1000 * TOKEN_UNIT,
paired_token_amount: 100 * TOKEN_UNIT,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<DistributionOrder> = array![
Expand Down Expand Up @@ -103,6 +104,7 @@ fn deploy_stream_token_mainnet_with_premints(
stream_token_amount: 1000 * TOKEN_UNIT,
paired_token_amount: 100 * TOKEN_UNIT,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

let distribution_orders: Array<DistributionOrder> = array![
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ fn deploy_stream_token_with_multiple_orders() -> StreamTokenSetup {
stream_token_amount: 1000_u128 * 1_000_000_000_000_000_000,
paired_token_amount: 100_u128 * 1_000_000_000_000_000_000,
min_liquidity: 1,
liquidity_owner: OWNER(),
};

// Create 2 orders with SAME buy_token and fee to trigger increase_sell_amount
Expand Down Expand Up @@ -102,6 +103,26 @@ fn deploy_stream_token_with_multiple_orders() -> StreamTokenSetup {
// Full Lifecycle Tests with Mocked Ekubo
// ============================================================================

// Mock NFT address returned by get_nft_address
fn MOCK_NFT() -> starknet::ContractAddress {
'MOCK_NFT'.try_into().unwrap()
}

/// Set up mocks for the LP NFT transfer (get_nft_address + transfer_from)
fn mock_nft_transfer(mock_positions: starknet::ContractAddress) {
start_mock_call(mock_positions, selector!("get_nft_address"), MOCK_NFT());
start_mock_call(mock_positions, selector!("transfer_from"), ());
start_mock_call(MOCK_NFT(), selector!("transferFrom"), ());
start_mock_call(MOCK_NFT(), selector!("transfer_from"), ());
}

fn stop_mock_nft_transfer(mock_positions: starknet::ContractAddress) {
stop_mock_call(mock_positions, selector!("get_nft_address"));
stop_mock_call(mock_positions, selector!("transfer_from"));
stop_mock_call(MOCK_NFT(), selector!("transferFrom"));
Comment on lines +115 to +122

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock setup includes both transferFrom (camelCase) and transfer_from (snake_case) variants on lines 115-116. However, based on the actual code in stream.cairo line 313 and consistent patterns throughout the codebase (e.g., in ticket_booth.cairo, prize.cairo), only transfer_from (snake_case) is the correct Cairo convention. The camelCase variant appears to be unnecessary. Consider removing line 115 to avoid confusion and reduce test maintenance.

Suggested change
start_mock_call(MOCK_NFT(), selector!("transferFrom"), ());
start_mock_call(MOCK_NFT(), selector!("transfer_from"), ());
}
fn stop_mock_nft_transfer(mock_positions: starknet::ContractAddress) {
stop_mock_call(mock_positions, selector!("get_nft_address"));
stop_mock_call(mock_positions, selector!("transfer_from"));
stop_mock_call(MOCK_NFT(), selector!("transferFrom"));
start_mock_call(MOCK_NFT(), selector!("transfer_from"), ());
}
fn stop_mock_nft_transfer(mock_positions: starknet::ContractAddress) {
stop_mock_call(mock_positions, selector!("get_nft_address"));
stop_mock_call(mock_positions, selector!("transfer_from"));

Copilot uses AI. Check for mistakes.

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar issue as above - line 122 stops mock for transferFrom (camelCase) but this is unnecessary. Only the snake_case variant transfer_from is used in the actual implementation. Consider removing line 122 to match the actual Cairo naming conventions.

Suggested change
stop_mock_call(MOCK_NFT(), selector!("transferFrom"));

Copilot uses AI. Check for mistakes.
stop_mock_call(MOCK_NFT(), selector!("transfer_from"));
}
Comment on lines +112 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The mock_nft_transfer and stop_mock_nft_transfer helper functions include mock calls that are not necessary for the test to pass and can be removed for clarity. The code under test only calls get_nft_address on the positions contract and transfer_from on the NFT contract. The mocks for transfer_from on the positions contract and transferFrom (with a capital F) on the NFT contract are not needed.

fn mock_nft_transfer(mock_positions: starknet::ContractAddress) {
    start_mock_call(mock_positions, selector!("get_nft_address"), MOCK_NFT());
    start_mock_call(MOCK_NFT(), selector!("transfer_from"), ());
}

fn stop_mock_nft_transfer(mock_positions: starknet::ContractAddress) {
    stop_mock_call(mock_positions, selector!("get_nft_address"));
    stop_mock_call(MOCK_NFT(), selector!("transfer_from"));
}


/// Test provide_initial_liquidity succeeds with mocked Ekubo calls
#[test]
fn test_provide_initial_liquidity_with_mocks() {
Expand All @@ -122,6 +143,9 @@ fn test_provide_initial_liquidity_with_mocks() {
(1_u64, 1000_u128, 500_u256, 500_u256),
);

// Mock NFT transfer (get_nft_address + transfer_from)
mock_nft_transfer(mock_positions);

// Verify initial state
assert!(setup.token.get_deployment_state() == 0, "Should be state 0 after construction");

Expand All @@ -147,6 +171,7 @@ fn test_provide_initial_liquidity_with_mocks() {
// Clean up mocks
stop_mock_call(mock_core, selector!("initialize_pool"));
stop_mock_call(mock_positions, selector!("mint_and_deposit_and_clear_both"));
stop_mock_nft_transfer(mock_positions);
}

/// Test full lifecycle: provide_initial_liquidity -> start_distributions
Expand All @@ -164,6 +189,7 @@ fn test_start_distributions_with_mocks() {
selector!("mint_and_deposit_and_clear_both"),
(1_u64, 1000_u128, 500_u256, 500_u256),
);
mock_nft_transfer(mock_positions);

// Mock Ekubo Positions mint_and_increase_sell_amount for start_distributions
// Returns: (position_id: u64, sale_rate: u128)
Expand Down Expand Up @@ -194,6 +220,7 @@ fn test_start_distributions_with_mocks() {
stop_mock_call(mock_core, selector!("initialize_pool"));
stop_mock_call(mock_positions, selector!("mint_and_deposit_and_clear_both"));
stop_mock_call(mock_positions, selector!("mint_and_increase_sell_amount"));
stop_mock_nft_transfer(mock_positions);
}

/// Test claim_distribution_proceeds with mocks
Expand All @@ -212,6 +239,7 @@ fn test_claim_distribution_proceeds_with_mocks() {
(1_u64, 1000_u128, 500_u256, 500_u256),
);
start_mock_call(mock_positions, selector!("mint_and_increase_sell_amount"), (2_u64, 100_u128));
mock_nft_transfer(mock_positions);

// Mock withdraw_proceeds_from_sale_to to return proceeds
start_mock_call(mock_positions, selector!("withdraw_proceeds_from_sale_to"), 250_u128);
Expand All @@ -237,6 +265,7 @@ fn test_claim_distribution_proceeds_with_mocks() {
stop_mock_call(mock_positions, selector!("mint_and_deposit_and_clear_both"));
stop_mock_call(mock_positions, selector!("mint_and_increase_sell_amount"));
stop_mock_call(mock_positions, selector!("withdraw_proceeds_from_sale_to"));
stop_mock_nft_transfer(mock_positions);
}

/// Test lifecycle emits correct events
Expand All @@ -253,6 +282,7 @@ fn test_lifecycle_events_with_mocks() {
selector!("mint_and_deposit_and_clear_both"),
(1_u64, 1000_u128, 500_u256, 500_u256),
);
mock_nft_transfer(mock_positions);

let setup_dispatcher = IStreamTokenSetupDispatcher { contract_address: setup.token_address };

Expand All @@ -277,8 +307,24 @@ fn test_lifecycle_events_with_mocks() {
],
);

// Verify LiquidityPositionTransferred event
spy
.assert_emitted(
@array![
(
setup.token_address,
StreamComponent::Event::LiquidityPositionTransferred(
StreamComponent::LiquidityPositionTransferred {
position_id: 1, owner: OWNER(),
},
),
),
],
);

stop_mock_call(mock_core, selector!("initialize_pool"));
stop_mock_call(mock_positions, selector!("mint_and_deposit_and_clear_both"));
stop_mock_nft_transfer(mock_positions);
}

/// Test DistributionStarted event
Expand All @@ -296,6 +342,7 @@ fn test_distribution_started_event_with_mocks() {
(1_u64, 1000_u128, 500_u256, 500_u256),
);
start_mock_call(mock_positions, selector!("mint_and_increase_sell_amount"), (2_u64, 100_u128));
mock_nft_transfer(mock_positions);

let setup_dispatcher = IStreamTokenSetupDispatcher { contract_address: setup.token_address };

Expand Down Expand Up @@ -335,6 +382,7 @@ fn test_distribution_started_event_with_mocks() {
stop_mock_call(mock_core, selector!("initialize_pool"));
stop_mock_call(mock_positions, selector!("mint_and_deposit_and_clear_both"));
stop_mock_call(mock_positions, selector!("mint_and_increase_sell_amount"));
stop_mock_nft_transfer(mock_positions);
}

/// Test ProceedsClaimed event
Expand All @@ -353,6 +401,7 @@ fn test_proceeds_claimed_event_with_mocks() {
);
start_mock_call(mock_positions, selector!("mint_and_increase_sell_amount"), (2_u64, 100_u128));
start_mock_call(mock_positions, selector!("withdraw_proceeds_from_sale_to"), 250_u128);
mock_nft_transfer(mock_positions);

let setup_dispatcher = IStreamTokenSetupDispatcher { contract_address: setup.token_address };

Expand Down Expand Up @@ -384,6 +433,7 @@ fn test_proceeds_claimed_event_with_mocks() {
stop_mock_call(mock_positions, selector!("mint_and_deposit_and_clear_both"));
stop_mock_call(mock_positions, selector!("mint_and_increase_sell_amount"));
stop_mock_call(mock_positions, selector!("withdraw_proceeds_from_sale_to"));
stop_mock_nft_transfer(mock_positions);
}

/// Test start_distributions with multiple orders sharing same buy_token/fee
Expand All @@ -402,6 +452,7 @@ fn test_start_distributions_multiple_orders_same_pool() {
selector!("mint_and_deposit_and_clear_both"),
(1_u64, 1000_u128, 500_u256, 500_u256),
);
mock_nft_transfer(mock_positions);

// Mock mint_and_increase_sell_amount for FIRST order (creates new position)
// Returns: (position_id: u64, sale_rate: u128)
Expand Down Expand Up @@ -443,4 +494,5 @@ fn test_start_distributions_multiple_orders_same_pool() {
stop_mock_call(mock_positions, selector!("mint_and_deposit_and_clear_both"));
stop_mock_call(mock_positions, selector!("mint_and_increase_sell_amount"));
stop_mock_call(mock_positions, selector!("increase_sell_amount"));
stop_mock_nft_transfer(mock_positions);
}
5 changes: 5 additions & 0 deletions packages/interfaces/src/tokenomics/stream.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ pub struct LiquidityConfig {
pub paired_token_amount: u128,
/// Minimum liquidity to accept (slippage protection)
pub min_liquidity: u128,
/// Address to receive the LP position NFT (can withdraw/manage liquidity)
pub liquidity_owner: ContractAddress,
}

/// Parameters for creating a new stream token
Expand Down Expand Up @@ -115,6 +117,9 @@ pub trait IStreamToken<TContractState> {
/// Get the liquidity position ID
fn get_liquidity_position_id(self: @TContractState) -> u64;

/// Get the liquidity owner address (receives LP position NFT)
fn get_liquidity_owner(self: @TContractState) -> ContractAddress;

/// Get the primary pool ID (for the liquidity pool)
fn get_pool_id(self: @TContractState) -> u256;

Expand Down
Loading
Loading