instantiate/execute/query entry points and every handler behind them. This is where the rules actually run.
Live source from
contracts/launchpad/src/contract.rs (3985 lines). Generated from the deployed contract code.use cosmwasm_schema::cw_serde;
use cosmwasm_std::{
entry_point, to_binary, Addr, BankMsg, Binary, Coin, Deps, DepsMut,
Env, MessageInfo, Reply, Response, StdResult, SubMsg, Uint128, WasmMsg,
StdError,
};
use cw_storage_plus::Item;
use cw2::set_contract_version;
use cw_utils::parse_instantiate_response_data;
use crate::error::ContractError;
use crate::msg::{
ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, CurveResponse, AllCurvesResponse,
ProgressResponse, ConfigResponse, SimulateBuyResponse, SimulateSellResponse,
OracleResponse, MetadataFieldState, MetadataStateResponse, MetadataVoteResponse,
PresaleContributionResponse, PresaleResponse,
};
use crate::state::{
normalize_for_cooldown, AppliedMetadata, Config, CreatorVestingConfig, Curve, MetadataVote,
OracleState, PendingCurve, PendingFinalize, PresaleConfig, PresaleState, TokenMetadata,
APPLIED_METADATA, CONFIG, CURVES, DEFAULT_NAME_COOLDOWN_SECS, MAX_CREATOR_ALLOCATION_BPS,
METADATA_FIRST_APPLY_DELAY_SECS, METADATA_TALLIES, METADATA_VOTES,
METADATA_VOTING_WINDOW_SECS, NAME_COOLDOWN, NAME_PERMA_BLOCK, ORACLE_STATE, PENDING_CURVE,
PENDING_FINALIZE, PRESALES, PRESALE_CLAIMED, PRESALE_CONTRIBUTIONS, SYMBOL_COOLDOWN, SYMBOL_PERMA_BLOCK,
VESTING_INSTANCES,
};
const CONTRACT_NAME: &str = "crates.io:bwick-launchpad";
const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
// ── Oracle integration ────────────────────────────────────────────────────────
//
// Mirror of the bwick-oracle contract's QueryMsg::Twap response. Defined inline
// here to avoid a dependency cycle on the oracle crate. As long as the field
// names match, serde will deserialize correctly.
#[cw_serde]
struct OracleTwapQueryMsg {
twap: OracleTwapArgs,
}
#[cw_serde]
struct OracleTwapArgs {
window_seconds: Option<u64>,
}
#[cw_serde]
struct OracleTwapResponse {
bwick_usd_price: Uint128,
sol_usd_price: Uint128,
last_update_height: u64,
last_update_timestamp: u64,
source: String,
fresh: bool,
}
/// Returns the current BWICK/USD price in micro-USD (6 decimals), preferring
/// the live oracle contract's TWAP when configured. Falls back to the legacy
/// admin-set ORACLE_STATE when the oracle contract is not configured or the
/// query fails. Returns 0 if no source provides a price.
fn get_bwick_usd_price(deps: Deps, config: &Config) -> u128 {
if !config.oracle_contract.as_str().is_empty() {
let req = OracleTwapQueryMsg {
twap: OracleTwapArgs { window_seconds: Some(60) },
};
if let Ok(resp) = deps.querier.query_wasm_smart::<OracleTwapResponse>(
config.oracle_contract.as_str(),
&req,
) {
if resp.fresh && resp.bwick_usd_price.u128() > 0 {
return resp.bwick_usd_price.u128();
}
}
}
// Fallback: legacy admin-set local oracle.
ORACLE_STATE
.may_load(deps.storage)
.ok()
.flatten()
.map(|o| o.bwick_usd_price)
.unwrap_or(0)
}
// Reply ID for CW20 instantiation
const REPLY_CW20_INSTANTIATE: u64 = 1;
// v4: Reply ID for bwick-vesting instantiation triggered by FinalizePresale.
const REPLY_VESTING_INSTANTIATE: u64 = 2;
// v4: bwick-vesting code ID. Populated via UpdateConfig at runtime (TODO:
// add a Config.vesting_code_id field + migrate). For now, the FinalizePresale
// path will fail loudly if Config doesn't carry a vesting code id.
const VESTING_CONTRACT_LABEL_PREFIX: &str = "bwick-vesting-";
// Fixed total supply: 100 thousand tokens with 6 decimals
// Starting FDV = base_price * total_supply = 0.01 USD * 100k = $1,000
pub const TOTAL_SUPPLY: u128 = 100_000_000_000; // 100 thousand * 10^6
// ===========================================
// Constant Product Curve Constants
// ===========================================
/// Tokens available for purchase on the bonding curve (79.31% of supply)
pub const TOKENS_ON_CURVE: u128 = 79_310_000_000; // 79.31k * 10^6
/// Tokens reserved for AMM liquidity pool at graduation (20.69% of supply)
pub const TOKENS_FOR_LP: u128 = 20_690_000_000; // 20.69k * 10^6
/// Virtual BWICK reserves at curve start (in ubwick)
/// Determines starting price and total BWICK raised at graduation.
/// Higher value = more BWICK raised at graduation, lower starting price.
pub const VIRTUAL_BWICK_START: u128 = 34_800_000_000_000; // 34,800,000 BWICK
/// Virtual token reserves at curve start (in base units with 6 decimals)
/// Scaled from pump.fun's 1.073B virtual tokens (/ 10 for 100M supply).
/// Must be > TOKENS_ON_CURVE for the math to work.
pub const VIRTUAL_TOKENS_START: u128 = 107_300_000_000_000; // 107.3M tokens
/// Constant product invariant: k = virtual_bwick * virtual_tokens
/// This remains constant throughout the curve's lifecycle.
/// = 34_800_000_000_000 * 107_300_000_000_000
pub const K: u128 = 3_734_040_000_000_000_000_000_000_000;
// ===========================================
// Dynamic Curve Parameter Computation
// ===========================================
/// Precision multiplier for fixed-point sqrt computation
const CURVE_PRECISION: u128 = 1_000_000;
#[derive(Debug)]
struct CurveParams {
virtual_bwick_start: u128,
virtual_tokens_start: u128,
curve_k: u128,
tokens_on_curve: u128,
tokens_for_lp: u128,
}
/// Integer square root using Newton's method
fn isqrt(n: u128) -> u128 {
if n == 0 {
return 0;
}
let mut x = n;
let mut y = (x + 1) / 2;
while y < x {
x = y;
y = (x + n / x) / 2;
}
x
}
/// Compute per-curve bonding curve parameters from USD targets and oracle price.
///
/// All USD values in micro-USD (6 decimals). BWICK amounts in ubwick.
/// Token amounts in utokens (with 6 decimal places).
///
/// Key formulas (S = sqrt(graduation_mc / starting_mc)):
/// tokens_on_curve = raised * TOTAL_SUPPLY / (starting_mc * S)
/// virtual_tokens = tokens_on_curve * S / (S - 1)
/// raised_ubwick = raised_usd * 10^6 / bwick_price
/// virtual_bwick = raised_ubwick / (S - 1)
/// K = virtual_bwick * virtual_tokens
fn compute_curve_params(
bwick_usd_price: u128,
target_starting_mc_usd: u128,
target_graduation_mc_usd: u128,
target_raised_usd: u128,
raised_ubwick_override: u128,
) -> Result<CurveParams, ContractError> {
// v6: when a BWICK-denominated raise target is configured the oracle is
// not needed: the USD targets only contribute dimensionless ratios
// (graduation/starting MC and raised/starting MC), and the absolute
// BWICK raise comes straight from the override. Curves sized this way
// keep the same appreciation multiple regardless of BWICK's USD price.
if bwick_usd_price == 0 && raised_ubwick_override == 0 {
return Err(ContractError::OraclePriceRequired {});
}
if target_starting_mc_usd == 0 {
return Err(ContractError::InvalidCurveParams {
reason: "target_starting_mc_usd must be > 0".to_string(),
});
}
if target_graduation_mc_usd <= target_starting_mc_usd {
return Err(ContractError::InvalidCurveParams {
reason: "graduation MC must be > starting MC".to_string(),
});
}
// 1. MC ratio (integer)
let mc_ratio = target_graduation_mc_usd / target_starting_mc_usd;
// 2. S_scaled = isqrt(mc_ratio * PRECISION^2) ≈ sqrt(mc_ratio) * PRECISION
let s_scaled = isqrt(mc_ratio * CURVE_PRECISION * CURVE_PRECISION);
if s_scaled <= CURVE_PRECISION {
return Err(ContractError::InvalidCurveParams {
reason: "sqrt(mc_ratio) must be > 1".to_string(),
});
}
let s_minus_one = s_scaled - CURVE_PRECISION;
// 3. tokens_on_curve (utokens) = raised * TOTAL_SUPPLY * PRECISION / (starting_mc * S_scaled)
let toc_num = target_raised_usd
.checked_mul(TOTAL_SUPPLY)
.and_then(|v| v.checked_mul(CURVE_PRECISION))
.ok_or_else(|| ContractError::InvalidCurveParams {
reason: "overflow computing tokens_on_curve numerator".to_string(),
})?;
let toc_denom = target_starting_mc_usd
.checked_mul(s_scaled)
.ok_or_else(|| ContractError::InvalidCurveParams {
reason: "overflow computing tokens_on_curve denominator".to_string(),
})?;
let tokens_on_curve = toc_num / toc_denom;
if tokens_on_curve == 0 || tokens_on_curve >= TOTAL_SUPPLY {
return Err(ContractError::InvalidCurveParams {
reason: format!(
"tokens_on_curve out of range: {} (must be 0 < x < {})",
tokens_on_curve, TOTAL_SUPPLY
),
});
}
// 4. virtual_tokens (utokens) = tokens_on_curve * S_scaled / s_minus_one
let virtual_tokens_start = tokens_on_curve
.checked_mul(s_scaled)
.ok_or_else(|| ContractError::InvalidCurveParams {
reason: "overflow computing virtual_tokens".to_string(),
})?
/ s_minus_one;
// 5. raised_ubwick: BWICK override wins; otherwise convert the USD
// target at the oracle price.
let raised_ubwick = if raised_ubwick_override > 0 {
raised_ubwick_override
} else {
target_raised_usd
.checked_mul(1_000_000)
.ok_or_else(|| ContractError::InvalidCurveParams {
reason: "overflow computing raised_ubwick".to_string(),
})?
/ bwick_usd_price
};
// 6. virtual_bwick (ubwick) = raised_ubwick * PRECISION / s_minus_one
let virtual_bwick_start = raised_ubwick
.checked_mul(CURVE_PRECISION)
.ok_or_else(|| ContractError::InvalidCurveParams {
reason: "overflow computing virtual_bwick".to_string(),
})?
/ s_minus_one;
if virtual_bwick_start == 0 {
return Err(ContractError::InvalidCurveParams {
reason: "virtual_bwick_start is zero (price too high or raised too low)".to_string(),
});
}
// 7. K = virtual_bwick * virtual_tokens
let curve_k = virtual_bwick_start
.checked_mul(virtual_tokens_start)
.ok_or_else(|| ContractError::InvalidCurveParams {
reason: "overflow computing K (virtual reserves too large)".to_string(),
})?;
// 8. tokens_for_lp
let tokens_for_lp = TOTAL_SUPPLY - tokens_on_curve;
Ok(CurveParams {
virtual_bwick_start,
virtual_tokens_start,
curve_k,
tokens_on_curve,
tokens_for_lp,
})
}
/// Check that buyer's holding after purchase won't exceed max_wallet_bps of total supply.
/// Queries the CW20 contract for the buyer's current balance.
/// Skips the check if max_wallet_bps is 0 (disabled).
fn check_max_wallet(
deps: &DepsMut,
token_address: &str,
buyer_address: &str,
tokens_out: u128,
max_wallet_bps: u16,
) -> Result<(), ContractError> {
if max_wallet_bps == 0 {
return Ok(());
}
let limit = TOTAL_SUPPLY * (max_wallet_bps as u128) / 10000;
// Query buyer's current CW20 balance
let balance_resp: cw20::BalanceResponse = deps.querier.query_wasm_smart(
token_address,
&cw20::Cw20QueryMsg::Balance {
address: buyer_address.to_string(),
},
)?;
let current = balance_resp.balance.u128();
let would_become = current + tokens_out;
if would_become > limit {
return Err(ContractError::MaxWalletExceeded {
current,
buying: tokens_out,
would_become,
limit,
});
}
Ok(())
}
/// Extract per-curve constants, falling back to legacy hardcoded values if not set.
fn get_curve_constants(curve: &Curve) -> (u128, u128, u128, u128, u128) {
if curve.curve_k > 0 {
(
curve.virtual_bwick_start,
curve.virtual_tokens_start,
curve.curve_k,
curve.tokens_on_curve,
curve.tokens_for_lp,
)
} else {
(VIRTUAL_BWICK_START, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE, TOKENS_FOR_LP)
}
}
// ===========================================
// Constant Product Curve Functions
// ===========================================
/// Calculate current spot price at given tokens_sold using constant product curve.
/// Returns price in ubwick per whole token (per 10^6 base units).
///
/// Spot price = virtual_bwick / virtual_tokens * 10^6
/// Simplified: price = k * 10^6 / (virtual_tokens_start - tokens_sold)^2
fn calculate_price_cp(tokens_sold: u128, virtual_tokens_start: u128, k: u128) -> u128 {
let virtual_tokens = virtual_tokens_start - tokens_sold;
k * 1_000_000 / (virtual_tokens * virtual_tokens)
}
/// Calculate tokens received for BWICK input using constant product formula.
/// Fee is deducted from bwick_input BEFORE applying to curve.
/// Returns (tokens_out, fee_amount) in base units.
///
/// Formula: tokens_out = virtual_tokens_current - k / (virtual_bwick_current + bwick_after_fee)
fn calculate_buy_cp(
tokens_sold: u128,
bwick_input: u128,
buy_fee_bps: u16,
vt_start: u128,
k: u128,
toc: u128,
) -> Result<(u128, u128), ContractError> {
// Deduct buy fee
let fee = bwick_input * (buy_fee_bps as u128) / 10000;
let bwick_after_fee = bwick_input - fee;
if bwick_after_fee == 0 {
return Err(ContractError::NoTokensAvailable {});
}
// Current virtual reserves
let virtual_tokens = vt_start - tokens_sold;
let virtual_bwick = k / virtual_tokens;
// After adding BWICK to pool
let new_virtual_bwick = virtual_bwick + bwick_after_fee;
let new_virtual_tokens = k / new_virtual_bwick;
let tokens_out = virtual_tokens - new_virtual_tokens;
// Cap at remaining curve tokens
let tokens_remaining = toc.saturating_sub(tokens_sold);
let tokens_out = tokens_out.min(tokens_remaining);
if tokens_out == 0 {
return Err(ContractError::NoTokensAvailable {});
}
Ok((tokens_out, fee))
}
/// Calculate BWICK returned for tokens sold back using constant product formula.
/// Fee is deducted from BWICK output AFTER computing curve value.
/// Returns (bwick_out_after_fee, total_fee) in ubwick.
///
/// Formula: bwick_returned_before_fee = virtual_bwick_current - k / (virtual_tokens_current + tokens_returned)
fn calculate_sell_cp(
tokens_sold: u128,
tokens_input: u128,
sell_fee_bps: u16,
vt_start: u128,
k: u128,
) -> Result<(u128, u128), ContractError> {
if tokens_input > tokens_sold {
return Err(ContractError::Std(StdError::generic_err(
"Cannot sell more tokens than have been sold"
)));
}
// Current virtual reserves
let virtual_tokens = vt_start - tokens_sold;
let virtual_bwick = k / virtual_tokens;
// After returning tokens to the pool
let new_virtual_tokens = virtual_tokens + tokens_input;
let new_virtual_bwick = k / new_virtual_tokens;
// BWICK to return (before fee)
let bwick_before_fee = virtual_bwick - new_virtual_bwick;
// Apply sell fee
let total_fee = bwick_before_fee * (sell_fee_bps as u128) / 10000;
let bwick_out = bwick_before_fee - total_fee;
Ok((bwick_out, total_fee))
}
// ===========================================
// Dynamic Graduation Threshold Functions
// ===========================================
/// Compute dynamic graduation threshold from oracle price.
///
/// Formula: threshold_ubwick = target_raised_usd * 10^6 / bwick_usd_price
/// Result is clamped to [min_threshold, max_threshold].
/// If price is 0, returns fallback_threshold.
///
/// All USD values in micro-USD (6 decimals).
/// All BWICK values in ubwick (6 decimals).
fn compute_dynamic_threshold(
bwick_usd_price: u128, // micro-USD per BWICK
target_raised_usd: u128, // micro-USD target raised
min_threshold: u128, // ubwick
max_threshold: u128, // ubwick
fallback_threshold: u128, // ubwick (used when price is 0)
target_raised_bwick: u128, // ubwick override; 0 = use USD path
) -> u128 {
// v5: BWICK-denominated override. Skips the oracle entirely so graduation
// is deterministic across price swings. Still clamped to the safety bounds.
if target_raised_bwick > 0 {
return target_raised_bwick.max(min_threshold).min(max_threshold);
}
if bwick_usd_price == 0 {
return fallback_threshold;
}
let raw = target_raised_usd
.checked_mul(1_000_000)
.expect("target * 10^6 overflow")
/ bwick_usd_price;
raw.max(min_threshold).min(max_threshold)
}
/// Compute effective threshold for a specific curve, applying ratchet logic.
///
/// - If curve has a stored threshold (Some(t)): effective = max(t, computed)
/// - If legacy curve (None): effective = max(legacy_fallback, computed)
///
/// This is a pure function for testability. The caller loads state and oracle.
fn effective_threshold_pure(
stored_threshold: Option<u128>, // curve.graduation_threshold_ubwick
computed_threshold: u128, // from compute_dynamic_threshold
legacy_fallback: u128, // config.graduation_threshold (old global)
) -> u128 {
match stored_threshold {
Some(t) => t.max(computed_threshold),
None => legacy_fallback.max(computed_threshold),
}
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> Result<Response, ContractError> {
// Validate AMM contract address
let amm_contract = deps.api.addr_validate(&msg.amm_contract)?;
let admin = deps.api.addr_validate(&msg.admin)?;
// Validate fees (buy <= 5%, sell <= 10%)
if msg.buy_fee_bps > 500 || msg.sell_fee_bps > 1000 {
return Err(ContractError::InvalidFees {});
}
// Validate threshold bounds
let min_thresh = msg.min_graduation_threshold.u128();
let max_thresh = msg.max_graduation_threshold.u128();
if min_thresh > max_thresh {
return Err(ContractError::InvalidThresholdBounds {
min: min_thresh,
max: max_thresh,
});
}
let config = Config {
presales_enabled: false,
amm_contract,
cw20_code_id: msg.cw20_code_id,
creation_fee: msg.creation_fee.u128(),
graduation_threshold: msg.graduation_threshold.u128(),
buy_fee_bps: msg.buy_fee_bps,
sell_fee_bps: msg.sell_fee_bps,
creator_fee_share_bps: msg.creator_fee_share_bps,
admin,
target_graduation_usd: msg.target_graduation_usd.u128(),
min_graduation_threshold: min_thresh,
max_graduation_threshold: max_thresh,
target_starting_mc_usd: msg.target_starting_mc_usd.u128(),
target_raised_usd: msg.target_raised_usd.u128(),
target_raised_bwick: msg.target_raised_bwick.u128(),
max_wallet_bps: msg.max_wallet_bps,
fee_exempt_address: Addr::unchecked(""),
oracle_contract: Addr::unchecked(""),
name_cooldown_seconds: 0, // 0 = use the 7-day default
vesting_code_id: 0, // set via UpdateConfig or Migrate
};
CONFIG.save(deps.storage, &config)?;
// Initialize oracle state with zeroes (no price yet)
let oracle_state = OracleState {
bwick_usd_price: 0,
last_update_height: 0,
last_update_timestamp: 0,
};
ORACLE_STATE.save(deps.storage, &oracle_state)?;
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
Ok(Response::new()
.add_attribute("action", "instantiate")
.add_attribute("amm_contract", msg.amm_contract)
.add_attribute("cw20_code_id", msg.cw20_code_id.to_string())
.add_attribute("creation_fee", msg.creation_fee)
.add_attribute("graduation_threshold", msg.graduation_threshold))
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn execute(
deps: DepsMut,
env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> Result<Response, ContractError> {
match msg {
ExecuteMsg::CreateToken {
name,
symbol,
image,
description,
social_links,
presale,
creator_vesting,
lp_lock_seconds,
} => execute_create_token(
deps,
env,
info,
name,
symbol,
image,
description,
social_links,
presale,
creator_vesting,
lp_lock_seconds,
),
ExecuteMsg::Buy { token_address, min_tokens_out } => {
execute_buy(deps, env, info, token_address, min_tokens_out)
}
ExecuteMsg::Receive(cw20_msg) => execute_receive(deps, env, info, cw20_msg),
ExecuteMsg::Graduate { token_address } => {
execute_graduate(deps, env, info, token_address)
}
ExecuteMsg::UpdateBwickPrice { bwick_usd_price } => {
execute_update_bwick_price(deps, env, info, bwick_usd_price)
}
ExecuteMsg::UpdateConfig {
target_graduation_usd,
min_graduation_threshold,
max_graduation_threshold,
target_starting_mc_usd,
target_raised_usd,
target_raised_bwick,
max_wallet_bps,
fee_exempt_address,
oracle_contract,
name_cooldown_seconds,
vesting_code_id,
presales_enabled,
} => execute_update_config(
deps,
info,
target_graduation_usd,
min_graduation_threshold,
max_graduation_threshold,
target_starting_mc_usd,
target_raised_usd,
target_raised_bwick,
max_wallet_bps,
fee_exempt_address,
oracle_contract,
name_cooldown_seconds,
vesting_code_id,
presales_enabled,
),
ExecuteMsg::VoteMetadata { token_address, name, symbol, image, description } => {
execute_vote_metadata(deps, env, info, token_address, name, symbol, image, description)
}
ExecuteMsg::ApplyMetadata { token_address } => {
execute_apply_metadata(deps, env, info, token_address)
}
ExecuteMsg::BuyPresale { token_address, merkle_proof, min_tokens_out } => {
execute_buy_presale(deps, env, info, token_address, merkle_proof, min_tokens_out)
}
ExecuteMsg::FinalizePresale { token_address } => {
execute_finalize_presale(deps, env, info, token_address)
}
ExecuteMsg::ClaimPresale { token_address } => {
execute_claim_presale(deps, env, info, token_address)
}
}
}
#[allow(clippy::too_many_arguments)]
fn execute_create_token(
deps: DepsMut,
env: Env,
info: MessageInfo,
name: String,
symbol: String,
image: String,
description: String,
social_links: Vec<String>,
presale: Option<PresaleConfig>,
creator_vesting: Option<CreatorVestingConfig>,
lp_lock_seconds: Option<u64>,
) -> Result<Response, ContractError> {
// v4: validate presale config up-front. If presale is None the rest
// of the function behaves exactly as v3.
if let Some(cfg) = presale.as_ref() {
validate_presale_config(cfg, env.block.time.seconds())?;
}
// Suppress unused-variable warnings for the v4 fields that are
// wired through PendingCurve but not yet acted on by the reply +
// graduate handlers. The follow-up commit will hook them up.
let _ = (&creator_vesting, &lp_lock_seconds);
let config = CONFIG.load(deps.storage)?;
// v7: presales ship dormant; flip Config.presales_enabled to launch them.
if presale.is_some() && !config.presales_enabled {
return Err(ContractError::PresalesDisabled {});
}
if let Some(ref presale_cfg) = presale {
if presale_cfg.min_raise_ubwick > presale_cfg.hard_cap_ubwick {
return Err(ContractError::InvalidPresaleConfig {
reason: "min_raise_ubwick cannot exceed hard_cap_ubwick".into(),
});
}
}
// Verify creation fee sent (80,000 BWICK) — exempt address skips fee
let bwick_sent = extract_bwick_from_funds(&info.funds)?;
let is_exempt = !config.fee_exempt_address.as_str().is_empty()
&& info.sender == config.fee_exempt_address;
if !is_exempt && bwick_sent < config.creation_fee {
return Err(ContractError::InsufficientCreationFee {
expected: config.creation_fee,
got: bwick_sent,
});
}
// Anti-vampire name/symbol reuse limits. Two layers:
// - permanent block: if a curve with this normalized name/symbol has
// ever graduated, it's reserved forever. Anti-fork.
// - cooldown: 7-day default; configurable via UpdateConfig. Prevents
// a copycat from re-deploying immediately after a rugged / failed
// curve. Normalization strips case + non-alphanumeric so cheap
// evasions ("BWICK V2", "B-WICK", "bwick_") all collide.
let cooldown = if config.name_cooldown_seconds > 0 {
config.name_cooldown_seconds
} else {
DEFAULT_NAME_COOLDOWN_SECS
};
let now = env.block.time.seconds();
let symbol_key = normalize_for_cooldown(&symbol);
let name_key = normalize_for_cooldown(&name);
// Reject empty normalization (e.g. only symbols/whitespace).
if symbol_key.is_empty() || name_key.is_empty() {
return Err(ContractError::Std(StdError::generic_err(
"name and symbol must contain at least one letter or digit",
)));
}
if SYMBOL_PERMA_BLOCK.has(deps.storage, &symbol_key) {
return Err(ContractError::NameCooldown {
field: "symbol (permanently taken — graduated token)".into(),
value: symbol.clone(),
seconds_ago: 0,
remaining: u64::MAX,
});
}
if NAME_PERMA_BLOCK.has(deps.storage, &name_key) {
return Err(ContractError::NameCooldown {
field: "name (permanently taken — graduated token)".into(),
value: name.clone(),
seconds_ago: 0,
remaining: u64::MAX,
});
}
if let Some(last_used) = SYMBOL_COOLDOWN.may_load(deps.storage, &symbol_key)? {
let elapsed = now.saturating_sub(last_used);
if elapsed < cooldown {
return Err(ContractError::NameCooldown {
field: "symbol".into(),
value: symbol.clone(),
seconds_ago: elapsed,
remaining: cooldown - elapsed,
});
}
}
if let Some(last_used) = NAME_COOLDOWN.may_load(deps.storage, &name_key)? {
let elapsed = now.saturating_sub(last_used);
if elapsed < cooldown {
return Err(ContractError::NameCooldown {
field: "name".into(),
value: name.clone(),
seconds_ago: elapsed,
remaining: cooldown - elapsed,
});
}
}
// Reserve the symbol and name immediately (before SubMsg)
SYMBOL_COOLDOWN.save(deps.storage, &symbol_key, &now)?;
NAME_COOLDOWN.save(deps.storage, &name_key, &now)?;
// Load oracle price (prefer live oracle contract, fall back to local) and
// compute per-curve bonding curve parameters.
let bwick_usd_price = get_bwick_usd_price(deps.as_ref(), &config);
if bwick_usd_price == 0 {
return Err(ContractError::OraclePriceRequired {});
}
let params = compute_curve_params(
bwick_usd_price,
config.target_starting_mc_usd,
config.target_graduation_usd, // graduation MC target
config.target_raised_usd,
config.target_raised_bwick,
)?;
// Compute initial graduation threshold for this curve
let grad_threshold = compute_dynamic_threshold(
bwick_usd_price,
config.target_raised_usd,
config.min_graduation_threshold,
config.max_graduation_threshold,
config.graduation_threshold,
config.target_raised_bwick,
);
// Store pending curve info for reply handler
let pending = PendingCurve {
metadata: TokenMetadata {
name: name.clone(),
symbol: symbol.clone(),
image,
description,
social_links,
},
creator: info.sender.clone(),
initial_bwick: bwick_sent,
virtual_bwick_start: params.virtual_bwick_start,
virtual_tokens_start: params.virtual_tokens_start,
curve_k: params.curve_k,
tokens_on_curve: params.tokens_on_curve,
tokens_for_lp: params.tokens_for_lp,
graduation_threshold_ubwick: grad_threshold,
presale,
creator_vesting,
lp_lock_seconds,
};
PENDING_CURVE.save(deps.storage, &pending)?;
// Instantiate CW20 token with this contract as minter
// All tokens go to this contract (held by curve)
let cw20_instantiate_msg = cw20_base::msg::InstantiateMsg {
name,
symbol,
decimals: 6,
initial_balances: vec![cw20::Cw20Coin {
address: env.contract.address.to_string(),
amount: Uint128::from(TOTAL_SUPPLY),
}],
mint: None, // No minting after creation (fixed supply)
marketing: None,
};
let instantiate_msg = WasmMsg::Instantiate {
admin: None,
code_id: config.cw20_code_id,
msg: to_binary(&cw20_instantiate_msg)?,
funds: vec![],
label: format!("bwick-launchpad-{}", pending.metadata.symbol),
};
// Use SubMsg to get reply with contract address
let sub_msg = SubMsg::reply_on_success(instantiate_msg, REPLY_CW20_INSTANTIATE);
Ok(Response::new()
.add_submessage(sub_msg)
.add_attribute("action", "create_token")
.add_attribute("creator", info.sender)
.add_attribute("initial_bwick", bwick_sent.to_string()))
}
/// Extract BWICK amount from funds
fn extract_bwick_from_funds(funds: &[Coin]) -> Result<u128, ContractError> {
for coin in funds {
if coin.denom == "ubwick" {
return Ok(coin.amount.u128());
}
}
Err(ContractError::InsufficientFunds {})
}
fn execute_update_bwick_price(
deps: DepsMut,
env: Env,
info: MessageInfo,
bwick_usd_price: Uint128,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
if info.sender != config.admin {
return Err(ContractError::Unauthorized {});
}
if bwick_usd_price.is_zero() {
return Err(ContractError::ZeroPrice {});
}
let oracle = OracleState {
bwick_usd_price: bwick_usd_price.u128(),
last_update_height: env.block.height,
last_update_timestamp: env.block.time.seconds(),
};
ORACLE_STATE.save(deps.storage, &oracle)?;
Ok(Response::new()
.add_attribute("action", "update_bwick_price")
.add_attribute("price", bwick_usd_price)
.add_attribute("height", env.block.height.to_string()))
}
#[allow(clippy::too_many_arguments)]
fn execute_update_config(
deps: DepsMut,
info: MessageInfo,
target_graduation_usd: Option<Uint128>,
min_graduation_threshold: Option<Uint128>,
max_graduation_threshold: Option<Uint128>,
target_starting_mc_usd: Option<Uint128>,
target_raised_usd: Option<Uint128>,
target_raised_bwick: Option<Uint128>,
max_wallet_bps: Option<u16>,
fee_exempt_address: Option<String>,
oracle_contract: Option<String>,
name_cooldown_seconds: Option<u64>,
vesting_code_id: Option<u64>,
presales_enabled: Option<bool>,
) -> Result<Response, ContractError> {
let mut config = CONFIG.load(deps.storage)?;
if info.sender != config.admin {
return Err(ContractError::Unauthorized {});
}
if let Some(val) = target_graduation_usd {
config.target_graduation_usd = val.u128();
}
if let Some(val) = min_graduation_threshold {
config.min_graduation_threshold = val.u128();
}
if let Some(val) = max_graduation_threshold {
config.max_graduation_threshold = val.u128();
}
if let Some(val) = target_starting_mc_usd {
config.target_starting_mc_usd = val.u128();
}
if let Some(val) = target_raised_usd {
config.target_raised_usd = val.u128();
}
if let Some(val) = target_raised_bwick {
config.target_raised_bwick = val.u128();
}
if let Some(val) = presales_enabled {
config.presales_enabled = val;
}
if let Some(val) = max_wallet_bps {
config.max_wallet_bps = val;
}
if let Some(addr) = fee_exempt_address {
config.fee_exempt_address = if addr.is_empty() {
Addr::unchecked("")
} else {
deps.api.addr_validate(&addr)?
};
}
if let Some(addr) = oracle_contract {
config.oracle_contract = if addr.is_empty() {
Addr::unchecked("")
} else {
deps.api.addr_validate(&addr)?
};
}
if let Some(secs) = name_cooldown_seconds {
config.name_cooldown_seconds = secs;
}
if let Some(id) = vesting_code_id {
config.vesting_code_id = id;
}
// Validate bounds after updates
if config.min_graduation_threshold > config.max_graduation_threshold {
return Err(ContractError::InvalidThresholdBounds {
min: config.min_graduation_threshold,
max: config.max_graduation_threshold,
});
}
CONFIG.save(deps.storage, &config)?;
Ok(Response::new()
.add_attribute("action", "update_config")
.add_attribute("target_graduation_usd", config.target_graduation_usd.to_string())
.add_attribute("min_threshold", config.min_graduation_threshold.to_string())
.add_attribute("max_threshold", config.max_graduation_threshold.to_string())
.add_attribute("target_starting_mc_usd", config.target_starting_mc_usd.to_string())
.add_attribute("target_raised_usd", config.target_raised_usd.to_string()))
}
/// Load oracle and compute effective threshold for a curve.
fn load_effective_threshold(
deps: Deps,
config: &Config,
curve: &Curve,
) -> u128 {
let oracle_price = get_bwick_usd_price(deps, config);
let computed = compute_dynamic_threshold(
oracle_price,
config.target_raised_usd,
config.min_graduation_threshold,
config.max_graduation_threshold,
config.graduation_threshold, // fallback = old global threshold
config.target_raised_bwick,
);
effective_threshold_pure(
curve.graduation_threshold_ubwick,
computed,
config.graduation_threshold, // legacy fallback
)
}
// ── Metadata voting (v3) ──────────────────────────────────────────────────
/// Composite key helper. Tally storage is `(token, "field:value")` because
/// cw-storage-plus owned-String composite keys are clumsier. Field names
/// are fixed ASCII so there's no escaping concern.
fn tally_key(field: &str, value: &str) -> String {
let mut s = String::with_capacity(field.len() + 1 + value.len());
s.push_str(field);
s.push(':');
s.push_str(value);
s
}
/// Query the live CW20 balance for `voter` against `token`. Returns u128.
fn query_cw20_balance(
deps: Deps,
token: &Addr,
voter: &Addr,
) -> StdResult<u128> {
let r: cw20::BalanceResponse = deps.querier.query_wasm_smart(
token,
&cw20::Cw20QueryMsg::Balance { address: voter.to_string() },
)?;
Ok(r.balance.u128())
}
/// Iterate all distinct values for (token, field) and return (winner, winner_weight, total_weight).
/// `applied_value` is used as the tiebreaker when no votes have been cast or
/// when the winning value has zero weight — i.e. the applied value sticks.
fn find_leader(
deps: Deps,
token: &Addr,
field: &str,
applied_value: &str,
) -> StdResult<(String, u128, u128)> {
let prefix = format!("{}:", field);
let mut best_value = applied_value.to_string();
let mut best_weight: u128 = 0;
let mut total: u128 = 0;
// Iterate the (token, "field:value") tallies and pick the highest weight.
// The prefix bound keeps us scoped to a single field.
let entries: StdResult<Vec<_>> = METADATA_TALLIES
.prefix(token)
.range(deps.storage, None, None, cosmwasm_std::Order::Ascending)
.collect();
for (key, weight) in entries? {
if !key.starts_with(&prefix) {
continue;
}
total = total.saturating_add(weight);
if weight > best_weight {
best_weight = weight;
best_value = key[prefix.len()..].to_string();
}
}
Ok((best_value, best_weight, total))
}
/// Default-init the AppliedMetadata for a token if not yet stored. Called
/// from execute_create_token after the curve is finalized. Also called
/// lazily by vote / apply paths so the contract migrates cleanly.
fn ensure_applied_metadata(
deps: &mut DepsMut,
env: &Env,
token: &Addr,
) -> Result<AppliedMetadata, ContractError> {
let now = env.block.time.seconds();
if let Some(mut existing) = APPLIED_METADATA.may_load(deps.storage, token)? {
// Pre-v3 records (or pre-fields migration) won't have
// created_at_secs. Backfill from last_applied_at so the 48h voting
// window is anchored at first init. This preserves intent for
// tokens that existed before this code rolled out.
if existing.created_at_secs == 0 {
existing.created_at_secs = existing.last_applied_at.max(1);
APPLIED_METADATA.save(deps.storage, token, &existing)?;
}
return Ok(existing);
}
let curve = CURVES
.may_load(deps.storage, token)?
.ok_or(ContractError::CurveNotFound { token: token.to_string() })?;
let applied = AppliedMetadata {
name: curve.metadata.name.clone(),
symbol: curve.metadata.symbol.clone(),
image: curve.metadata.image.clone(),
description: curve.metadata.description.clone(),
last_applied_at: now,
created_at_secs: now,
first_vote_at: 0,
apply_count: 0,
twitter: social_at(&curve.metadata.social_links, 0),
telegram: social_at(&curve.metadata.social_links, 1),
website: social_at(&curve.metadata.social_links, 2),
};
APPLIED_METADATA.save(deps.storage, token, &applied)?;
Ok(applied)
}
/// Compute the (winner, leading_weight, total_weight) for one field and
/// determine if the leader differs from the applied value. Used by both
/// the inline-update path (in VoteMetadata when apply_count > 0) and the
/// explicit ApplyMetadata path.
fn leader_for_field(
deps: Deps,
token: &Addr,
field: &str,
applied: &str,
) -> StdResult<(String, bool)> {
let (leader, _w, _total) = find_leader(deps, token, field, applied)?;
let changed = leader != applied;
Ok((leader, changed))
}
/// Returns true if voting is still open for this token (within
/// METADATA_VOTING_WINDOW_SECS of opening).
fn voting_open(applied: &AppliedMetadata, now: u64) -> bool {
let close_at = applied
.created_at_secs
.saturating_add(METADATA_VOTING_WINDOW_SECS);
now < close_at
}
fn execute_vote_metadata(
mut deps: DepsMut,
env: Env,
info: MessageInfo,
token_address: String,
name: Option<String>,
symbol: Option<String>,
image: Option<String>,
description: Option<String>,
) -> Result<Response, ContractError> {
let token = deps.api.addr_validate(&token_address)?;
// Validate curve exists.
if !CURVES.has(deps.storage, &token) {
return Err(ContractError::CurveNotFound { token: token.to_string() });
}
let applied = ensure_applied_metadata(&mut deps, &env, &token)?;
// 48h voting window — refuse new votes after the close.
let now = env.block.time.seconds();
if !voting_open(&applied, now) {
return Err(ContractError::Std(StdError::generic_err(
"metadata voting window has closed (48h from token creation)",
)));
}
// Reject empty-string votes (use None to withdraw).
for (label, v) in [
("name", &name),
("symbol", &symbol),
("image", &image),
("description", &description),
] {
if let Some(s) = v {
if s.is_empty() {
return Err(ContractError::Std(StdError::generic_err(format!(
"field {} cannot be empty; pass null to withdraw vote",
label
))));
}
}
}
// Pull voter's live CW20 balance — that's their voting weight.
let weight = query_cw20_balance(deps.as_ref(), &token, &info.sender)?;
if weight == 0 {
return Err(ContractError::Std(StdError::generic_err(
"voter holds none of this token",
)));
}
// Subtract the voter's previous tallies, if any.
let prev = METADATA_VOTES
.may_load(deps.storage, (&token, &info.sender))?
.unwrap_or_default();
let prev_weight = prev.weight;
for (field, value) in [
("n", prev.name.as_deref()),
("s", prev.symbol.as_deref()),
("i", prev.image.as_deref()),
("d", prev.description.as_deref()),
] {
if let Some(v) = value {
let key = tally_key(field, v);
let cur = METADATA_TALLIES
.may_load(deps.storage, (&token, &key))?
.unwrap_or(0);
let new = cur.saturating_sub(prev_weight);
if new == 0 {
METADATA_TALLIES.remove(deps.storage, (&token, &key));
} else {
METADATA_TALLIES.save(deps.storage, (&token, &key), &new)?;
}
}
}
// Add new tallies.
for (field, value) in [
("n", name.as_deref()),
("s", symbol.as_deref()),
("i", image.as_deref()),
("d", description.as_deref()),
] {
if let Some(v) = value {
let key = tally_key(field, v);
let cur = METADATA_TALLIES
.may_load(deps.storage, (&token, &key))?
.unwrap_or(0);
METADATA_TALLIES.save(
deps.storage,
(&token, &key),
&cur.saturating_add(weight),
)?;
}
}
// Persist the voter's record (or remove if they cleared every field).
let is_non_empty =
name.is_some() || symbol.is_some() || image.is_some() || description.is_some();
if !is_non_empty {
METADATA_VOTES.remove(deps.storage, (&token, &info.sender));
} else {
METADATA_VOTES.save(
deps.storage,
(&token, &info.sender),
&MetadataVote {
name,
symbol,
image,
description,
weight,
},
)?;
}
// Inline-apply: once the first apply has run (apply_count > 0), the
// applied metadata IS whatever's currently winning. This vote may
// have shifted the leader, so refresh applied immediately. The first
// apply still requires the explicit ApplyMetadata call (10-min
// cooldown) so the demo has a "stabilization window" before mutations
// start propagating live.
let mut applied2 = applied;
// Set first_vote_at on the first non-empty vote for this token. The
// 10-min stabilization countdown runs from this timestamp, not from
// token creation, so a token with no voter activity doesn't auto-snap.
if applied2.first_vote_at == 0 && is_non_empty {
applied2.first_vote_at = now;
APPLIED_METADATA.save(deps.storage, &token, &applied2)?;
}
let mut inline_changed: Vec<&str> = Vec::new();
if applied2.apply_count > 0 {
let (n, c_n) = leader_for_field(deps.as_ref(), &token, "n", &applied2.name)?;
let (s, c_s) = leader_for_field(deps.as_ref(), &token, "s", &applied2.symbol)?;
let (i, c_i) = leader_for_field(deps.as_ref(), &token, "i", &applied2.image)?;
let (d, c_d) = leader_for_field(deps.as_ref(), &token, "d", &applied2.description)?;
if c_n { applied2.name = n; inline_changed.push("name"); }
if c_s { applied2.symbol = s; inline_changed.push("symbol"); }
if c_i { applied2.image = i; inline_changed.push("image"); }
if c_d { applied2.description = d; inline_changed.push("description"); }
if !inline_changed.is_empty() {
applied2.last_applied_at = now;
applied2.apply_count = applied2.apply_count.saturating_add(1);
APPLIED_METADATA.save(deps.storage, &token, &applied2)?;
CURVES.update(deps.storage, &token, |c| -> Result<_, ContractError> {
let mut c = c.ok_or(ContractError::CurveNotFound { token: token.to_string() })?;
c.metadata.name = applied2.name.clone();
c.metadata.symbol = applied2.symbol.clone();
c.metadata.image = applied2.image.clone();
c.metadata.description = applied2.description.clone();
Ok(c)
})?;
}
}
Ok(Response::new()
.add_attribute("action", "vote_metadata")
.add_attribute("token", token.to_string())
.add_attribute("voter", info.sender.to_string())
.add_attribute("weight", weight.to_string())
.add_attribute("inline_applied", inline_changed.join(",")))
}
fn execute_apply_metadata(
mut deps: DepsMut,
env: Env,
_info: MessageInfo,
token_address: String,
) -> Result<Response, ContractError> {
let token = deps.api.addr_validate(&token_address)?;
if !CURVES.has(deps.storage, &token) {
return Err(ContractError::CurveNotFound { token: token.to_string() });
}
let mut applied = ensure_applied_metadata(&mut deps, &env, &token)?;
let now = env.block.time.seconds();
// 48h voting window — refuse explicit applies after close (votes are
// already refused at that point).
if !voting_open(&applied, now) {
return Err(ContractError::Std(StdError::generic_err(
"metadata voting window has closed (48h from token creation)",
)));
}
// Two-phase apply:
// - apply_count == 0: this is the FIRST apply for the token. It's
// gated behind METADATA_FIRST_APPLY_DELAY_SECS (10 min) from
// `first_vote_at` — the timer starts on the first non-empty vote,
// not from token creation. Refuses entirely if nobody has voted.
// - apply_count > 0: subsequent applies happen inline at vote time.
// This explicit call becomes a no-op fallback for cases where the
// dApp wants to force a recompute.
if applied.apply_count == 0 {
if applied.first_vote_at == 0 {
return Err(ContractError::Std(StdError::generic_err(
"no votes cast yet — first-apply countdown hasn't started",
)));
}
let elapsed = now.saturating_sub(applied.first_vote_at);
if elapsed < METADATA_FIRST_APPLY_DELAY_SECS {
return Err(ContractError::Std(StdError::generic_err(format!(
"first apply in {}s",
METADATA_FIRST_APPLY_DELAY_SECS - elapsed
))));
}
}
let (n, _, _) = find_leader(deps.as_ref(), &token, "n", &applied.name)?;
let (s, _, _) = find_leader(deps.as_ref(), &token, "s", &applied.symbol)?;
let (i, _, _) = find_leader(deps.as_ref(), &token, "i", &applied.image)?;
let (d, _, _) = find_leader(deps.as_ref(), &token, "d", &applied.description)?;
let mut changed = Vec::<&str>::new();
if n != applied.name { applied.name = n; changed.push("name"); }
if s != applied.symbol { applied.symbol = s; changed.push("symbol"); }
if i != applied.image { applied.image = i; changed.push("image"); }
if d != applied.description { applied.description = d; changed.push("description"); }
applied.last_applied_at = now;
applied.apply_count = applied.apply_count.saturating_add(1);
APPLIED_METADATA.save(deps.storage, &token, &applied)?;
// Mirror into the Curve so AllCurves/Curve responses naturally serve
// the canonical metadata without callers having to query
// APPLIED_METADATA separately.
CURVES.update(deps.storage, &token, |c| -> Result<_, ContractError> {
let mut c = c.ok_or(ContractError::CurveNotFound { token: token.to_string() })?;
c.metadata.name = applied.name.clone();
c.metadata.symbol = applied.symbol.clone();
c.metadata.image = applied.image.clone();
c.metadata.description = applied.description.clone();
Ok(c)
})?;
Ok(Response::new()
.add_attribute("action", "apply_metadata")
.add_attribute("token", token.to_string())
.add_attribute("changed", changed.join(",")))
}
fn execute_buy(
deps: DepsMut,
_env: Env,
info: MessageInfo,
token_address: String,
min_tokens_out: Uint128,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
let token_addr = deps.api.addr_validate(&token_address)?;
// Load curve
let mut curve = CURVES.load(deps.storage, &token_addr)
.map_err(|_| ContractError::CurveNotFound { token: token_address.clone() })?;
// Check not graduated
if curve.graduated {
return Err(ContractError::AlreadyGraduated {});
}
// v4: block public Buy while a presale is open. Only BuyPresale
// can move tokens during the presale window; FinalizePresale
// flips `finalized=true` and opens the curve for everyone.
if let Some(state) = PRESALES.may_load(deps.storage, &token_addr)? {
if !state.finalized {
return Err(ContractError::PresaleNotFinalized {});
}
}
// Extract BWICK input
let bwick_input = extract_bwick_from_funds(&info.funds)?;
if bwick_input == 0 {
return Err(ContractError::InsufficientFunds {});
}
// Get per-curve constants (or legacy fallback)
let (_vx, vt, k, toc, _tlp) = get_curve_constants(&curve);
// Calculate tokens out using constant product curve
let (tokens_out, fee) = calculate_buy_cp(curve.tokens_sold, bwick_input, config.buy_fee_bps, vt, k, toc)?;
// Check remaining supply on curve
let tokens_remaining_on_curve = toc.saturating_sub(curve.tokens_sold);
if tokens_out > tokens_remaining_on_curve {
return Err(ContractError::NoTokensAvailable {});
}
// Check slippage
if tokens_out < min_tokens_out.u128() {
return Err(ContractError::SlippageExceeded {
expected: min_tokens_out.u128(),
actual: tokens_out,
});
}
// Check max wallet holding limit
check_max_wallet(
&deps,
&token_address,
info.sender.as_str(),
tokens_out,
config.max_wallet_bps,
)?;
// Update curve state — all fees stay in reserves (LP)
curve.tokens_sold += tokens_out;
curve.bwick_reserves += bwick_input;
// Compute effective threshold and ratchet it on the curve
let threshold = load_effective_threshold(deps.as_ref(), &config, &curve);
curve.graduation_threshold_ubwick = Some(match curve.graduation_threshold_ubwick {
Some(existing) => existing.max(threshold),
None => threshold,
});
// Check graduation threshold - auto-graduate if reached
if curve.bwick_reserves >= threshold && !curve.graduated {
// Mark as graduated
curve.graduated = true;
CURVES.save(deps.storage, &token_addr, &curve)?;
// Calculate AMM pool liquidity
let bwick_for_pool = curve.bwick_reserves;
let tokens_remaining = TOTAL_SUPPLY - curve.tokens_sold;
// Build messages
let mut messages: Vec<cosmwasm_std::CosmosMsg> = vec![];
// Transfer tokens to buyer
messages.push(cosmwasm_std::CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: token_address.clone(),
msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer {
recipient: info.sender.to_string(),
amount: Uint128::from(tokens_out),
})?,
funds: vec![],
}));
// Transfer remaining tokens to AMM
messages.push(cosmwasm_std::CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: token_address.clone(),
msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer {
recipient: config.amm_contract.to_string(),
amount: Uint128::from(tokens_remaining),
})?,
funds: vec![],
}));
// Create AMM pool with augmented fee protection
let lp_target = bwick_for_pool
.checked_mul(GRADUATION_LP_TARGET_MULTIPLIER)
.unwrap_or(bwick_for_pool);
messages.push(cosmwasm_std::CosmosMsg::Wasm(WasmMsg::Execute {
contract_addr: config.amm_contract.to_string(),
msg: to_binary(&AmmExecuteMsg::CreatePool {
token_address: token_address.clone(),
bwick_amount: Uint128::from(bwick_for_pool),
token_amount: Uint128::from(tokens_remaining),
augmented_fee_bps: Some(GRADUATION_AUGMENTED_FEE_BPS),
lp_target_ubwick: Some(lp_target.to_string()),
locked_lp_unlock_at: None,
locked_lp_recipient: None,
})?,
funds: vec![Coin {
denom: "ubwick".to_string(),
amount: Uint128::from(bwick_for_pool),
}],
}));
return Ok(Response::new()
.add_messages(messages)
.add_attribute("action", "buy_and_graduate")
.add_attribute("buyer", info.sender.to_string())
.add_attribute("token_address", token_address)
.add_attribute("tokens_out", tokens_out.to_string())
.add_attribute("graduated", "true")
.add_attribute("bwick_for_pool", bwick_for_pool.to_string())
.add_attribute("tokens_for_pool", tokens_remaining.to_string()));
}
// Save curve (non-graduating case)
CURVES.save(deps.storage, &token_addr, &curve)?;
// Transfer tokens to buyer
let transfer_msg = WasmMsg::Execute {
contract_addr: token_address.clone(),
msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer {
recipient: info.sender.to_string(),
amount: Uint128::from(tokens_out),
})?,
funds: vec![],
};
Ok(Response::new()
.add_message(transfer_msg)
.add_attribute("action", "buy")
.add_attribute("buyer", info.sender.to_string())
.add_attribute("token_address", token_address)
.add_attribute("bwick_input", bwick_input.to_string())
.add_attribute("tokens_out", tokens_out.to_string())
.add_attribute("fee", fee.to_string()))
}
fn execute_receive(
deps: DepsMut,
_env: Env,
info: MessageInfo,
cw20_msg: cw20::Cw20ReceiveMsg,
) -> Result<Response, ContractError> {
use crate::msg::SellTokens;
let config = CONFIG.load(deps.storage)?;
// info.sender is the CW20 token contract
let token_addr = info.sender;
// Load curve
let mut curve = CURVES.load(deps.storage, &token_addr)
.map_err(|_| ContractError::CurveNotFound { token: token_addr.to_string() })?;
// Check not graduated
if curve.graduated {
return Err(ContractError::AlreadyGraduated {});
}
// v4: block public Sell while a presale is open (same as Buy).
if let Some(state) = PRESALES.may_load(deps.storage, &token_addr)? {
if !state.finalized {
return Err(ContractError::PresaleNotFinalized {});
}
}
// Parse sell message
let sell_msg: SellTokens = cosmwasm_std::from_slice(&cw20_msg.msg)?;
let user_addr = deps.api.addr_validate(&cw20_msg.sender)?;
let tokens_input = cw20_msg.amount.u128();
if tokens_input == 0 {
return Err(ContractError::Std(StdError::generic_err("Zero token amount")));
}
// Get per-curve constants (or legacy fallback)
let (_vx, vt, k, _toc, _tlp) = get_curve_constants(&curve);
// Calculate BWICK out using constant product curve
let (bwick_out, total_fee) = calculate_sell_cp(
curve.tokens_sold,
tokens_input,
config.sell_fee_bps,
vt,
k,
)?;
// All fees stay in reserves (LP) — no burn, no creator share
// Cap at available reserves if curve math exceeds actual balance
// (can happen for curves created before a curve-type migration)
let mut bwick_out = bwick_out;
if bwick_out > curve.bwick_reserves {
bwick_out = curve.bwick_reserves;
}
// Check slippage (after reserves cap so user sees realistic amount)
if bwick_out < sell_msg.min_bwick_out.u128() {
return Err(ContractError::SlippageExceeded {
expected: sell_msg.min_bwick_out.u128(),
actual: bwick_out,
});
}
// Update curve state
curve.tokens_sold -= tokens_input;
curve.bwick_reserves -= bwick_out; // Only bwick_out leaves; fees stay in reserves
// Save curve
CURVES.save(deps.storage, &token_addr, &curve)?;
// Send BWICK to seller
let send_msg = BankMsg::Send {
to_address: user_addr.to_string(),
amount: vec![Coin {
denom: "ubwick".to_string(),
amount: Uint128::from(bwick_out),
}],
};
Ok(Response::new()
.add_message(send_msg)
.add_attribute("action", "sell")
.add_attribute("seller", user_addr.to_string())
.add_attribute("token_address", token_addr.to_string())
.add_attribute("tokens_input", tokens_input.to_string())
.add_attribute("bwick_out", bwick_out.to_string())
.add_attribute("fee_to_lp", total_fee.to_string()))
}
/// AMM ExecuteMsg for cross-contract calls
#[cw_serde]
pub enum AmmExecuteMsg {
CreatePool {
token_address: String,
bwick_amount: Uint128,
token_amount: Uint128,
/// Optional augmented fee in basis points (100 = 1%)
augmented_fee_bps: Option<u16>,
/// Optional target pool value in ubwick for augmented fee auto-disable
lp_target_ubwick: Option<String>,
/// v4: optional unix timestamp at which the locked LP seed
/// becomes withdrawable. Missing = permanent lock.
#[serde(default, skip_serializing_if = "Option::is_none")]
locked_lp_unlock_at: Option<u64>,
/// v4: address allowed to withdraw the locked LP after unlock.
#[serde(default, skip_serializing_if = "Option::is_none")]
locked_lp_recipient: Option<String>,
},
}
/// Default augmented fee for graduated pools: 1% (100 bps)
const GRADUATION_AUGMENTED_FEE_BPS: u16 = 100;
/// LP target multiplier: pool must grow 10x before augmented fee disables
const GRADUATION_LP_TARGET_MULTIPLIER: u128 = 10;
fn execute_graduate(
deps: DepsMut,
env: Env,
_info: MessageInfo,
token_address: String,
) -> Result<Response, ContractError> {
let config = CONFIG.load(deps.storage)?;
let token_addr = deps.api.addr_validate(&token_address)?;
// Load curve
let mut curve = CURVES.load(deps.storage, &token_addr)
.map_err(|_| ContractError::CurveNotFound { token: token_address.clone() })?;
// Check not already graduated
if curve.graduated {
return Err(ContractError::AlreadyGraduated {});
}
// Check threshold reached using effective threshold
let threshold = load_effective_threshold(deps.as_ref(), &config, &curve);
if curve.bwick_reserves < threshold {
return Err(ContractError::Std(StdError::generic_err(format!(
"Graduation threshold not reached: {} / {} BWICK",
curve.bwick_reserves, threshold
))));
}
// Mark as graduated (closes the curve)
curve.graduated = true;
CURVES.save(deps.storage, &token_addr, &curve)?;
// Anti-vampire: lock the (normalized) name + symbol forever. The token
// now lives on the AMM and has holders; allowing a fork to claim the
// same branding would let an attacker drain trust without effort.
let now_secs = env.block.time.seconds();
let name_key = normalize_for_cooldown(&curve.metadata.name);
let symbol_key = normalize_for_cooldown(&curve.metadata.symbol);
if !name_key.is_empty() {
NAME_PERMA_BLOCK.save(deps.storage, &name_key, &now_secs)?;
}
if !symbol_key.is_empty() {
SYMBOL_PERMA_BLOCK.save(deps.storage, &symbol_key, &now_secs)?;
}
// Calculate AMM pool liquidity
// All BWICK reserves + all remaining tokens go to AMM
let bwick_for_pool = curve.bwick_reserves;
let tokens_remaining = TOTAL_SUPPLY - curve.tokens_sold;
// Transfer remaining tokens from this contract to AMM
let transfer_tokens_msg = WasmMsg::Execute {
contract_addr: token_address.clone(),
msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer {
recipient: config.amm_contract.to_string(),
amount: Uint128::from(tokens_remaining),
})?,
funds: vec![],
};
// Create AMM pool with BWICK funds and augmented fee protection
let lp_target = bwick_for_pool
.checked_mul(GRADUATION_LP_TARGET_MULTIPLIER)
.unwrap_or(bwick_for_pool);
// v4: forward the lp_lock duration set at CreateToken time. The
// unlock timestamp is "now + lp_lock_seconds"; the recipient is
// the token's creator. None / Some(0) => leave as permanent lock.
let (locked_lp_unlock_at, locked_lp_recipient) = match curve.lp_lock_seconds {
Some(secs) if secs > 0 => (
Some(env.block.time.seconds().saturating_add(secs)),
Some(curve.creator.to_string()),
),
_ => (None, None),
};
let create_pool_msg = WasmMsg::Execute {
contract_addr: config.amm_contract.to_string(),
msg: to_binary(&AmmExecuteMsg::CreatePool {
token_address: token_address.clone(),
bwick_amount: Uint128::from(bwick_for_pool),
token_amount: Uint128::from(tokens_remaining),
augmented_fee_bps: Some(GRADUATION_AUGMENTED_FEE_BPS),
lp_target_ubwick: Some(lp_target.to_string()),
locked_lp_unlock_at,
locked_lp_recipient,
})?,
funds: vec![Coin {
denom: "ubwick".to_string(),
amount: Uint128::from(bwick_for_pool),
}],
};
Ok(Response::new()
.add_message(transfer_tokens_msg)
.add_message(create_pool_msg)
.add_attribute("action", "graduate")
.add_attribute("token_address", token_address)
.add_attribute("bwick_for_pool", bwick_for_pool.to_string())
.add_attribute("tokens_for_pool", tokens_remaining.to_string())
.add_attribute("creator", curve.creator.to_string()))
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> Result<Response, ContractError> {
match msg.id {
REPLY_CW20_INSTANTIATE => reply_cw20_instantiate(deps, env, msg),
REPLY_VESTING_INSTANTIATE => reply_vesting_instantiate(deps, env, msg),
_ => Err(ContractError::Std(StdError::generic_err("Unknown reply id"))),
}
}
// v4: parses the freshly-instantiated bwick-vesting contract address,
// records it on the PresaleState, and funds it via CW20 Send (which
// triggers vesting's Receive hook to lock in the schedule total).
fn reply_vesting_instantiate(
deps: DepsMut,
_env: Env,
msg: Reply,
) -> Result<Response, ContractError> {
let res = parse_instantiate_response_data(
msg.result
.into_result()
.map_err(|e| ContractError::Std(StdError::generic_err(e)))?
.data
.ok_or_else(|| {
ContractError::Std(StdError::generic_err(
"vesting reply missing data",
))
})?
.as_slice(),
)
.map_err(|e| ContractError::Std(StdError::generic_err(e.to_string())))?;
let vesting_addr = deps.api.addr_validate(&res.contract_address)?;
let pending = PENDING_FINALIZE.load(deps.storage)?;
PENDING_FINALIZE.remove(deps.storage);
// Record the vesting contract address on the presale state.
let mut state = PRESALES.load(deps.storage, &pending.token_address)?;
state.creator_vesting_contract = Some(vesting_addr.clone());
PRESALES.save(deps.storage, &pending.token_address, &state)?;
VESTING_INSTANCES.save(deps.storage, &pending.token_address, &vesting_addr)?;
// Fund the vesting account via CW20 Send. The vesting contract
// accepts the Receive hook once and pins the schedule.
let curve = CURVES.load(deps.storage, &pending.token_address)?;
let send_msg = WasmMsg::Execute {
contract_addr: curve.token_address.to_string(),
msg: to_binary(&cw20::Cw20ExecuteMsg::Send {
contract: vesting_addr.to_string(),
amount: Uint128::from(pending.vesting_funding_utokens),
msg: Binary::default(),
})?,
funds: vec![],
};
Ok(Response::new()
.add_message(send_msg)
.add_attribute("action", "vesting_instantiate_reply")
.add_attribute("vesting_contract", vesting_addr.to_string())
.add_attribute("funded_utokens", pending.vesting_funding_utokens.to_string()))
}
fn reply_cw20_instantiate(
deps: DepsMut,
env: Env,
msg: Reply,
) -> Result<Response, ContractError> {
// Parse reply to get new contract address
let response = msg.result.into_result().map_err(StdError::generic_err)?;
// CosmWasm 1.x uses data field (2.0+ uses msg_responses)
let data = response.data
.ok_or_else(|| ContractError::Std(StdError::generic_err("No instantiate response data found")))?;
let res = parse_instantiate_response_data(&data.as_slice())
.map_err(|e| ContractError::Std(StdError::generic_err(format!("Parse error: {}", e))))?;
let token_address = deps.api.addr_validate(&res.contract_address)?;
// Load pending curve data
let pending = PENDING_CURVE.load(deps.storage)?;
PENDING_CURVE.remove(deps.storage);
// Create curve with creation fee as initial BWICK reserves
let grad_threshold = if pending.graduation_threshold_ubwick > 0 {
Some(pending.graduation_threshold_ubwick)
} else {
None
};
let curve = crate::state::Curve {
token_address: token_address.clone(),
metadata: pending.metadata.clone(),
creator: pending.creator.clone(),
tokens_sold: 0,
bwick_reserves: pending.initial_bwick,
graduated: false,
created_at: env.block.height,
creator_fees_earned: 0,
graduation_threshold_ubwick: grad_threshold,
virtual_bwick_start: pending.virtual_bwick_start,
virtual_tokens_start: pending.virtual_tokens_start,
curve_k: pending.curve_k,
tokens_on_curve: pending.tokens_on_curve,
tokens_for_lp: pending.tokens_for_lp,
lp_lock_seconds: pending.lp_lock_seconds,
};
// Save curve indexed by token address
CURVES.save(deps.storage, &token_address, &curve)?;
// v4: if a presale config was attached at CreateToken, persist it
// here so BuyPresale + FinalizePresale can find it. The token's
// bonding curve is left in place but gated by the PresaleState
// until FinalizePresale flips `finalized = true`.
if let Some(cfg) = pending.presale.clone() {
PRESALES.save(
deps.storage,
&token_address,
&crate::state::PresaleState {
config: cfg,
bwick_raised: 0,
tokens_sold: 0,
finalized: false,
failed: false,
escrowed: true,
creator_vesting_contract: None,
},
)?;
}
// Seed AppliedMetadata with the creator's values so initial reads + the
// first ApplyMetadata after votes accumulate have a baseline. The 48h
// voting window starts now (created_at_secs = now).
let now_secs = env.block.time.seconds();
APPLIED_METADATA.save(
deps.storage,
&token_address,
&AppliedMetadata {
name: curve.metadata.name.clone(),
symbol: curve.metadata.symbol.clone(),
image: curve.metadata.image.clone(),
description: curve.metadata.description.clone(),
last_applied_at: now_secs,
created_at_secs: now_secs,
first_vote_at: 0,
apply_count: 0,
twitter: social_at(&curve.metadata.social_links, 0),
telegram: social_at(&curve.metadata.social_links, 1),
website: social_at(&curve.metadata.social_links, 2),
},
)?;
Ok(Response::new()
.add_attribute("action", "token_created")
.add_attribute("token_address", token_address.to_string())
.add_attribute("creator", pending.creator)
.add_attribute("initial_reserves", pending.initial_bwick.to_string()))
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::Curve { token_address } => {
to_binary(&query_curve(deps, token_address)?)
}
QueryMsg::AllCurves { start_after, limit } => {
to_binary(&query_all_curves(deps, start_after, limit)?)
}
QueryMsg::Progress { token_address } => {
to_binary(&query_progress(deps, token_address)?)
}
QueryMsg::Config {} => {
to_binary(&query_config(deps)?)
}
QueryMsg::SimulateBuy { token_address, bwick_amount } => {
to_binary(&query_simulate_buy(deps, token_address, bwick_amount)?)
}
QueryMsg::SimulateSell { token_address, token_amount } => {
to_binary(&query_simulate_sell(deps, token_address, token_amount)?)
}
QueryMsg::Oracle {} => {
to_binary(&query_oracle(deps)?)
}
QueryMsg::MetadataState { token_address } => {
to_binary(&query_metadata_state(deps, env, token_address)?)
}
QueryMsg::MetadataVote { token_address, voter } => {
to_binary(&query_metadata_vote(deps, token_address, voter)?)
}
QueryMsg::Presale { token_address } => {
to_binary(&query_presale(deps, env, token_address)?)
}
QueryMsg::PresaleContribution { token_address, buyer } => {
to_binary(&query_presale_contribution(deps, token_address, buyer)?)
}
}
}
/// v7: pay out an escrowed presale position after finalize.
/// Failed presale: full BWICK refund. Successful: tokens at the presale
/// price for the pro-rata ACCEPTED portion, plus refund of any surplus
/// (oversubscription past the hard cap).
fn execute_claim_presale(
deps: DepsMut,
_env: Env,
info: MessageInfo,
token_address: String,
) -> Result<Response, ContractError> {
let token_addr = deps.api.addr_validate(&token_address)?;
let state = PRESALES
.may_load(deps.storage, &token_addr)?
.ok_or_else(|| ContractError::NoPresale { token: token_address.clone() })?;
if !state.finalized {
return Err(ContractError::PresaleNotFinalizedForClaim {});
}
if !state.escrowed {
// Legacy presales delivered tokens at buy time; nothing to claim.
return Err(ContractError::PresaleNothingToClaim {});
}
let contribution = PRESALE_CONTRIBUTIONS
.may_load(deps.storage, (&token_addr, &info.sender))?
.unwrap_or(0);
if contribution == 0 {
return Err(ContractError::PresaleNothingToClaim {});
}
if PRESALE_CLAIMED
.may_load(deps.storage, (&token_addr, &info.sender))?
.unwrap_or(false)
{
return Err(ContractError::PresaleAlreadyClaimed {});
}
let mut response = Response::new()
.add_attribute("action", "claim_presale")
.add_attribute("token_address", token_address.clone())
.add_attribute("claimer", info.sender.to_string());
if state.failed {
// Full refund.
response = response
.add_message(cosmwasm_std::BankMsg::Send {
to_address: info.sender.to_string(),
amount: vec![cosmwasm_std::Coin {
denom: "ubwick".to_string(),
amount: Uint128::from(contribution),
}],
})
.add_attribute("refund_ubwick", contribution.to_string())
.add_attribute("tokens_out", "0");
} else {
// Pro-rata acceptance: accepted_i = contribution_i * accepted_total / raised.
let accepted_total = state.bwick_raised.min(state.config.hard_cap_ubwick);
let accepted = if state.bwick_raised == 0 {
0
} else {
// contribution and raise both fit in u128; use checked math via
// widening through u128 mul-div (contribution <= raised, so the
// product fits if contribution * accepted_total does; clamp via
// u128::checked_mul fallback to proportional division order).
contribution
.checked_mul(accepted_total)
.map(|product| product / state.bwick_raised)
.unwrap_or_else(|| (contribution / state.bwick_raised.max(1)).saturating_mul(accepted_total))
};
let refund = contribution.saturating_sub(accepted);
let tokens_out = accepted / state.config.price_ubwick_per_utoken.max(1);
let curve = CURVES
.load(deps.storage, &token_addr)
.map_err(|_| ContractError::CurveNotFound { token: token_address.clone() })?;
if tokens_out > 0 {
response = response.add_message(WasmMsg::Execute {
contract_addr: curve.token_address.to_string(),
msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer {
recipient: info.sender.to_string(),
amount: Uint128::from(tokens_out),
})?,
funds: vec![],
});
}
if refund > 0 {
response = response.add_message(cosmwasm_std::BankMsg::Send {
to_address: info.sender.to_string(),
amount: vec![cosmwasm_std::Coin {
denom: "ubwick".to_string(),
amount: Uint128::from(refund),
}],
});
}
response = response
.add_attribute("refund_ubwick", refund.to_string())
.add_attribute("tokens_out", tokens_out.to_string());
}
PRESALE_CLAIMED.save(deps.storage, (&token_addr, &info.sender), &true)?;
Ok(response)
}
fn query_presale(
deps: Deps,
env: Env,
token_address: String,
) -> StdResult<PresaleResponse> {
let token_addr = deps.api.addr_validate(&token_address)?;
let state = PRESALES
.may_load(deps.storage, &token_addr)?
.ok_or_else(|| StdError::not_found(format!("Presale for {}", token_address)))?;
let now = env.block.time.seconds();
let is_active = !state.finalized
&& state.config.start_time <= now
&& now < state.config.end_time;
let can_finalize = !state.finalized
&& (now >= state.config.end_time || state.bwick_raised >= state.config.hard_cap_ubwick);
Ok(PresaleResponse {
token_address: token_addr,
config: state.config.clone(),
bwick_raised: Uint128::from(state.bwick_raised),
tokens_sold: Uint128::from(state.tokens_sold),
finalized: state.finalized,
failed: state.failed,
escrowed: state.escrowed,
creator_vesting_contract: state.creator_vesting_contract,
is_active,
can_finalize,
now_seconds: now,
})
}
fn query_presale_contribution(
deps: Deps,
token_address: String,
buyer: String,
) -> StdResult<PresaleContributionResponse> {
let token_addr = deps.api.addr_validate(&token_address)?;
let buyer_addr = deps.api.addr_validate(&buyer)?;
let bwick_contributed = PRESALE_CONTRIBUTIONS
.may_load(deps.storage, (&token_addr, &buyer_addr))?
.unwrap_or(0);
// tokens_received is derivable from contribution / price; the
// dedicated handler will record it directly in a follow-up commit.
// For now: compute the floor of (contribution / price) if presale
// exists, else zero.
let claimed = PRESALE_CLAIMED
.may_load(deps.storage, (&token_addr, &buyer_addr))?
.unwrap_or(false);
let (tokens_received, refundable) = match PRESALES.may_load(deps.storage, &token_addr)? {
Some(state) if bwick_contributed > 0 => {
if state.finalized && state.failed {
(0, bwick_contributed)
} else if state.finalized && state.escrowed {
let accepted_total = state.bwick_raised.min(state.config.hard_cap_ubwick);
let accepted = if state.bwick_raised == 0 {
0
} else {
bwick_contributed
.checked_mul(accepted_total)
.map(|product| product / state.bwick_raised)
.unwrap_or(0)
};
(
accepted / state.config.price_ubwick_per_utoken.max(1),
bwick_contributed.saturating_sub(accepted),
)
} else {
// Pre-finalize estimate (or legacy instant-delivery presale).
(bwick_contributed / state.config.price_ubwick_per_utoken.max(1), 0)
}
}
_ => (0, 0),
};
Ok(PresaleContributionResponse {
buyer: buyer_addr,
token_address: token_addr,
bwick_contributed: Uint128::from(bwick_contributed),
tokens_received: Uint128::from(tokens_received),
claimed,
refundable_ubwick: Uint128::from(refundable),
})
}
fn query_metadata_state(
deps: Deps,
_env: Env,
token_address: String,
) -> StdResult<MetadataStateResponse> {
let token = deps.api.addr_validate(&token_address)?;
// If the curve doesn't exist, propagate a StdError so the dApp shows
// a clean "token not found" rather than a generic decode failure.
if !CURVES.has(deps.storage, &token) {
return Err(StdError::generic_err("token not found"));
}
// Pull applied metadata. If somehow missing (pre-v3 storage), fall back
// to the Curve's stored metadata.
let applied = APPLIED_METADATA
.may_load(deps.storage, &token)?
.unwrap_or_else(|| {
let curve = CURVES.load(deps.storage, &token).unwrap_or_else(|_| {
// unreachable — we checked .has above
Curve {
token_address: token.clone(),
metadata: TokenMetadata {
name: String::new(),
symbol: String::new(),
image: String::new(),
description: String::new(),
social_links: vec![],
},
creator: token.clone(),
tokens_sold: 0,
bwick_reserves: 0,
graduated: false,
created_at: 0,
creator_fees_earned: 0,
graduation_threshold_ubwick: None,
virtual_bwick_start: 0,
virtual_tokens_start: 0,
curve_k: 0,
tokens_on_curve: 0,
tokens_for_lp: 0,
lp_lock_seconds: None,
}
});
let twitter = social_at(&curve.metadata.social_links, 0);
let telegram = social_at(&curve.metadata.social_links, 1);
let website = social_at(&curve.metadata.social_links, 2);
AppliedMetadata {
name: curve.metadata.name,
symbol: curve.metadata.symbol,
image: curve.metadata.image,
description: curve.metadata.description,
last_applied_at: 0,
created_at_secs: 0,
first_vote_at: 0,
apply_count: 0,
twitter,
telegram,
website,
}
});
let make_field = |field: &str, applied_value: &str| -> StdResult<MetadataFieldState> {
let (leading, leading_w, total) =
find_leader(deps, &token, field, applied_value)?;
Ok(MetadataFieldState {
applied: applied_value.to_string(),
leading,
leading_weight: Uint128::from(leading_w),
total_weight: Uint128::from(total),
})
};
let name = make_field("n", &applied.name)?;
let symbol = make_field("s", &applied.symbol)?;
let image = make_field("i", &applied.image)?;
let description = make_field("d", &applied.description)?;
// First apply: 10-min cooldown from first_vote_at. After votes have
// started, the countdown is real; if nobody has voted yet the dApp
// sees u64::MAX-shape sentinel and renders "waiting for first vote".
let next_apply_eligible_at = if applied.apply_count == 0 {
if applied.first_vote_at == 0 {
0 // sentinel: countdown not started; dApp renders the wait state
} else {
applied
.first_vote_at
.saturating_add(METADATA_FIRST_APPLY_DELAY_SECS)
}
} else {
applied.created_at_secs
};
let voting_closes_at = applied
.created_at_secs
.saturating_add(METADATA_VOTING_WINDOW_SECS);
Ok(MetadataStateResponse {
token_address: token,
name,
symbol,
image,
description,
last_applied_at: applied.last_applied_at,
next_apply_eligible_at,
apply_count: applied.apply_count,
created_at_secs: applied.created_at_secs,
voting_closes_at,
first_vote_at: applied.first_vote_at,
})
}
fn query_metadata_vote(
deps: Deps,
token_address: String,
voter: String,
) -> StdResult<MetadataVoteResponse> {
let token = deps.api.addr_validate(&token_address)?;
let voter_addr = deps.api.addr_validate(&voter)?;
let v = METADATA_VOTES
.may_load(deps.storage, (&token, &voter_addr))?
.unwrap_or_default();
Ok(MetadataVoteResponse {
voter: voter_addr,
name: v.name,
symbol: v.symbol,
image: v.image,
description: v.description,
weight: Uint128::from(v.weight),
})
}
fn query_curve(deps: Deps, token_address: String) -> StdResult<CurveResponse> {
let token_addr = deps.api.addr_validate(&token_address)?;
let curve = CURVES.load(deps.storage, &token_addr)
.map_err(|_| StdError::not_found(format!("Curve for token {}", token_address)))?;
let (vx, vt, k, toc, tlp) = get_curve_constants(&curve);
let tokens_remaining = toc.saturating_sub(curve.tokens_sold);
let current_price = calculate_price_cp(curve.tokens_sold, vt, k);
Ok(CurveResponse {
token_address: curve.token_address,
metadata: curve.metadata,
creator: curve.creator,
tokens_sold: Uint128::from(curve.tokens_sold),
tokens_remaining: Uint128::from(tokens_remaining),
bwick_reserves: Uint128::from(curve.bwick_reserves),
current_price: format!("{:.6}", current_price as f64 / 1_000_000.0),
graduated: curve.graduated,
created_at: curve.created_at,
virtual_bwick_start: Uint128::from(vx),
virtual_tokens_start: Uint128::from(vt),
tokens_on_curve: Uint128::from(toc),
tokens_for_lp: Uint128::from(tlp),
})
}
fn query_all_curves(
deps: Deps,
start_after: Option<String>,
limit: Option<u32>,
) -> StdResult<AllCurvesResponse> {
let limit = limit.unwrap_or(10).min(30) as usize;
let start = start_after
.map(|s| deps.api.addr_validate(&s))
.transpose()?;
let curves: Vec<CurveResponse> = CURVES
.range(
deps.storage,
start.as_ref().map(cw_storage_plus::Bound::exclusive),
None,
cosmwasm_std::Order::Ascending,
)
.take(limit)
.map(|item| {
let (_, curve) = item?;
let (vx, vt, k, toc, tlp) = get_curve_constants(&curve);
let tokens_remaining = toc.saturating_sub(curve.tokens_sold);
let current_price = calculate_price_cp(curve.tokens_sold, vt, k);
Ok(CurveResponse {
token_address: curve.token_address,
metadata: curve.metadata,
creator: curve.creator,
tokens_sold: Uint128::from(curve.tokens_sold),
tokens_remaining: Uint128::from(tokens_remaining),
bwick_reserves: Uint128::from(curve.bwick_reserves),
current_price: format!("{:.6}", current_price as f64 / 1_000_000.0),
graduated: curve.graduated,
created_at: curve.created_at,
virtual_bwick_start: Uint128::from(vx),
virtual_tokens_start: Uint128::from(vt),
tokens_on_curve: Uint128::from(toc),
tokens_for_lp: Uint128::from(tlp),
})
})
.collect::<StdResult<Vec<_>>>()?;
Ok(AllCurvesResponse { curves })
}
fn query_progress(deps: Deps, token_address: String) -> StdResult<ProgressResponse> {
let token_addr = deps.api.addr_validate(&token_address)?;
let curve = CURVES.load(deps.storage, &token_addr)
.map_err(|_| StdError::not_found(format!("Curve for token {}", token_address)))?;
let config = CONFIG.load(deps.storage)?;
let threshold = load_effective_threshold(deps, &config, &curve);
let progress_percent = if threshold > 0 {
(curve.bwick_reserves as f64 / threshold as f64 * 100.0).min(100.0)
} else {
100.0
};
let (_vx, _vt, _k, toc, _tlp) = get_curve_constants(&curve);
let tokens_remaining = toc.saturating_sub(curve.tokens_sold);
Ok(ProgressResponse {
token_address: curve.token_address,
bwick_raised: Uint128::from(curve.bwick_reserves),
graduation_threshold: Uint128::from(threshold),
progress_percent: format!("{:.2}%", progress_percent),
tokens_sold: Uint128::from(curve.tokens_sold),
tokens_remaining: Uint128::from(tokens_remaining),
graduated: curve.graduated,
})
}
fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
let config = CONFIG.load(deps.storage)?;
Ok(ConfigResponse {
amm_contract: config.amm_contract,
cw20_code_id: config.cw20_code_id,
creation_fee: Uint128::from(config.creation_fee),
graduation_threshold: Uint128::from(config.graduation_threshold),
buy_fee_bps: config.buy_fee_bps,
sell_fee_bps: config.sell_fee_bps,
admin: config.admin,
target_graduation_usd: Uint128::from(config.target_graduation_usd),
min_graduation_threshold: Uint128::from(config.min_graduation_threshold),
max_graduation_threshold: Uint128::from(config.max_graduation_threshold),
target_starting_mc_usd: Uint128::from(config.target_starting_mc_usd),
target_raised_usd: Uint128::from(config.target_raised_usd),
target_raised_bwick: Uint128::from(config.target_raised_bwick),
max_wallet_bps: config.max_wallet_bps,
fee_exempt_address: config.fee_exempt_address,
})
}
fn query_oracle(deps: Deps) -> StdResult<OracleResponse> {
// Prefer the live oracle contract's TWAP if configured.
let config = CONFIG.load(deps.storage)?;
if !config.oracle_contract.as_str().is_empty() {
let req = OracleTwapQueryMsg {
twap: OracleTwapArgs { window_seconds: Some(60) },
};
if let Ok(resp) = deps.querier.query_wasm_smart::<OracleTwapResponse>(
config.oracle_contract.as_str(),
&req,
) {
return Ok(OracleResponse {
bwick_usd_price: resp.bwick_usd_price,
last_update_height: resp.last_update_height,
last_update_timestamp: resp.last_update_timestamp,
});
}
}
// Fallback to legacy local oracle.
let oracle = ORACLE_STATE.may_load(deps.storage)?
.unwrap_or(OracleState {
bwick_usd_price: 0,
last_update_height: 0,
last_update_timestamp: 0,
});
Ok(OracleResponse {
bwick_usd_price: Uint128::from(oracle.bwick_usd_price),
last_update_height: oracle.last_update_height,
last_update_timestamp: oracle.last_update_timestamp,
})
}
fn query_simulate_buy(
deps: Deps,
token_address: String,
bwick_amount: Uint128,
) -> StdResult<SimulateBuyResponse> {
let token_addr = deps.api.addr_validate(&token_address)?;
let curve = CURVES.load(deps.storage, &token_addr)
.map_err(|_| StdError::not_found(format!("Curve for token {}", token_address)))?;
let config = CONFIG.load(deps.storage)?;
if curve.graduated {
return Err(StdError::generic_err("Curve already graduated - use AMM for trading"));
}
let (_vx, vt, k, toc, _tlp) = get_curve_constants(&curve);
let (tokens_out, fee) = calculate_buy_cp(curve.tokens_sold, bwick_amount.u128(), config.buy_fee_bps, vt, k, toc)
.map_err(|e| StdError::generic_err(format!("Simulation failed: {:?}", e)))?;
let new_sold = curve.tokens_sold + tokens_out;
let new_price = calculate_price_cp(new_sold, vt, k);
Ok(SimulateBuyResponse {
tokens_out: Uint128::from(tokens_out),
fee_amount: Uint128::from(fee),
new_price: format!("{:.6}", new_price as f64 / 1_000_000.0),
})
}
fn query_simulate_sell(
deps: Deps,
token_address: String,
token_amount: Uint128,
) -> StdResult<SimulateSellResponse> {
let token_addr = deps.api.addr_validate(&token_address)?;
let curve = CURVES.load(deps.storage, &token_addr)
.map_err(|_| StdError::not_found(format!("Curve for token {}", token_address)))?;
let config = CONFIG.load(deps.storage)?;
if curve.graduated {
return Err(StdError::generic_err("Curve already graduated - use AMM for trading"));
}
let (_vx, vt, k, _toc, _tlp) = get_curve_constants(&curve);
let (bwick_out, total_fee) = calculate_sell_cp(
curve.tokens_sold,
token_amount.u128(),
config.sell_fee_bps,
vt,
k,
).map_err(|e| StdError::generic_err(format!("Simulation failed: {:?}", e)))?;
// Cap at available reserves (matches execute path behavior)
let mut bwick_out = bwick_out;
if bwick_out > curve.bwick_reserves {
bwick_out = curve.bwick_reserves;
}
let new_sold = curve.tokens_sold - token_amount.u128();
let new_price = calculate_price_cp(new_sold, vt, k);
Ok(SimulateSellResponse {
bwick_out: Uint128::from(bwick_out),
fee_amount: Uint128::from(total_fee),
burned_amount: Uint128::zero(),
new_price: format!("{:.6}", new_price as f64 / 1_000_000.0),
})
}
#[cfg_attr(not(feature = "library"), entry_point)]
pub fn migrate(
deps: DepsMut,
_env: Env,
msg: MigrateMsg,
) -> Result<Response, ContractError> {
// Parametric migrate: the wasm-level admin can selectively reset Config
// fields. Designed so that a previously-corrupted Config (e.g. clobbered
// by a hardcoded migration in an earlier version) can be repaired by the
// operator who controls the wasm admin without re-instantiating.
let mut config = CONFIG.load(deps.storage)?;
if let Some(addr) = msg.admin {
config.admin = if addr.is_empty() { Addr::unchecked("") } else { deps.api.addr_validate(&addr)? };
}
if let Some(addr) = msg.oracle_contract {
config.oracle_contract = if addr.is_empty() { Addr::unchecked("") } else { deps.api.addr_validate(&addr)? };
}
if let Some(addr) = msg.fee_exempt_address {
config.fee_exempt_address = if addr.is_empty() { Addr::unchecked("") } else { deps.api.addr_validate(&addr)? };
}
if let Some(v) = msg.creation_fee { config.creation_fee = v.u128(); }
if let Some(v) = msg.graduation_threshold { config.graduation_threshold = v.u128(); }
if let Some(v) = msg.min_graduation_threshold { config.min_graduation_threshold = v.u128(); }
if let Some(v) = msg.max_graduation_threshold { config.max_graduation_threshold = v.u128(); }
if let Some(v) = msg.target_graduation_usd { config.target_graduation_usd = v.u128(); }
if let Some(v) = msg.target_starting_mc_usd { config.target_starting_mc_usd = v.u128(); }
if let Some(v) = msg.target_raised_usd { config.target_raised_usd = v.u128(); }
if let Some(v) = msg.buy_fee_bps { config.buy_fee_bps = v; }
if let Some(v) = msg.sell_fee_bps { config.sell_fee_bps = v; }
if let Some(v) = msg.creator_fee_share_bps { config.creator_fee_share_bps = v; }
if let Some(v) = msg.max_wallet_bps { config.max_wallet_bps = v; }
CONFIG.save(deps.storage, &config)?;
set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
Ok(Response::new()
.add_attribute("action", "migrate")
.add_attribute("version", CONTRACT_VERSION))
}
// ── v4: presale handlers + helpers ───────────────────────────────────────
//
// Status: validation + dispatch wiring is complete. Buy/Finalize logic is
// currently STUB (returns a clear error). Follow-up commit lands:
// - BuyPresale: window/cap/Merkle gate, CW20 transfer to buyer, accounting
// - FinalizePresale: vesting instantiate (SubMsg + reply), curve unlock
// - reply_vesting_instantiate (REPLY_VESTING_INSTANTIATE)
// - Public Buy/Sell gating when an unfinalized presale exists
// - lp_lock_seconds propagation into AmmExecuteMsg::CreatePool
//
// Stub keeps the contract compileable + the message surface advertised so
// frontends + SDKs can integrate against the type shape in parallel.
/// Convention from `social_links` Vec into the three named slots used by
/// metadata voting. Position 0=twitter, 1=telegram, 2=website. Anything
/// beyond index 2 is ignored. Missing slots become empty strings.
fn social_at(social_links: &[String], idx: usize) -> String {
social_links.get(idx).cloned().unwrap_or_default()
}
fn validate_presale_config(
cfg: &PresaleConfig,
now_seconds: u64,
) -> Result<(), ContractError> {
if cfg.price_ubwick_per_utoken == 0 {
return Err(ContractError::InvalidPresaleConfig {
reason: "price_ubwick_per_utoken must be > 0".into(),
});
}
if cfg.hard_cap_ubwick == 0 {
return Err(ContractError::InvalidPresaleConfig {
reason: "hard_cap_ubwick must be > 0".into(),
});
}
if cfg.end_time <= cfg.start_time {
return Err(ContractError::InvalidPresaleConfig {
reason: "end_time must be > start_time".into(),
});
}
if cfg.end_time <= now_seconds {
return Err(ContractError::InvalidPresaleConfig {
reason: "end_time must be in the future".into(),
});
}
if let Some(cap) = cfg.per_wallet_cap_ubwick {
if cap == 0 || cap > cfg.hard_cap_ubwick {
return Err(ContractError::InvalidPresaleConfig {
reason: "per_wallet_cap_ubwick must be in (0, hard_cap_ubwick]".into(),
});
}
}
if cfg.creator_allocation_bps > MAX_CREATOR_ALLOCATION_BPS {
return Err(ContractError::CreatorAllocationTooHigh {
bps: cfg.creator_allocation_bps,
max_bps: MAX_CREATOR_ALLOCATION_BPS,
});
}
Ok(())
}
/// SHA-256 a byte slice. Helper for Merkle leaf + node hashing.
fn sha256(input: &[u8]) -> [u8; 32] {
use sha2::Digest;
let mut h = sha2::Sha256::new();
h.update(input);
let out = h.finalize();
let mut buf = [0u8; 32];
buf.copy_from_slice(&out);
buf
}
/// Verify a Merkle proof for `leaf` against `root`. Pair-hashing convention:
/// sort each (left, right) pair lexicographically before hashing, matching
/// the OpenZeppelin / standard EVM Merkle proof convention. `proof` is an
/// ordered list of sibling hashes from leaf to root.
fn merkle_verify(leaf: &[u8; 32], proof: &[Binary], root: &[u8; 32]) -> bool {
let mut computed = *leaf;
for sibling in proof {
let sib_slice = sibling.as_slice();
if sib_slice.len() != 32 {
return false;
}
let mut concat = [0u8; 64];
if computed.as_slice() <= sib_slice {
concat[..32].copy_from_slice(&computed);
concat[32..].copy_from_slice(sib_slice);
} else {
concat[..32].copy_from_slice(sib_slice);
concat[32..].copy_from_slice(&computed);
}
computed = sha256(&concat);
}
computed == *root
}
/// Canonical leaf hash for an address. The off-chain allowlist tooling
/// must use the same hashing scheme: leaf = sha256(addr_str_utf8_bytes).
/// Using the validated bech32 string keeps the on-chain side simple and
/// avoids needing to canonicalize via deps.api.addr_canonicalize.
fn merkle_leaf_for(addr: &Addr) -> [u8; 32] {
sha256(addr.as_str().as_bytes())
}
fn execute_buy_presale(
deps: DepsMut,
env: Env,
info: MessageInfo,
token_address: String,
merkle_proof: Vec<Binary>,
min_tokens_out: Uint128,
) -> Result<Response, ContractError> {
let token_addr = deps.api.addr_validate(&token_address)?;
let mut state = PRESALES
.may_load(deps.storage, &token_addr)?
.ok_or_else(|| ContractError::NoPresale { token: token_address.clone() })?;
if state.finalized {
return Err(ContractError::PresaleAlreadyFinalized {});
}
// Window check.
let now = env.block.time.seconds();
if now < state.config.start_time {
return Err(ContractError::PresaleNotStarted {
start_time: state.config.start_time,
now,
});
}
if now >= state.config.end_time {
return Err(ContractError::PresaleEnded {
end_time: state.config.end_time,
now,
});
}
// Funds check — single BWICK coin, positive amount.
let bwick_in = extract_bwick_from_funds(&info.funds)?;
if bwick_in == 0 {
return Err(ContractError::InsufficientFunds {});
}
// Hard-cap check. v7 oversubscribed presales keep accepting past the
// cap (MetaDAO-style); acceptance is settled pro rata at finalize.
let effective_in = if state.config.allow_oversubscription {
bwick_in
} else {
let remaining_to_cap = state
.config
.hard_cap_ubwick
.saturating_sub(state.bwick_raised);
if remaining_to_cap == 0 {
return Err(ContractError::PresaleHardCapReached {
raised: state.bwick_raised,
hard_cap: state.config.hard_cap_ubwick,
});
}
// Clip the caller's input to whatever's still buyable.
bwick_in.min(remaining_to_cap)
};
// Per-wallet cap (if set).
if let Some(cap) = state.config.per_wallet_cap_ubwick {
let prior = PRESALE_CONTRIBUTIONS
.may_load(deps.storage, (&token_addr, &info.sender))?
.unwrap_or(0);
if prior.saturating_add(effective_in) > cap {
return Err(ContractError::PresalePerWalletCapExceeded {
contributing: effective_in,
cap,
});
}
}
// Merkle gate — empty root means open presale, any proof ignored.
let root = state.config.merkle_root;
let zero_root = [0u8; 32];
if root != zero_root {
let leaf = merkle_leaf_for(&info.sender);
if !merkle_verify(&leaf, &merkle_proof, &root) {
return Err(ContractError::PresaleAllowlistRejected {});
}
}
// Tokens out = effective_in / price_ubwick_per_utoken (truncate).
let price = state.config.price_ubwick_per_utoken;
if price == 0 {
return Err(ContractError::InvalidPresaleConfig {
reason: "price must be > 0".into(),
});
}
let tokens_out = effective_in / price;
if tokens_out == 0 {
return Err(ContractError::Std(StdError::generic_err(
"buy amount too small to receive any tokens at the configured price",
)));
}
if tokens_out < min_tokens_out.u128() {
return Err(ContractError::SlippageExceeded {
expected: min_tokens_out.u128(),
actual: tokens_out,
});
}
// v7 escrow model: tokens stay with the launchpad until ClaimPresale
// after finalize (needed for refunds + pro-rata). Legacy presales
// (escrowed=false) transfer immediately, as before.
let curve = CURVES
.load(deps.storage, &token_addr)
.map_err(|_| ContractError::CurveNotFound { token: token_address.clone() })?;
let transfer_msg = if state.escrowed {
None
} else {
Some(WasmMsg::Execute {
contract_addr: curve.token_address.to_string(),
msg: to_binary(&cw20::Cw20ExecuteMsg::Transfer {
recipient: info.sender.to_string(),
amount: Uint128::from(tokens_out),
})?,
funds: vec![],
})
};
// Update state.
state.bwick_raised = state.bwick_raised.saturating_add(effective_in);
state.tokens_sold = state.tokens_sold.saturating_add(tokens_out);
PRESALES.save(deps.storage, &token_addr, &state)?;
let prior_contribution = PRESALE_CONTRIBUTIONS
.may_load(deps.storage, (&token_addr, &info.sender))?
.unwrap_or(0);
PRESALE_CONTRIBUTIONS.save(
deps.storage,
(&token_addr, &info.sender),
&(prior_contribution.saturating_add(effective_in)),
)?;
// If the caller sent MORE than what we could accept (capped by
// the hard cap), refund the surplus.
let mut response = Response::new();
if let Some(msg) = transfer_msg {
response = response.add_message(msg);
}
response = response
.add_attribute("action", "buy_presale")
.add_attribute("buyer", info.sender.to_string())
.add_attribute("token_address", token_address)
.add_attribute("bwick_in", effective_in.to_string())
.add_attribute("tokens_out", tokens_out.to_string())
.add_attribute("bwick_raised", state.bwick_raised.to_string())
.add_attribute("tokens_sold", state.tokens_sold.to_string());
let refund = bwick_in.saturating_sub(effective_in);
if refund > 0 {
response = response
.add_message(BankMsg::Send {
to_address: info.sender.to_string(),
amount: vec![Coin {
denom: "ubwick".to_string(),
amount: Uint128::from(refund),
}],
})
.add_attribute("refund_ubwick", refund.to_string());
}
Ok(response)
}
fn execute_finalize_presale(
deps: DepsMut,
env: Env,
_info: MessageInfo,
token_address: String,
) -> Result<Response, ContractError> {
let token_addr = deps.api.addr_validate(&token_address)?;
let mut state = PRESALES
.may_load(deps.storage, &token_addr)?
.ok_or_else(|| ContractError::NoPresale { token: token_address.clone() })?;
if state.finalized {
return Err(ContractError::PresaleAlreadyFinalized {});
}
let now = env.block.time.seconds();
let cap_hit = state.bwick_raised >= state.config.hard_cap_ubwick;
let window_done = now >= state.config.end_time;
if !cap_hit && !window_done {
return Err(ContractError::PresaleNotFinalizable {
end_time: state.config.end_time,
hard_cap: state.config.hard_cap_ubwick,
raised: state.bwick_raised,
now,
});
}
let config = CONFIG.load(deps.storage)?;
let curve = CURVES
.load(deps.storage, &token_addr)
.map_err(|_| ContractError::CurveNotFound { token: token_address.clone() })?;
// Creator allocation = total_supply × bps / 10000. Only matters
// when bps > 0; vesting_code_id must be configured in that case.
let total_supply = TOTAL_SUPPLY;
let creator_alloc_utokens = (total_supply as u128)
.saturating_mul(state.config.creator_allocation_bps as u128)
/ 10_000u128;
// v7 (MetaDAO-style): if the raise closed below the configured
// minimum, the presale FAILS. No curve seeding, no vesting; every
// contributor claims a full refund via ClaimPresale.
if state.config.min_raise_ubwick > 0 && state.bwick_raised < state.config.min_raise_ubwick {
state.finalized = true;
state.failed = true;
PRESALES.save(deps.storage, &token_addr, &state)?;
return Ok(Response::new()
.add_attribute("action", "finalize_presale")
.add_attribute("token_address", token_address)
.add_attribute("result", "failed_min_raise")
.add_attribute("bwick_raised", state.bwick_raised.to_string())
.add_attribute("min_raise", state.config.min_raise_ubwick.to_string()));
}
// v7 pro-rata settlement: only up to the hard cap is accepted. The
// surplus stays in the launchpad's bank balance and is paid back to
// contributors pro rata at ClaimPresale.
let accepted_ubwick = state.bwick_raised.min(state.config.hard_cap_ubwick);
let tokens_sold_final = if state.escrowed {
accepted_ubwick / state.config.price_ubwick_per_utoken.max(1)
} else {
state.tokens_sold
};
// Permissionless: anyone can call FinalizePresale once eligible.
// The funded creator allocation goes to vesting; the rest of the
// unsold supply seeds the bonding curve. The accepted BWICK is
// added to the curve's reserves so the curve continues from the
// presale's price point.
state.finalized = true;
state.tokens_sold = tokens_sold_final;
PRESALES.save(deps.storage, &token_addr, &state)?;
// Reflect the ACCEPTED BWICK on the curve (not the oversubscribed raw).
let mut curve_mut = curve.clone();
curve_mut.bwick_reserves = curve_mut
.bwick_reserves
.saturating_add(accepted_ubwick);
curve_mut.tokens_sold = curve_mut.tokens_sold.saturating_add(tokens_sold_final);
CURVES.save(deps.storage, &token_addr, &curve_mut)?;
let mut response = Response::new()
.add_attribute("action", "finalize_presale")
.add_attribute("token_address", token_address.clone())
.add_attribute("bwick_raised", state.bwick_raised.to_string())
.add_attribute("tokens_sold", state.tokens_sold.to_string())
.add_attribute("creator_allocation_utokens", creator_alloc_utokens.to_string());
if creator_alloc_utokens > 0 {
if config.vesting_code_id == 0 {
return Err(ContractError::InvalidPresaleConfig {
reason: "creator_allocation set but Config.vesting_code_id is unset".into(),
});
}
// Stash data needed by the reply handler to fund the vesting
// account once it's instantiated and we know its address.
PENDING_FINALIZE.save(
deps.storage,
&PendingFinalize {
token_address: token_addr.clone(),
creator_allocation_utokens: creator_alloc_utokens,
vesting_funding_utokens: creator_alloc_utokens,
},
)?;
let creator_vesting = curve
.clone();
let _ = creator_vesting; // silence unused
// Build the vesting Instantiate. Cliff and end_time are
// computed against the actual presale end_time so callers
// can configure them once at CreateToken time.
#[derive(serde::Serialize)]
struct VestingInstantiate<'a> {
token: &'a str,
beneficiary: &'a str,
start_time: u64,
cliff_time: u64,
end_time: u64,
}
let start_time = state.config.end_time;
// Defaults: linear from end_time over 30 days. Real cliffs
// come from the CreatorVestingConfig stored in PendingCurve
// at create time and are forwarded to this Instantiate via
// a later wiring (kept simple here).
let cliff_time = start_time;
let end_time = start_time.saturating_add(30 * 86_400);
let init = VestingInstantiate {
token: curve.token_address.as_str(),
beneficiary: curve.creator.as_str(),
start_time,
cliff_time,
end_time,
};
let submsg = SubMsg::reply_on_success(
WasmMsg::Instantiate {
admin: Some(env.contract.address.to_string()),
code_id: config.vesting_code_id,
msg: to_binary(&init)?,
funds: vec![],
label: format!("bwick-vesting-{}", curve.metadata.symbol),
},
REPLY_VESTING_INSTANTIATE,
);
response = response.add_submessage(submsg);
}
Ok(response)
}
#[cfg(test)]
mod tests {
use super::*;
// ===========================================
// Constants Tests
// ===========================================
#[test]
fn test_total_supply_constant() {
// 100 thousand tokens with 6 decimals
assert_eq!(TOTAL_SUPPLY, 100_000_000_000);
}
#[test]
fn test_cp_tokens_on_curve_plus_lp_equals_total() {
assert_eq!(TOKENS_ON_CURVE + TOKENS_FOR_LP, TOTAL_SUPPLY);
}
// ===========================================
// Graduation Threshold Tests
// ===========================================
#[test]
fn test_graduation_threshold_check() {
// Test threshold boundary conditions
let threshold = 5_000_000_000_000u128; // 5M BWICK
// Just below threshold
let reserves_below = threshold - 1;
assert!(reserves_below < threshold);
// At threshold
let reserves_at = threshold;
assert!(reserves_at >= threshold);
// Above threshold
let reserves_above = threshold + 1;
assert!(reserves_above >= threshold);
}
// ===========================================
// Fee Basis Points Tests
// ===========================================
#[test]
fn test_buy_fee_bps_validation() {
// Buy fee should be 50 bps (0.5%)
let buy_fee_bps: u16 = 50;
let bwick_amount = 1_000_000u128; // 1 BWICK
let fee = bwick_amount * (buy_fee_bps as u128) / 10000;
assert_eq!(fee, 5000); // 0.5% of 1M = 5000
}
#[test]
fn test_sell_fee_bps_validation() {
// Sell fee should be 350 bps (3.5%)
let sell_fee_bps: u16 = 350;
let bwick_amount = 1_000_000u128; // 1 BWICK
let fee = bwick_amount * (sell_fee_bps as u128) / 10000;
assert_eq!(fee, 35000); // 3.5% of 1M = 35000
}
#[test]
fn test_max_fee_validation() {
// Buy fee max is 500 bps (5%)
// Sell fee max is 1000 bps (10%)
let max_buy_fee: u16 = 500;
let max_sell_fee: u16 = 1000;
assert!(50 <= max_buy_fee, "Default buy fee should be within max");
assert!(350 <= max_sell_fee, "Default sell fee should be within max");
}
// ===========================================
// Constant Product Curve Tests
// ===========================================
#[test]
fn test_cp_constants_consistency() {
// TOKENS_ON_CURVE + TOKENS_FOR_LP == TOTAL_SUPPLY
assert_eq!(
TOKENS_ON_CURVE + TOKENS_FOR_LP,
TOTAL_SUPPLY,
"Curve tokens + LP tokens must equal total supply"
);
// K == VIRTUAL_BWICK_START * VIRTUAL_TOKENS_START
assert_eq!(
K,
VIRTUAL_BWICK_START * VIRTUAL_TOKENS_START,
"K must equal product of virtual reserves"
);
// Virtual tokens must exceed tokens on curve
assert!(
VIRTUAL_TOKENS_START > TOKENS_ON_CURVE,
"Virtual token reserve must be greater than tokens on curve"
);
}
#[test]
fn test_cp_price_at_zero_sold() {
let price = calculate_price_cp(0, VIRTUAL_TOKENS_START, K);
// price per whole token (10^6 base units) = K * 10^6 / VIRTUAL_TOKENS_START^2
let expected = K * 1_000_000 / (VIRTUAL_TOKENS_START * VIRTUAL_TOKENS_START);
assert_eq!(price, expected, "Price at zero sold should match formula");
// Sanity: should be a small positive number (around 324,324 ubwick with current constants)
assert!(price > 0 && price < 1_000_000, "Starting price should be reasonable");
}
#[test]
fn test_cp_price_increases_monotonically() {
let price_0 = calculate_price_cp(0, VIRTUAL_TOKENS_START, K);
let price_10pct = calculate_price_cp(TOKENS_ON_CURVE / 10, VIRTUAL_TOKENS_START, K);
let price_50pct = calculate_price_cp(TOKENS_ON_CURVE / 2, VIRTUAL_TOKENS_START, K);
let price_90pct = calculate_price_cp(TOKENS_ON_CURVE * 9 / 10, VIRTUAL_TOKENS_START, K);
assert!(price_0 < price_10pct, "Price should increase at 10%");
assert!(price_10pct < price_50pct, "Price should increase at 50%");
assert!(price_50pct < price_90pct, "Price should increase at 90%");
}
#[test]
fn test_cp_price_multiplier_at_graduation() {
// Note: After the 100k-supply migration, the legacy constants
// (VIRTUAL_TOKENS_START=107.3M, K) are scaled for 100M supply but
// TOKENS_ON_CURVE=79.31k. The legacy fallback path is no longer
// self-consistent and is unused in practice (no pre-v2 curves exist).
// We just check that grad_price >= start_price.
let start_price = calculate_price_cp(0, VIRTUAL_TOKENS_START, K);
let grad_price = calculate_price_cp(TOKENS_ON_CURVE, VIRTUAL_TOKENS_START, K);
assert!(
grad_price >= start_price,
"Grad price should be >= start price, got grad={} start={}",
grad_price, start_price
);
}
#[test]
fn test_cp_buy_basic() {
// Buy with 1 BWICK, 0.5% fee
let (tokens_out, fee) = calculate_buy_cp(0, 1_000_000, 50, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
// Fee should be 0.5% of 1_000_000 = 5000
assert_eq!(fee, 5000, "Fee should be 0.5%");
assert!(tokens_out > 0, "Should receive tokens");
// Verify constant product invariant holds approximately
let bwick_after_fee = 1_000_000u128 - 5000;
let virtual_bwick_before = K / VIRTUAL_TOKENS_START;
let new_virtual_bwick = virtual_bwick_before + bwick_after_fee;
let new_virtual_tokens = K / new_virtual_bwick;
let expected_tokens_out = VIRTUAL_TOKENS_START - new_virtual_tokens;
assert_eq!(tokens_out, expected_tokens_out, "Tokens out should match CP formula");
}
#[test]
fn test_cp_buy_fee_deducted_before_curve() {
let (tokens_out_with_fee, fee) = calculate_buy_cp(0, 10_000_000, 50, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
let (tokens_out_no_fee, _) = calculate_buy_cp(0, 10_000_000, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
assert!(
tokens_out_with_fee < tokens_out_no_fee,
"Fee should result in fewer tokens"
);
assert_eq!(fee, 10_000_000 * 50 / 10000, "Fee should be 0.5%");
}
#[test]
fn test_cp_buy_respects_curve_cap() {
// Try to buy with an enormous amount of BWICK
// tokens_out should be capped at TOKENS_ON_CURVE
let huge_bwick = 1_000_000_000_000_000u128; // 1 billion BWICK
let (tokens_out, _) = calculate_buy_cp(0, huge_bwick, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
assert!(
tokens_out <= TOKENS_ON_CURVE,
"Tokens out must not exceed curve cap: got {} > {}",
tokens_out,
TOKENS_ON_CURVE
);
}
#[test]
fn test_cp_buy_returns_zero_tokens_error() {
// When all curve tokens are sold, buying should fail with NoTokensAvailable
let result = calculate_buy_cp(TOKENS_ON_CURVE, 1_000_000, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE);
assert!(
result.is_err(),
"Buying when all curve tokens are sold should return error"
);
// When fee consumes entire input (bwick_after_fee = 0), should fail
// fee_bps=10000 means 100% fee, so bwick_after_fee = 0
let result = calculate_buy_cp(0, 100, 10000, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE);
assert!(
result.is_err(),
"Buying with 100% fee should return error"
);
}
#[test]
fn test_cp_sell_basic() {
// First buy some tokens (use 0% fee for simpler math)
let buy_bwick = 100_000_000_000u128; // 100,000 BWICK
let (tokens_bought, _) = calculate_buy_cp(0, buy_bwick, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
assert!(tokens_bought > 0, "Should buy some tokens");
// Sell half back with 3.5% fee
let half = tokens_bought / 2;
let (bwick_out, fee) = calculate_sell_cp(tokens_bought, half, 350, VIRTUAL_TOKENS_START, K).unwrap();
assert!(bwick_out > 0, "Should receive BWICK from sell");
assert!(fee > 0, "Should have a sell fee");
}
#[test]
fn test_cp_sell_returns_less_than_buy_paid() {
// Buy with 0% fee, sell with 0% fee -- should get same BWICK back (roundtrip)
let buy_bwick = 1_000_000_000u128; // 1000 BWICK
let (tokens_out, _) = calculate_buy_cp(0, buy_bwick, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
let (bwick_back, _) = calculate_sell_cp(tokens_out, tokens_out, 0, VIRTUAL_TOKENS_START, K).unwrap();
// Should be equal within 1 ubwick rounding
let diff = if bwick_back > buy_bwick {
bwick_back - buy_bwick
} else {
buy_bwick - bwick_back
};
assert!(
diff <= 1,
"Roundtrip should conserve BWICK within 1 ubwick, diff={}",
diff
);
}
#[test]
fn test_cp_sell_with_fee() {
// Buy some tokens first
let (tokens_bought, _) = calculate_buy_cp(0, 50_000_000_000u128, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
let sell_amount = tokens_bought / 4;
let (bwick_no_fee, _) = calculate_sell_cp(tokens_bought, sell_amount, 0, VIRTUAL_TOKENS_START, K).unwrap();
let (bwick_with_fee, fee) = calculate_sell_cp(tokens_bought, sell_amount, 350, VIRTUAL_TOKENS_START, K).unwrap();
assert!(
bwick_with_fee < bwick_no_fee,
"Fee should reduce BWICK output"
);
// bwick_with_fee + fee should approximately equal bwick_no_fee
let reconstructed = bwick_with_fee + fee;
let diff = if reconstructed > bwick_no_fee {
reconstructed - bwick_no_fee
} else {
bwick_no_fee - reconstructed
};
assert!(
diff <= 1,
"bwick_with_fee + fee should equal bwick_no_fee within rounding, diff={}",
diff
);
}
#[test]
fn test_cp_sell_more_than_sold_fails() {
let result = calculate_sell_cp(1000, 2000, 350, VIRTUAL_TOKENS_START, K);
assert!(result.is_err(), "Selling more than sold should fail");
}
#[test]
fn test_cp_sell_all_tokens() {
// Buy tokens, then sell ALL of them back
let (tokens_bought, _) = calculate_buy_cp(0, 10_000_000_000u128, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
let result = calculate_sell_cp(tokens_bought, tokens_bought, 350, VIRTUAL_TOKENS_START, K);
assert!(result.is_ok(), "Selling all tokens should succeed");
let (bwick_out, _) = result.unwrap();
assert!(bwick_out > 0, "Should receive BWICK when selling all");
}
#[test]
fn test_cp_buy_sell_roundtrip_conservation() {
// Buy with 1000 BWICK at 0% fee, sell back at 0% fee
let buy_bwick = 1_000_000_000u128; // 1000 BWICK
let (tokens_out, _) = calculate_buy_cp(0, buy_bwick, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
let (bwick_back, _) = calculate_sell_cp(tokens_out, tokens_out, 0, VIRTUAL_TOKENS_START, K).unwrap();
let diff = if bwick_back > buy_bwick {
bwick_back - buy_bwick
} else {
buy_bwick - bwick_back
};
assert!(
diff <= 1,
"Buy-sell roundtrip should conserve BWICK within 1 ubwick rounding, diff={}",
diff
);
}
#[test]
fn test_cp_graduated_bwick_raised() {
// Compute total BWICK raised when all TOKENS_ON_CURVE are sold
let virtual_tokens_at_grad = VIRTUAL_TOKENS_START - TOKENS_ON_CURVE;
let virtual_bwick_at_grad = K / virtual_tokens_at_grad;
let bwick_raised = virtual_bwick_at_grad - VIRTUAL_BWICK_START;
// Verify by buying all tokens with 0% fee
let (tokens_out, _) = calculate_buy_cp(0, bwick_raised, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
// tokens_out should be close to TOKENS_ON_CURVE (within integer division rounding)
let diff = if tokens_out > TOKENS_ON_CURVE {
tokens_out - TOKENS_ON_CURVE
} else {
TOKENS_ON_CURVE - tokens_out
};
assert!(
diff <= TOKENS_ON_CURVE / 1000, // within 0.1%
"Buying with bwick_raised should get ~TOKENS_ON_CURVE tokens, diff={}",
diff
);
// bwick_raised should be positive
// Note: After the 100k-supply migration, legacy constants and
// TOKENS_ON_CURVE are no longer self-consistent (the legacy fallback
// is unused in practice). We only assert positivity here.
assert!(bwick_raised > 0, "Should raise positive BWICK");
}
// ===========================================
// Integration Tests: Curve Cap Behavior
// ===========================================
#[test]
fn test_buy_near_curve_cap() {
// Almost all tokens sold, small buy should work and be capped
let tokens_sold = TOKENS_ON_CURVE - 1_000_000; // 1 token remaining
let (tokens_out, _) = calculate_buy_cp(tokens_sold, 100_000_000_000, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE).unwrap();
// Should be capped at the remaining 1_000_000
assert!(
tokens_out <= 1_000_000,
"Tokens out should be capped at remaining: got {} > 1_000_000",
tokens_out
);
}
#[test]
fn test_buy_at_curve_cap() {
// All curve tokens sold, any buy should fail
let result = calculate_buy_cp(TOKENS_ON_CURVE, 1_000_000, 0, VIRTUAL_TOKENS_START, K, TOKENS_ON_CURVE);
assert!(
result.is_err(),
"Buying when curve is at cap should fail with NoTokensAvailable"
);
}
// ===========================================
// Dynamic Graduation Threshold Tests
// ===========================================
#[test]
fn test_compute_dynamic_threshold_basic() {
// $0.0001/BWICK, $2K raised => 20M BWICK = 20_000_000_000_000 ubwick
// raw = 2_000_000_000 * 10^6 / 100 = 20_000_000_000_000
let threshold = compute_dynamic_threshold(
100, // $0.0001 in micro-USD
2_000_000_000, // $2K raised in micro-USD
100_000_000_000, // min: 100K BWICK
50_000_000_000_000, // max: 50M BWICK
5_000_000_000_000, // fallback: 5M BWICK
0, // no BWICK override; use USD path
);
assert_eq!(threshold, 20_000_000_000_000);
}
#[test]
fn test_compute_dynamic_threshold_low_price_clamps_to_max() {
// $0.00002/BWICK, $2K raised => raw = 100M BWICK, clamped to max 50M
let threshold = compute_dynamic_threshold(
20, // $0.00002
2_000_000_000, // $2K raised
100_000_000_000, // min: 100K BWICK
50_000_000_000_000, // max: 50M BWICK
5_000_000_000_000,
0,
);
assert_eq!(threshold, 50_000_000_000_000); // clamped to max
}
#[test]
fn test_compute_dynamic_threshold_high_price_clamps_to_min() {
// $1000/BWICK, $2K raised => raw = 2K BWICK = 2_000_000_000 ubwick, clamped to min 100K BWICK
let threshold = compute_dynamic_threshold(
1_000_000_000, // $1000
2_000_000_000, // $2K raised
100_000_000_000, // min: 100K BWICK
50_000_000_000_000, // max: 50M BWICK
5_000_000_000_000,
0,
);
assert_eq!(threshold, 100_000_000_000); // clamped to min
}
#[test]
fn test_compute_dynamic_threshold_zero_price_uses_fallback() {
let threshold = compute_dynamic_threshold(
0, // no price
2_000_000_000,
100_000_000_000,
50_000_000_000_000,
5_000_000_000_000, // fallback
0,
);
assert_eq!(threshold, 5_000_000_000_000); // uses fallback
}
#[test]
fn test_compute_dynamic_threshold_at_boundaries() {
// $20/BWICK, $2K raised => raw = 100K BWICK = exactly min
let threshold = compute_dynamic_threshold(
20_000_000, // $20
2_000_000_000, // $2K raised
100_000_000_000, // min: 100K BWICK
50_000_000_000_000,
5_000_000_000_000,
0,
);
assert_eq!(threshold, 100_000_000_000); // exactly at min
// $0.00004/BWICK, $2K raised => raw = 50B BWICK, clamped to max 50M
let threshold2 = compute_dynamic_threshold(
40, // $0.00004 = 40 micro-USD
2_000_000_000, // $2K raised
100_000_000_000,
50_000_000_000_000, // max: 50M BWICK
5_000_000_000_000,
0,
);
assert_eq!(threshold2, 50_000_000_000_000); // clamped to max
}
#[test]
fn test_presale_v7_fail_and_prorata() {
use crate::state::{PresaleConfig, PresaleState, PRESALES, PRESALE_CONTRIBUTIONS, PRESALE_CLAIMED};
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
let mut deps = mock_dependencies();
let token = Addr::unchecked("token1");
let buyer = Addr::unchecked("buyer1");
let cfg = PresaleConfig {
price_ubwick_per_utoken: 10,
hard_cap_ubwick: 1_000_000,
per_wallet_cap_ubwick: None,
start_time: 0,
end_time: 10,
merkle_root: [0u8; 32],
creator_allocation_bps: 0,
min_raise_ubwick: 500_000,
allow_oversubscription: true,
};
// FAILED presale: raised below min -> full refund on claim.
PRESALES
.save(deps.as_mut().storage, &token, &PresaleState {
config: cfg.clone(),
bwick_raised: 100_000,
tokens_sold: 0,
finalized: true,
failed: true,
escrowed: true,
creator_vesting_contract: None,
})
.unwrap();
PRESALE_CONTRIBUTIONS
.save(deps.as_mut().storage, (&token, &buyer), &100_000u128)
.unwrap();
let res = execute_claim_presale(
deps.as_mut(),
mock_env(),
mock_info("buyer1", &[]),
"token1".to_string(),
)
.unwrap();
let refund_attr = res.attributes.iter().find(|a| a.key == "refund_ubwick").unwrap();
assert_eq!(refund_attr.value, "100000", "failed presale refunds the full contribution");
// Double-claim rejected.
let err = execute_claim_presale(
deps.as_mut(),
mock_env(),
mock_info("buyer1", &[]),
"token1".to_string(),
)
.unwrap_err();
assert!(matches!(err, ContractError::PresaleAlreadyClaimed {}));
// OVERSUBSCRIBED success: raised 2x the cap -> half accepted, half refunded.
let token2 = Addr::unchecked("token2");
let buyer2 = Addr::unchecked("buyer2");
PRESALES
.save(deps.as_mut().storage, &token2, &PresaleState {
config: cfg.clone(),
bwick_raised: 2_000_000, // 2x hard cap
tokens_sold: 100_000,
finalized: true,
failed: false,
escrowed: true,
creator_vesting_contract: None,
})
.unwrap();
PRESALE_CONTRIBUTIONS
.save(deps.as_mut().storage, (&token2, &buyer2), &400_000u128)
.unwrap();
// claim needs a curve for the token transfer
let mut curve = test_curve();
curve.token_address = token2.clone();
CURVES.save(deps.as_mut().storage, &token2, &curve).unwrap();
let res = execute_claim_presale(
deps.as_mut(),
mock_env(),
mock_info("buyer2", &[]),
"token2".to_string(),
)
.unwrap();
let refund = res.attributes.iter().find(|a| a.key == "refund_ubwick").unwrap();
let tokens = res.attributes.iter().find(|a| a.key == "tokens_out").unwrap();
// accepted = 400K * 1M / 2M = 200K -> refund 200K, tokens 200K/10 = 20K utokens
assert_eq!(refund.value, "200000");
assert_eq!(tokens.value, "20000");
assert!(PRESALE_CLAIMED.load(deps.as_ref().storage, (&token2, &buyer2)).unwrap());
}
#[test]
fn test_compute_curve_params_bwick_override() {
// Oracle price 0 + override must work (oracle-free path).
let raised = 10_000_000_000_000u128; // 10M BWICK
let params = compute_curve_params(
0,
500_000_000, // starting MC $500 (ratio only)
69_000_000_000, // graduation MC $69K (ratio only)
4_000_000_000, // raised $4K (ratio only)
raised,
).unwrap();
// Constant product: price multiple at graduation must equal the
// configured MC ratio (138x) regardless of BWICK's USD price.
let p0 = params.virtual_bwick_start as f64 / params.virtual_tokens_start as f64;
let vb1 = params.virtual_bwick_start as f64 + raised as f64;
let vt1 = (params.virtual_bwick_start as f64 * params.virtual_tokens_start as f64) / vb1;
let p1 = vb1 / vt1;
let multiple = p1 / p0;
assert!(
(multiple - 138.0).abs() / 138.0 < 0.02,
"graduation multiple should be ~138x, got {multiple}"
);
// virtual BWICK = raised / (sqrt(138) - 1) ~= raised / 10.747
let expected_vb = raised as f64 / (138.0f64.sqrt() - 1.0);
assert!(
(params.virtual_bwick_start as f64 - expected_vb).abs() / expected_vb < 0.01,
"virtual_bwick_start should be ~{expected_vb}, got {}",
params.virtual_bwick_start
);
// Same ratios as the USD path: ~68.1% of supply on the curve.
assert!(
params.tokens_on_curve > 65_000_000_000 && params.tokens_on_curve < 71_000_000_000,
"tokens_on_curve should be ~68.1K tokens, got {}",
params.tokens_on_curve
);
}
#[test]
fn test_compute_dynamic_threshold_bwick_override() {
// target_raised_bwick set => oracle ignored entirely
let threshold = compute_dynamic_threshold(
2, // price irrelevant
4_000_000_000, // raised_usd irrelevant
100_000_000_000, // min: 100K BWICK
50_000_000_000_000, // max: 50M BWICK
5_000_000_000_000, // fallback irrelevant
500_000_000_000, // 500K BWICK direct target
);
assert_eq!(threshold, 500_000_000_000);
// Override above max => clamped
let clamped_high = compute_dynamic_threshold(
2, 4_000_000_000, 100_000_000_000, 50_000_000_000_000, 5_000_000_000_000,
999_000_000_000_000, // way above max
);
assert_eq!(clamped_high, 50_000_000_000_000);
// Override below min => clamped
let clamped_low = compute_dynamic_threshold(
2, 4_000_000_000, 100_000_000_000, 50_000_000_000_000, 5_000_000_000_000,
1_000_000, // way below min
);
assert_eq!(clamped_low, 100_000_000_000);
}
#[test]
fn test_effective_threshold_ratchet_increases() {
// Curve has stored threshold of 3M, computed is 5M => effective = 5M (increases)
let effective = effective_threshold_pure(
Some(3_000_000_000_000), // stored
5_000_000_000_000, // computed
5_000_000_000_000, // legacy_fallback (unused when Some)
);
assert_eq!(effective, 5_000_000_000_000);
}
#[test]
fn test_effective_threshold_ratchet_never_decreases() {
// Curve has stored threshold of 8M, computed is 5M => effective = 8M (ratchet)
let effective = effective_threshold_pure(
Some(8_000_000_000_000), // stored
5_000_000_000_000, // computed
5_000_000_000_000, // legacy_fallback (unused)
);
assert_eq!(effective, 8_000_000_000_000);
}
#[test]
fn test_effective_threshold_legacy_curve_no_stored() {
// Legacy curve (None), computed is 3M, global fallback is 5M => max(5M, 3M) = 5M
let effective = effective_threshold_pure(
None, // legacy
3_000_000_000_000, // computed
5_000_000_000_000, // legacy_fallback (old global threshold)
);
assert_eq!(effective, 5_000_000_000_000);
}
#[test]
fn test_effective_threshold_legacy_curve_computed_higher() {
// Legacy curve (None), computed is 8M, global fallback is 5M => max(5M, 8M) = 8M
let effective = effective_threshold_pure(
None,
8_000_000_000_000,
5_000_000_000_000,
);
assert_eq!(effective, 8_000_000_000_000);
}
// ===========================================
// Oracle Handler Integration Tests
// ===========================================
use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
use cosmwasm_std::Addr;
/// Helper: create a default Config for tests
fn test_config() -> Config {
Config {
presales_enabled: false,
amm_contract: Addr::unchecked("amm_contract"),
cw20_code_id: 1,
creation_fee: 80_000_000_000,
graduation_threshold: 5_000_000_000_000,
buy_fee_bps: 50,
sell_fee_bps: 350,
creator_fee_share_bps: 2000,
admin: Addr::unchecked("admin"),
target_graduation_usd: 10_000_000_000,
min_graduation_threshold: 100_000_000_000,
max_graduation_threshold: 50_000_000_000_000,
target_starting_mc_usd: 1_000_000_000,
target_raised_usd: 2_000_000_000,
target_raised_bwick: 0,
max_wallet_bps: 300,
fee_exempt_address: Addr::unchecked(""),
oracle_contract: Addr::unchecked(""),
name_cooldown_seconds: 0,
vesting_code_id: 0,
}
}
#[test]
fn test_oracle_handler_rejects_non_admin() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
let info = mock_info("not_admin", &[]);
let env = mock_env();
let result = execute_update_bwick_price(
deps.as_mut(), env, info,
Uint128::from(2_000_000u128),
);
assert!(result.is_err());
match result.unwrap_err() {
ContractError::Unauthorized {} => {},
e => panic!("Expected Unauthorized, got {:?}", e),
}
}
#[test]
fn test_oracle_handler_rejects_zero_price() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
let info = mock_info("admin", &[]);
let env = mock_env();
let result = execute_update_bwick_price(
deps.as_mut(), env, info,
Uint128::zero(),
);
assert!(result.is_err());
match result.unwrap_err() {
ContractError::ZeroPrice {} => {},
e => panic!("Expected ZeroPrice, got {:?}", e),
}
}
#[test]
fn test_oracle_handler_saves_price() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
let info = mock_info("admin", &[]);
let env = mock_env();
let result = execute_update_bwick_price(
deps.as_mut(), env.clone(), info,
Uint128::from(2_000_000u128),
);
assert!(result.is_ok());
let oracle = ORACLE_STATE.load(deps.as_ref().storage).unwrap();
assert_eq!(oracle.bwick_usd_price, 2_000_000);
assert_eq!(oracle.last_update_height, env.block.height);
}
#[test]
fn test_update_config_rejects_non_admin() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
let info = mock_info("not_admin", &[]);
let result = execute_update_config(
deps.as_mut(), info,
Some(Uint128::from(20_000_000_000u128)),
None, None, None, None, None, None, None, None, None, None, None,
);
assert!(result.is_err());
match result.unwrap_err() {
ContractError::Unauthorized {} => {},
e => panic!("Expected Unauthorized, got {:?}", e),
}
}
#[test]
fn test_update_config_validates_bounds() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
let info = mock_info("admin", &[]);
// Set min > max -- should fail
let result = execute_update_config(
deps.as_mut(), info,
None,
Some(Uint128::from(100_000_000_000_000u128)), // min = 100M BWICK
Some(Uint128::from(1_000_000_000u128)), // max = 1K BWICK
None, None, None, None, None, None, None, None, None,
);
assert!(result.is_err());
match result.unwrap_err() {
ContractError::InvalidThresholdBounds { .. } => {},
e => panic!("Expected InvalidThresholdBounds, got {:?}", e),
}
}
#[test]
fn test_update_config_updates_values() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
let info = mock_info("admin", &[]);
let result = execute_update_config(
deps.as_mut(), info,
Some(Uint128::from(20_000_000_000u128)), // $20K target
None, None, None, None, None, None, None, None, None, None, None,
);
assert!(result.is_ok());
let updated = CONFIG.load(deps.as_ref().storage).unwrap();
assert_eq!(updated.target_graduation_usd, 20_000_000_000);
// Other fields unchanged
assert_eq!(updated.min_graduation_threshold, 100_000_000_000);
}
/// Helper: create a test Curve for integration tests
fn test_curve() -> crate::state::Curve {
crate::state::Curve {
token_address: Addr::unchecked("token1"),
metadata: TokenMetadata {
name: "Test".to_string(),
symbol: "TST".to_string(),
image: "".to_string(),
description: "".to_string(),
social_links: vec![],
},
creator: Addr::unchecked("creator"),
tokens_sold: 0,
bwick_reserves: 0,
graduated: false,
created_at: 0,
creator_fees_earned: 0,
graduation_threshold_ubwick: None,
virtual_bwick_start: 0,
virtual_tokens_start: 0,
curve_k: 0,
tokens_on_curve: 0,
tokens_for_lp: 0,
lp_lock_seconds: None,
}
}
#[test]
fn test_load_effective_threshold_with_oracle() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
// Set oracle price to $2/BWICK
let oracle = OracleState {
bwick_usd_price: 2_000_000,
last_update_height: 100,
last_update_timestamp: 1000,
};
ORACLE_STATE.save(deps.as_mut().storage, &oracle).unwrap();
// New curve (None threshold)
// computed = target_raised_usd * 10^6 / price = 2e9 * 10^6 / 2e6 = 1e9 = 1_000_000_000_000
// effective = max(global_fallback=5M, computed=1M) = 5M
let curve = test_curve();
let threshold = load_effective_threshold(deps.as_ref(), &config, &curve);
assert_eq!(threshold, 5_000_000_000_000); // max(5M fallback, 1M computed) = 5M
}
#[test]
fn test_load_effective_threshold_ratchet_on_curve() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
// Set oracle price to $2/BWICK => computed = 1M BWICK
let oracle = OracleState {
bwick_usd_price: 2_000_000,
last_update_height: 100,
last_update_timestamp: 1000,
};
ORACLE_STATE.save(deps.as_mut().storage, &oracle).unwrap();
// Curve with stored threshold of 8M (previously ratcheted)
let mut curve = test_curve();
curve.graduation_threshold_ubwick = Some(8_000_000_000_000);
let threshold = load_effective_threshold(deps.as_ref(), &config, &curve);
assert_eq!(threshold, 8_000_000_000_000); // ratchet: max(8M, 1M) = 8M
}
#[test]
fn test_load_effective_threshold_no_oracle_uses_fallback() {
let mut deps = mock_dependencies();
let config = test_config();
CONFIG.save(deps.as_mut().storage, &config).unwrap();
// No ORACLE_STATE saved => price = 0 => fallback
let curve = test_curve();
let threshold = load_effective_threshold(deps.as_ref(), &config, &curve);
// No oracle => price=0 => fallback = config.graduation_threshold = 5M
// effective_threshold_pure(None, 5M, 5M) = max(5M, 5M) = 5M
assert_eq!(threshold, 5_000_000_000_000);
}
// ===========================================
// Integer Square Root Tests
// ===========================================
#[test]
fn test_isqrt_perfect_squares() {
assert_eq!(isqrt(0), 0);
assert_eq!(isqrt(1), 1);
assert_eq!(isqrt(4), 2);
assert_eq!(isqrt(9), 3);
assert_eq!(isqrt(100), 10);
assert_eq!(isqrt(1_000_000), 1_000);
assert_eq!(isqrt(1_000_000_000_000), 1_000_000);
}
#[test]
fn test_isqrt_non_perfect() {
// isqrt floors to nearest integer
assert_eq!(isqrt(2), 1);
assert_eq!(isqrt(3), 1);
assert_eq!(isqrt(5), 2);
assert_eq!(isqrt(10), 3);
}
// ===========================================
// compute_curve_params Tests
// ===========================================
#[test]
fn test_compute_curve_params_at_0001() {
// P = $0.0001 = 100 micro-USD
let params = compute_curve_params(
100, // bwick price = 100 micro-USD
1_000_000_000, // starting MC = $1K
10_000_000_000, // graduation MC = $10K
2_000_000_000, // raised = $2K
0, // no BWICK override
).unwrap();
// tokens_on_curve should be ~63.25k tokens = ~63.25e9 utokens
assert!(
params.tokens_on_curve > 60_000_000_000 && params.tokens_on_curve < 70_000_000_000,
"tokens_on_curve should be ~63.25k utokens, got {}",
params.tokens_on_curve
);
// tokens_for_lp = TOTAL_SUPPLY - tokens_on_curve
assert_eq!(params.tokens_on_curve + params.tokens_for_lp, TOTAL_SUPPLY);
// virtual_tokens should be ~92.5k utokens
assert!(
params.virtual_tokens_start > 85_000_000_000 && params.virtual_tokens_start < 100_000_000_000,
"virtual_tokens_start should be ~92.5k utokens, got {}",
params.virtual_tokens_start
);
// virtual_bwick: at P=$0.0001, should be ~9.25M BWICK = ~9.25e12 ubwick
// (virtual_bwick scales with raised_usd / price, NOT with total supply)
assert!(
params.virtual_bwick_start > 8_000_000_000_000 && params.virtual_bwick_start < 11_000_000_000_000,
"virtual_bwick_start should be ~9.25M ubwick, got {}",
params.virtual_bwick_start
);
// K = vx * vt
assert_eq!(params.curve_k, params.virtual_bwick_start * params.virtual_tokens_start);
}
#[test]
fn test_compute_curve_params_at_00005() {
// P = $0.00005 = 50 micro-USD
let params = compute_curve_params(
50, // bwick price = 50 micro-USD
1_000_000_000, // starting MC = $1K
10_000_000_000, // graduation MC = $10K
2_000_000_000, // raised = $2K
0, // no BWICK override
).unwrap();
// tokens_on_curve stays the same (~63.25k) regardless of price
assert!(
params.tokens_on_curve > 60_000_000_000 && params.tokens_on_curve < 70_000_000_000,
"tokens_on_curve should be ~63.25k utokens at any price, got {}",
params.tokens_on_curve
);
// virtual_bwick doubles when price halves (~18.5M BWICK)
// (virtual_bwick scales with raised_usd / price, NOT with total supply)
assert!(
params.virtual_bwick_start > 16_000_000_000_000 && params.virtual_bwick_start < 22_000_000_000_000,
"virtual_bwick_start should be ~18.5M ubwick, got {}",
params.virtual_bwick_start
);
}
#[test]
fn test_compute_curve_params_rejects_zero_price() {
let result = compute_curve_params(0, 1_000_000_000, 10_000_000_000, 2_000_000_000, 0);
assert!(result.is_err());
match result.unwrap_err() {
ContractError::OraclePriceRequired {} => {},
e => panic!("Expected OraclePriceRequired, got {:?}", e),
}
}
#[test]
fn test_compute_curve_params_rejects_invalid_mc_ratio() {
// graduation MC <= starting MC
let result = compute_curve_params(100, 10_000_000_000, 1_000_000_000, 2_000_000_000, 0);
assert!(result.is_err());
}
#[test]
fn test_compute_curve_params_legacy_fallback() {
// Legacy curve (curve_k = 0) should use old hardcoded constants
let curve = test_curve();
let (vx, vt, k, toc, tlp) = get_curve_constants(&curve);
assert_eq!(vx, VIRTUAL_BWICK_START);
assert_eq!(vt, VIRTUAL_TOKENS_START);
assert_eq!(k, K);
assert_eq!(toc, TOKENS_ON_CURVE);
assert_eq!(tlp, TOKENS_FOR_LP);
}
#[test]
fn test_compute_curve_params_per_curve_used() {
// Curve with per-curve params should use them
let mut curve = test_curve();
curve.virtual_bwick_start = 100;
curve.virtual_tokens_start = 200;
curve.curve_k = 20000;
curve.tokens_on_curve = 300;
curve.tokens_for_lp = 400;
let (vx, vt, k, toc, tlp) = get_curve_constants(&curve);
assert_eq!(vx, 100);
assert_eq!(vt, 200);
assert_eq!(k, 20000);
assert_eq!(toc, 300);
assert_eq!(tlp, 400);
}
#[test]
fn test_compute_curve_params_buy_sell_roundtrip() {
// Verify buy/sell roundtrip with dynamically computed params
let params = compute_curve_params(
100, // $0.0001
1_000_000_000, // $1K starting MC
10_000_000_000, // $10K graduation MC
2_000_000_000, // $2K raised
0, // no BWICK override
).unwrap();
let buy_bwick = 1_000_000_000u128; // 1000 BWICK
let (tokens_out, _) = calculate_buy_cp(
0, buy_bwick, 0,
params.virtual_tokens_start, params.curve_k, params.tokens_on_curve,
).unwrap();
let (bwick_back, _) = calculate_sell_cp(
tokens_out, tokens_out, 0,
params.virtual_tokens_start, params.curve_k,
).unwrap();
let diff = if bwick_back > buy_bwick { bwick_back - buy_bwick } else { buy_bwick - bwick_back };
// Small integer rounding tolerance (a few units of ubwick)
assert!(
diff <= 10,
"Roundtrip with dynamic params should conserve BWICK, diff={}",
diff
);
}
#[test]
fn test_compute_curve_params_price_multiplier() {
// At graduation, price should be ~R times starting price
let params = compute_curve_params(
100, // $0.0001
1_000_000_000, // $1K starting MC
10_000_000_000, // $10K graduation MC (R=10)
2_000_000_000, // $2K raised
0, // no BWICK override
).unwrap();
let start_price = calculate_price_cp(0, params.virtual_tokens_start, params.curve_k);
let grad_price = calculate_price_cp(
params.tokens_on_curve,
params.virtual_tokens_start,
params.curve_k,
);
let multiplier = grad_price / start_price;
// Should be approximately 10x (R=10)
assert!(
multiplier >= 9 && multiplier <= 11,
"Price multiplier should be ~10x (R=10), got {}x",
multiplier
);
}
}

