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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ packages/test_dojo/
coverage.lcov
**/snfoundry_trace/
**/coverage_html/
**/coverage/
**/coverage/

# Example configs (only token_config.example.json should be committed)
scripts/examples/summit_test_*.json
8 changes: 6 additions & 2 deletions packages/presets/src/stream_token.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,12 @@ pub mod StreamToken {
// Mint tokens to registry for registration (1 token = 10^18 units)
self.erc20.mint(registry_address, ERC20_UNIT.into());

// Register token with Ekubo
// Register token with Ekubo (registry returns the token after verification)
self.stream.register_token();

// Burn the returned registry token so circulating supply = exactly what user specified
self.erc20.burn(starknet::get_contract_address(), ERC20_UNIT.into());

// Mint premint allocations directly to recipients
let mut premint_total: u128 = 0;
for premint in premint_allocations {
Expand All @@ -129,7 +132,8 @@ pub mod StreamToken {

// Mint remaining tokens to factory for distribution
// Factory will transfer to positions contract for liquidity and distribution
let remaining_supply: u256 = (total_supply - ERC20_UNIT - premint_total).into();
// (registry token was minted and burned, so remaining = total_supply - premints)
let remaining_supply: u256 = (total_supply - premint_total).into();
self.erc20.mint(factory, remaining_supply);
}
}
22 changes: 19 additions & 3 deletions packages/presets/src/tests/mocks/mock_registry.cairo
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Mock Token Registry for testing StreamToken
/// Accepts register_token calls without any validation or external calls
/// Simulates Ekubo's registry behavior: accepts 1 token, verifies, and returns it

use ekubo::interfaces::erc20::IERC20Dispatcher;

Expand All @@ -12,7 +12,11 @@ pub trait IMockTokenRegistry<TContractState> {
#[starknet::contract]
pub mod MockTokenRegistry {
use ekubo::interfaces::erc20::IERC20Dispatcher;
use openzeppelin_interfaces::token::erc20::{
IERC20Dispatcher as OzIERC20Dispatcher, IERC20DispatcherTrait,
};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{get_caller_address, get_contract_address};

#[storage]
struct Storage {
Expand All @@ -22,8 +26,20 @@ pub mod MockTokenRegistry {
#[abi(embed_v0)]
impl MockTokenRegistryImpl of super::IMockTokenRegistry<ContractState> {
fn register_token(ref self: ContractState, token: IERC20Dispatcher) {
// Just count registrations, don't do any validation
let _ = token; // Suppress unused warning
// Simulate Ekubo's registry behavior:
// 1. Receive 1 token from caller (already done by StreamToken minting to registry)
// 2. Verify token is valid (skip for mock)
// 3. Return 1 token back to the caller
let caller = get_caller_address();
let this = get_contract_address();

// Use OZ dispatcher to transfer the token back to caller (like Ekubo does)
let oz_token = OzIERC20Dispatcher { contract_address: token.contract_address };
let balance = oz_token.balance_of(this);
if balance > 0 {
oz_token.transfer(caller, balance);
}

let count = self.registered_count.read();
self.registered_count.write(count + 1);
}
Expand Down
25 changes: 20 additions & 5 deletions packages/presets/src/tests/test_stream_token.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -247,19 +247,22 @@ fn test_constructor_sets_decimals_to_18() {
}

#[test]
fn test_constructor_mints_one_token_to_registry() {
fn test_constructor_registry_token_is_burned() {
let setup = deploy_stream_token();

// Registry token is minted for verification then burned
// so registry should have 0 tokens after deployment
let registry_balance = setup.erc20.balance_of(setup.registry);
assert!(registry_balance == ONE_TOKEN, "Registry should have 1 token");
assert!(registry_balance == 0, "Registry should have 0 tokens (burned after registration)");
}

#[test]
fn test_constructor_mints_remaining_supply_to_factory() {
let setup = deploy_stream_token();

// Total supply is 10000 tokens, registry gets 1
let expected_factory_balance: u256 = (10000_u128 * ERC20_UNIT - ERC20_UNIT).into();
// Total supply is 10000 tokens, registry token is minted and burned
// so factory gets full total_supply (no premints in this test)
let expected_factory_balance: u256 = (10000_u128 * ERC20_UNIT).into();
let factory_balance = setup.erc20.balance_of(setup.factory);
assert!(factory_balance == expected_factory_balance, "Factory balance mismatch");
}
Expand Down Expand Up @@ -301,9 +304,21 @@ fn test_constructor_sets_deployment_state_to_zero() {
fn test_total_supply() {
let setup = deploy_stream_token();

// Total supply should be exactly what the user specified (10000 tokens)
// The registry token mint+burn is a net zero effect on total supply
let total_supply = setup.erc20.total_supply();
let expected: u256 = (10000_u128 * ERC20_UNIT).into();
assert!(total_supply == expected, "Total supply mismatch");
assert!(total_supply == expected, "Total supply should match user-specified amount");
}

#[test]
fn test_stream_token_contract_holds_zero_balance() {
let setup = deploy_stream_token();

// The StreamToken contract itself should hold 0 tokens
// (registry token was returned and burned)
let contract_balance = setup.erc20.balance_of(setup.token_address);
assert!(contract_balance == 0, "StreamToken contract should hold 0 tokens");
}

#[test]
Expand Down
6 changes: 4 additions & 2 deletions packages/tokenomics/src/factory/stream_token_factory.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub mod StreamTokenFactory {
};
use starknet::syscalls::deploy_syscall;
use starknet::{ClassHash, ContractAddress, get_caller_address, get_contract_address};
use crate::constants::{ERC20_UNIT, Errors};
use crate::constants::Errors;

// Embed Ownable component for admin functions
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);
Expand Down Expand Up @@ -149,7 +149,9 @@ pub mod StreamTokenFactory {
}

let lp_amount = params.liquidity_config.stream_token_amount;
let total_needed = ERC20_UNIT + lp_amount + distribution_total + premint_total;
// total_supply should equal exactly LP + distributions + premints
// (registry token is minted then burned, so no extra needed)
let total_needed = lp_amount + distribution_total + premint_total;
assert(params.total_supply >= total_needed, Errors::STREAM_SUPPLY_TOO_LOW);

// Transfer paired tokens from caller to positions contract
Expand Down
22 changes: 19 additions & 3 deletions packages/tokenomics/src/tests/mocks/mock_registry.cairo
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Mock Token Registry for testing StreamToken
/// Accepts register_token calls without any validation or external calls
/// Simulates Ekubo's registry behavior: accepts 1 token, verifies, and returns it

use ekubo::interfaces::erc20::IERC20Dispatcher;

Expand All @@ -12,7 +12,11 @@ pub trait IMockTokenRegistry<TContractState> {
#[starknet::contract]
pub mod MockTokenRegistry {
use ekubo::interfaces::erc20::IERC20Dispatcher;
use openzeppelin_interfaces::token::erc20::{
IERC20Dispatcher as OzIERC20Dispatcher, IERC20DispatcherTrait,
};
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
use starknet::{get_caller_address, get_contract_address};

#[storage]
struct Storage {
Expand All @@ -22,8 +26,20 @@ pub mod MockTokenRegistry {
#[abi(embed_v0)]
impl MockTokenRegistryImpl of super::IMockTokenRegistry<ContractState> {
fn register_token(ref self: ContractState, token: IERC20Dispatcher) {
// Just count registrations, don't do any validation
let _ = token; // Suppress unused warning
// Simulate Ekubo's registry behavior:
// 1. Receive 1 token from caller (already done by StreamToken minting to registry)
// 2. Verify token is valid (skip for mock)
// 3. Return 1 token back to the caller
let caller = get_caller_address();
let this = get_contract_address();

// Use OZ dispatcher to transfer the token back to caller (like Ekubo does)
let oz_token = OzIERC20Dispatcher { contract_address: token.contract_address };
let balance = oz_token.balance_of(this);
if balance > 0 {
oz_token.transfer(caller, balance);
}

let count = self.registered_count.read();
self.registered_count.write(count + 1);
}
Expand Down
6 changes: 3 additions & 3 deletions packages/tokenomics/src/tests/test_factory.cairo
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ fn test_create_token_supply_too_low_fails() {
},
];

// Supply = 100, but need: ERC20_UNIT + 1000 (LP) + 500 (distribution) = 1501 tokens
// Supply = 100, but need: 1000 (LP) + 500 (distribution) = 1500 tokens
let params = CreateTokenParams {
name: "Test",
symbol: "TST",
Expand Down Expand Up @@ -560,7 +560,7 @@ fn create_params_with_premints(
premint_total += *premint.amount;
}

// Supply needs to cover: ERC20_UNIT (1) + LP (1000) + distribution (500) + premints
// Supply needs to cover: LP (1000) + distribution (500) + premints
let base_supply: u128 = 10000_u128 * 1_000_000_000_000_000_000;
let total_supply: u128 = base_supply + premint_total;

Expand Down Expand Up @@ -610,7 +610,7 @@ fn test_create_token_with_premints_supply_too_low_fails() {
];

// Premints that push total over the supply limit
// Total needed: ERC20_UNIT (1) + LP (1000) + distribution (500) + premints (1000) = 2501+
// Total needed: LP (1000) + distribution (500) + premints (1000) = 2500
// Supply provided: 2000 tokens - NOT ENOUGH
let premint_allocations: Array<PremintAllocation> = array![
PremintAllocation { recipient: USER1(), amount: 500_u128 * 1_000_000_000_000_000_000 },
Expand Down
29 changes: 17 additions & 12 deletions scripts/create_stream_token.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# - Config values are human-readable (e.g., "1000000" = 1 million tokens)
# - The script automatically converts to wei (multiplies by 10^18)
# - Initial tick is calculated automatically by the StreamToken contract
# - total_supply is auto-calculated: LP + distributions + premints
#
# PRICE CALCULATION:
# The initial pool price is derived from the liquidity amounts you provide:
Expand Down Expand Up @@ -106,18 +107,15 @@ check_dependencies() {
if ! command -v xxd &> /dev/null; then
missing+=("xxd (part of vim or xxd package)")
fi
if ! command -v bc &> /dev/null; then
missing+=("bc")
fi

if [ ${#missing[@]} -ne 0 ]; then
print_error "Missing required dependencies:"
for dep in "${missing[@]}"; do
echo " - $dep"
done
echo ""
echo "Install on macOS: brew install jq xxd bc"
echo "Install on Ubuntu: apt install jq xxd bc curl"
echo "Install on macOS: brew install jq xxd"
echo "Install on Ubuntu: apt install jq xxd curl"
exit 1
fi
}
Expand Down Expand Up @@ -493,7 +491,6 @@ CONFIG=$(cat "$CONFIG_FILE")
# Extract and validate required fields
TOKEN_NAME=$(echo "$CONFIG" | jq -r '.name')
TOKEN_SYMBOL=$(echo "$CONFIG" | jq -r '.symbol')
TOTAL_SUPPLY=$(echo "$CONFIG" | jq -r '.total_supply')

if [ "$TOKEN_NAME" == "null" ] || [ -z "$TOKEN_NAME" ]; then
print_error "Missing required field: name"
Expand All @@ -505,11 +502,6 @@ if [ "$TOKEN_SYMBOL" == "null" ] || [ -z "$TOKEN_SYMBOL" ]; then
exit 1
fi

if [ "$TOTAL_SUPPLY" == "null" ] || [ -z "$TOTAL_SUPPLY" ]; then
print_error "Missing required field: total_supply"
exit 1
fi

# Extract liquidity config
PAIRED_TOKEN=$(echo "$CONFIG" | jq -r '.liquidity_config.paired_token')
POOL_FEE=$(echo "$CONFIG" | jq -r '.liquidity_config.fee')
Expand All @@ -534,7 +526,6 @@ if [ "$PAIRED_TOKEN_AMOUNT" == "null" ] || [ -z "$PAIRED_TOKEN_AMOUNT" ]; then
fi

# Convert human-readable amounts to wei (18 decimals)
TOTAL_SUPPLY_WEI=$(to_wei "$TOTAL_SUPPLY")
STREAM_TOKEN_AMOUNT_WEI=$(to_wei "$STREAM_TOKEN_AMOUNT")
PAIRED_TOKEN_AMOUNT_WEI=$(to_wei "$PAIRED_TOKEN_AMOUNT")

Expand All @@ -545,6 +536,20 @@ MIN_LIQUIDITY="${MIN_LIQUIDITY:-0}"
ORDER_COUNT=$(echo "$CONFIG" | jq '.distribution_orders | length')
PREMINT_COUNT=$(echo "$CONFIG" | jq '.premint_allocations | length')

# Calculate distribution and premint totals using jq (handles empty arrays gracefully)
DISTRIBUTION_TOTAL=$(echo "$CONFIG" | jq -r '[.distribution_orders[].amount | tonumber] | add // 0')
PREMINT_TOTAL=$(echo "$CONFIG" | jq -r '[.premint_allocations[].amount | tonumber] | add // 0')

# Auto-calculate total_supply: LP + distributions + premints
# (registry token is minted and burned during deployment, so no extra needed)
TOTAL_SUPPLY=$((STREAM_TOKEN_AMOUNT + DISTRIBUTION_TOTAL + PREMINT_TOTAL))
TOTAL_SUPPLY_WEI=$(to_wei "$TOTAL_SUPPLY")

print_info "Auto-calculated total_supply: $TOTAL_SUPPLY tokens"
print_verbose " LP tokens: $STREAM_TOKEN_AMOUNT"
print_verbose " Distribution tokens: $DISTRIBUTION_TOTAL"
print_verbose " Premint tokens: $PREMINT_TOTAL"

if [ "$ORDER_COUNT" -eq 0 ]; then
print_error "At least one distribution order is required"
exit 1
Expand Down
40 changes: 40 additions & 0 deletions scripts/examples/token_config.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"_comment": "All token amounts assume 18 decimals. Use human-readable values (e.g., '1000000' for 1M tokens). total_supply is auto-calculated from LP + distributions + premints.",

"name": "TOKEN_NAME",
"symbol": "TOKEN_SYMBOL",

"liquidity_config": {
"paired_token": "0x03C8559b31A325f9f45Ce98f709e8e7C655805C6ca4EECB78FF7761F202AcBA3",
"fee": "170141183460469235273462165868118016",
"stream_token_amount": "5000",
"paired_token_amount": "500"
},

"distribution_orders": [
{
"_comment": "TWAMM end_time must be aligned to powers of 16. Script validates and suggests valid times.",
"buy_token": "0x042DD777885AD2C116be96d4D634abC90A26A790ffB5871E037Dd5Ae7d2Ec86B",
"fee": "170141183460469235273462165868118016",
"start_time": 0,
"end_time": 1811939328,
"amount": "300000",
"proceeds_recipient": "0xYOUR_TREASURY_ADDRESS_HERE"
}
],

"premint_allocations": [
{
"recipient": "0xPREMINT_RECIPIENT_ADDRESS",
"amount": "PREMINT_AMOUNT"
},
{
"recipient": "0xPREMINT_RECIPIENT_ADDRESS2",
"amount": "PREMINT_AMOUNT"
},
{
"recipient": "0xPREMINT_RECIPIENT_ADDRESS3",
"amount": "PREMINT_AMOUNT"
}
]
}