diff --git a/FRONTEND_BUILD_FIX.md b/FRONTEND_BUILD_FIX.md deleted file mode 100644 index 3718056..0000000 --- a/FRONTEND_BUILD_FIX.md +++ /dev/null @@ -1,86 +0,0 @@ -# Frontend Build Fix - -## Issue -The frontend Docker build was failing in CI with a generic npm error: -``` -npm error Run "npm help ci" for more info -``` - -## Root Cause -The real issue (discovered via local Docker build) was: -``` -npm error Missing: @solana/kit@5.5.1 from lock file -npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync -``` - -The package-lock.json had **peer dependency conflicts** that `npm ci` couldn't resolve. The Injective Labs wallet dependencies have complex peer dependency chains that required `--legacy-peer-deps` to install. - -## Solution - -### 1. Added `.npmrc` for CI Stability -Created [frontend/.npmrc](frontend/.npmrc) with: -```ini -# CI stability settings -prefer-offline=true -audit=false -fund=false -progress=false -loglevel=error - -# Ensure npm uses the lockfile strictly -package-lock=true -``` - -### 2. Updated Dockerfile -Changed from `npm ci` (clean install) to `npm install --legacy-peer-deps`: - -**Before:** -```dockerfile -RUN npm ci -``` - -**After:** -```dockerfile -RUN npm install --legacy-peer-deps --no-audit --no-fund -``` - -### 3. Regenerated package-lock.json -Regenerated the lock file with peer dependency resolution: -```bash -npm install --legacy-peer-deps -``` - -## Why npm install Instead of npm ci? - -`npm ci` is stricter and faster for CI, but it requires: -- Perfect sync between package.json and package-lock.json -- All peer dependencies must be resolvable without conflicts - -`npm install` with `--legacy-peer-deps`: -- Handles complex peer dependency chains -- Still uses the lock file when available -- Slightly slower but more tolerant of dependency conflicts - -## Verification - -After fixes, the build should succeed: -```bash -cd frontend -docker build -t frontend-test . -``` - -## Dependencies Context - -The Injective Labs SDK has many wallet adapters with overlapping peer dependencies: -- `@injectivelabs/wallet-*` packages (12 different wallet adapters) -- Each has dependencies on various blockchain SDKs (Ethereum, Cosmos, Solana, etc.) -- These create a complex peer dependency graph that requires `--legacy-peer-deps` - -This is a known issue with blockchain wallet integrations and is acceptable for this use case. - -## Next Steps - -1. ✅ Frontend builds successfully in Docker -2. ✅ CI pipeline should now succeed -3. ⚠️ Consider consolidating wallet adapters if only specific wallets are needed -4. ⚠️ Monitor for security vulnerabilities (44 found, mostly in wallet dependencies) diff --git a/chance-staking/contracts/staking-hub/src/contract.rs b/chance-staking/contracts/staking-hub/src/contract.rs index 19fb0bc..302bf71 100644 --- a/chance-staking/contracts/staking-hub/src/contract.rs +++ b/chance-staking/contracts/staking-hub/src/contract.rs @@ -155,11 +155,19 @@ pub fn execute( execute::update_validators(deps, env, info, add, remove) } ExecuteMsg::SyncDelegations {} => execute::sync_delegations(deps, env, info), + ExecuteMsg::RedelegateStake { + src_validator, + dst_validator, + amount, + } => execute::redelegate_stake(deps, env, info, src_validator, dst_validator, amount), + ExecuteMsg::RebalanceStake { validator_weights } => { + execute::rebalance_stake(deps, env, info, validator_weights) + } } } #[entry_point] -pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult { match msg { QueryMsg::Config {} => query::query_config(deps), QueryMsg::EpochState {} => query::query_epoch_state(deps), @@ -170,6 +178,7 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { limit, } => query::query_unstake_requests(deps, address, start_after, limit), QueryMsg::StakerInfo { address } => query::query_staker_info(deps, address), + QueryMsg::ValidatorDelegations {} => query::query_validator_delegations(deps, env), } } @@ -902,4 +911,398 @@ mod tests { let info = message_info(&user1, &coins(5_000_000, "inj")); execute(deps.as_mut(), mock_env(), info, ExecuteMsg::Stake {}).unwrap(); } + + #[test] + fn test_redelegate_stake() { + use cosmwasm_std::FullDelegation; + + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let env = mock_env(); + + // Mock delegation of 1000 INJ to first validator + let delegation = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + Coin::new(1_000_000u128, "inj"), + Coin::new(1_000_000u128, "inj"), + vec![], + ); + deps.querier.staking.update("inj", &[], &[delegation]); + + let operator = deps.api.addr_make("operator"); + let info = message_info(&operator, &[]); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::RedelegateStake { + src_validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + dst_validator: "injvaloper1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + amount: Uint128::new(500_000), + }, + ) + .unwrap(); + + // Should have 1 redelegate message + assert_eq!(res.messages.len(), 1); + assert!(res.events.iter().any(|e| e.ty == "chance_redelegate_stake")); + } + + #[test] + fn test_redelegate_stake_unauthorized() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let random = deps.api.addr_make("random"); + let info = message_info(&random, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::RedelegateStake { + src_validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + dst_validator: "injvaloper1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + amount: Uint128::new(500_000), + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + #[test] + fn test_redelegate_stake_validator_not_in_set() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let operator = deps.api.addr_make("operator"); + let info = message_info(&operator, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::RedelegateStake { + src_validator: "injvaloper1unknownvalidatoraddressxxxxxxxxxx".to_string(), + dst_validator: "injvaloper1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + amount: Uint128::new(500_000), + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::ValidatorNotInSet { .. })); + } + + #[test] + fn test_redelegate_stake_same_validator() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let operator = deps.api.addr_make("operator"); + let info = message_info(&operator, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::RedelegateStake { + src_validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + dst_validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + amount: Uint128::new(500_000), + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::SameValidator)); + } + + #[test] + fn test_redelegate_stake_exceeds_delegation() { + use cosmwasm_std::FullDelegation; + + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let env = mock_env(); + + // Mock delegation of only 100 INJ + let delegation = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + Coin::new(100u128, "inj"), + Coin::new(100u128, "inj"), + vec![], + ); + deps.querier.staking.update("inj", &[], &[delegation]); + + let operator = deps.api.addr_make("operator"); + let info = message_info(&operator, &[]); + let err = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::RedelegateStake { + src_validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + dst_validator: "injvaloper1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + amount: Uint128::new(500_000), + }, + ) + .unwrap_err(); + assert!(matches!( + err, + ContractError::RedelegationExceedsDelegation { .. } + )); + } + + #[test] + fn test_rebalance_stake() { + use crate::msg::ValidatorWeight; + use cosmwasm_std::FullDelegation; + + let mut deps = mock_dependencies(); + + // Set up with 3 validators + let mut msg = default_instantiate_msg(); + msg.validators = vec![ + "injvaloper1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + "injvaloper1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + "injvaloper1ccccccccccccccccccccccccccccccccccccc".to_string(), + ]; + let admin = deps.api.addr_make("admin"); + let info = message_info(&admin, &[]); + instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + + let env = mock_env(); + + // Mock uneven delegations: 600, 300, 100 = 1000 total + let del_a = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + Coin::new(600u128, "inj"), + Coin::new(600u128, "inj"), + vec![], + ); + let del_b = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + Coin::new(300u128, "inj"), + Coin::new(300u128, "inj"), + vec![], + ); + let del_c = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1ccccccccccccccccccccccccccccccccccccc".to_string(), + Coin::new(100u128, "inj"), + Coin::new(100u128, "inj"), + vec![], + ); + deps.querier + .staking + .update("inj", &[], &[del_a, del_b, del_c]); + + let operator = deps.api.addr_make("operator"); + let info = message_info(&operator, &[]); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::RebalanceStake { + validator_weights: vec![ + ValidatorWeight { + validator: "injvaloper1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + weight: 1, + }, + ValidatorWeight { + validator: "injvaloper1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + weight: 1, + }, + ValidatorWeight { + validator: "injvaloper1ccccccccccccccccccccccccccccccccccccc".to_string(), + weight: 2, + }, + ], + }, + ) + .unwrap(); + + // Targets: A=250, B=250, C=500 + // A sends 350 (over by 350), B sends 50 (over by 50), C receives 400 (under by 400) + // Expected: A->C: 350, B->C: 50 + assert_eq!(res.messages.len(), 2); + assert!(res.events.iter().any(|e| e.ty == "chance_rebalance_stake")); + } + + #[test] + fn test_rebalance_stake_unauthorized() { + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let random = deps.api.addr_make("random"); + let info = message_info(&random, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::RebalanceStake { + validator_weights: vec![], + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::Unauthorized { .. })); + } + + #[test] + fn test_rebalance_stake_invalid_weights() { + use crate::msg::ValidatorWeight; + + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let operator = deps.api.addr_make("operator"); + + // Wrong number of weights (2 validators in config, providing 1) + let info = message_info(&operator, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::RebalanceStake { + validator_weights: vec![ValidatorWeight { + validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + weight: 1, + }], + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::InvalidValidatorWeights { .. })); + + // Zero weight + let info = message_info(&operator, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::RebalanceStake { + validator_weights: vec![ + ValidatorWeight { + validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + weight: 0, + }, + ValidatorWeight { + validator: "injvaloper1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + weight: 1, + }, + ], + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::InvalidValidatorWeights { .. })); + + // Duplicate validator + let info = message_info(&operator, &[]); + let err = execute( + deps.as_mut(), + mock_env(), + info, + ExecuteMsg::RebalanceStake { + validator_weights: vec![ + ValidatorWeight { + validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + weight: 1, + }, + ValidatorWeight { + validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + weight: 1, + }, + ], + }, + ) + .unwrap_err(); + assert!(matches!(err, ContractError::InvalidValidatorWeights { .. })); + } + + #[test] + fn test_rebalance_stake_already_balanced() { + use crate::msg::ValidatorWeight; + use cosmwasm_std::FullDelegation; + + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let env = mock_env(); + + // Mock equal delegations: 500, 500 + let del_a = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + Coin::new(500u128, "inj"), + Coin::new(500u128, "inj"), + vec![], + ); + let del_b = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + Coin::new(500u128, "inj"), + Coin::new(500u128, "inj"), + vec![], + ); + deps.querier.staking.update("inj", &[], &[del_a, del_b]); + + let operator = deps.api.addr_make("operator"); + let info = message_info(&operator, &[]); + let res = execute( + deps.as_mut(), + env, + info, + ExecuteMsg::RebalanceStake { + validator_weights: vec![ + ValidatorWeight { + validator: "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + weight: 1, + }, + ValidatorWeight { + validator: "injvaloper1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + weight: 1, + }, + ], + }, + ) + .unwrap(); + + // Already balanced — no redelegation messages + assert_eq!(res.messages.len(), 0); + } + + #[test] + fn test_query_validator_delegations() { + use crate::msg::ValidatorDelegationsResponse; + use cosmwasm_std::{from_json, FullDelegation}; + + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let env = mock_env(); + + // Mock delegations + let del_a = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqj9".to_string(), + Coin::new(700_000u128, "inj"), + Coin::new(700_000u128, "inj"), + vec![], + ); + let del_b = FullDelegation::create( + env.contract.address.clone(), + "injvaloper1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + Coin::new(300_000u128, "inj"), + Coin::new(300_000u128, "inj"), + vec![], + ); + deps.querier.staking.update("inj", &[], &[del_a, del_b]); + + let res = query(deps.as_ref(), env, QueryMsg::ValidatorDelegations {}).unwrap(); + let delegations: ValidatorDelegationsResponse = from_json(res).unwrap(); + + assert_eq!(delegations.delegations.len(), 2); + assert_eq!(delegations.delegations[0].amount, Uint128::new(700_000)); + assert_eq!(delegations.delegations[1].amount, Uint128::new(300_000)); + assert_eq!(delegations.total_delegated, Uint128::new(1_000_000)); + } } diff --git a/chance-staking/contracts/staking-hub/src/error.rs b/chance-staking/contracts/staking-hub/src/error.rs index eff878c..c3f4252 100644 --- a/chance-staking/contracts/staking-hub/src/error.rs +++ b/chance-staking/contracts/staking-hub/src/error.rs @@ -71,4 +71,20 @@ pub enum ContractError { // M-04 FIX: Invalid validator address error #[error("invalid validator address {address}: {reason}")] InvalidValidatorAddress { address: String, reason: String }, + + #[error("validator {validator} is not in the active validator set")] + ValidatorNotInSet { validator: String }, + + #[error("source and destination validators must be different")] + SameValidator, + + #[error("redelegation amount {amount} exceeds delegation to {validator} ({delegated})")] + RedelegationExceedsDelegation { + amount: Uint128, + validator: String, + delegated: Uint128, + }, + + #[error("invalid validator weights: {reason}")] + InvalidValidatorWeights { reason: String }, } diff --git a/chance-staking/contracts/staking-hub/src/execute.rs b/chance-staking/contracts/staking-hub/src/execute.rs index 7d9a262..4b3481b 100644 --- a/chance-staking/contracts/staking-hub/src/execute.rs +++ b/chance-staking/contracts/staking-hub/src/execute.rs @@ -7,7 +7,7 @@ use injective_cosmwasm::{ }; use crate::error::ContractError; -use crate::msg::DistributorExecuteMsg; +use crate::msg::{DistributorExecuteMsg, ValidatorWeight}; use crate::state::{ UnstakeRequest, CONFIG, EPOCH_STATE, EXCHANGE_RATE, NEXT_UNSTAKE_ID, PENDING_UNSTAKE_TOTAL, TOTAL_CSINJ_SUPPLY, TOTAL_INJ_BACKING, UNSTAKE_REQUESTS, USER_STAKE_EPOCH, @@ -752,6 +752,227 @@ pub fn sync_delegations( )) } +/// Redelegate a specific amount of INJ from one validator to another. +/// Both validators must be in the active validator set. Operator only. +pub fn redelegate_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, + src_validator: String, + dst_validator: String, + amount: Uint128, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + if info.sender != config.operator { + return Err(ContractError::Unauthorized { + reason: "only operator can redelegate stake".to_string(), + }); + } + + if !config.validators.contains(&src_validator) { + return Err(ContractError::ValidatorNotInSet { + validator: src_validator, + }); + } + if !config.validators.contains(&dst_validator) { + return Err(ContractError::ValidatorNotInSet { + validator: dst_validator, + }); + } + + if src_validator == dst_validator { + return Err(ContractError::SameValidator); + } + + if amount.is_zero() { + return Err(ContractError::NoFundsSent); + } + + let delegation = deps + .querier + .query_delegation(&env.contract.address, &src_validator)?; + let delegated = delegation + .map(|d| d.amount.amount) + .unwrap_or(Uint128::zero()); + + if amount > delegated { + return Err(ContractError::RedelegationExceedsDelegation { + amount, + validator: src_validator.clone(), + delegated, + }); + } + + let redelegate_msg = CosmosMsg::Staking(StakingMsg::Redelegate { + src_validator: src_validator.clone(), + dst_validator: dst_validator.clone(), + amount: Coin { + denom: "inj".to_string(), + amount, + }, + }); + + Ok(ContractResponse::new() + .add_message(redelegate_msg) + .add_attribute("action", "redelegate_stake") + .add_event( + Event::new("chance_redelegate_stake") + .add_attribute("src_validator", src_validator) + .add_attribute("dst_validator", dst_validator) + .add_attribute("amount", amount.to_string()), + )) +} + +/// Rebalance delegations across validators to match target weights. +/// Queries actual on-chain delegations, computes diffs vs target distribution, +/// and emits redelegation messages. Operator only. +pub fn rebalance_stake( + deps: DepsMut, + env: Env, + info: MessageInfo, + validator_weights: Vec, +) -> Result { + let config = CONFIG.load(deps.storage)?; + + if info.sender != config.operator { + return Err(ContractError::Unauthorized { + reason: "only operator can rebalance stake".to_string(), + }); + } + + if validator_weights.len() != config.validators.len() { + return Err(ContractError::InvalidValidatorWeights { + reason: format!( + "expected {} weights (one per validator), got {}", + config.validators.len(), + validator_weights.len() + ), + }); + } + + let mut weight_map: Vec<(String, u64)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut total_weight: u128 = 0; + + for vw in &validator_weights { + if !config.validators.contains(&vw.validator) { + return Err(ContractError::ValidatorNotInSet { + validator: vw.validator.clone(), + }); + } + if !seen.insert(vw.validator.clone()) { + return Err(ContractError::InvalidValidatorWeights { + reason: format!("duplicate validator: {}", vw.validator), + }); + } + if vw.weight == 0 { + return Err(ContractError::InvalidValidatorWeights { + reason: format!("weight for {} must be > 0", vw.validator), + }); + } + total_weight += vw.weight as u128; + weight_map.push((vw.validator.clone(), vw.weight)); + } + + // Query actual on-chain delegations + let mut actual: Vec<(String, Uint128)> = Vec::new(); + let mut total_delegated = Uint128::zero(); + for (validator, _) in &weight_map { + let delegation = deps + .querier + .query_delegation(&env.contract.address, validator)?; + let amount = delegation + .map(|d| d.amount.amount) + .unwrap_or(Uint128::zero()); + actual.push((validator.clone(), amount)); + total_delegated += amount; + } + + if total_delegated.is_zero() { + return Ok(ContractResponse::new() + .add_attribute("action", "rebalance_stake") + .add_attribute("result", "nothing_to_rebalance")); + } + + // Compute target amounts with remainder to last validator + let mut targets: Vec<(String, Uint128)> = Vec::new(); + let mut assigned = Uint128::zero(); + for (i, (validator, weight)) in weight_map.iter().enumerate() { + let target = if i == weight_map.len() - 1 { + total_delegated - assigned + } else { + let t = total_delegated.multiply_ratio(*weight as u128, total_weight); + assigned += t; + t + }; + targets.push((validator.clone(), target)); + } + + // Separate into senders (over-delegated) and receivers (under-delegated) + let mut senders: Vec<(String, Uint128)> = Vec::new(); + let mut receivers: Vec<(String, Uint128)> = Vec::new(); + + for (i, (validator, target)) in targets.iter().enumerate() { + let current = actual[i].1; + if current > *target { + senders.push((validator.clone(), current - *target)); + } else if current < *target { + receivers.push((validator.clone(), *target - current)); + } + } + + // Greedy matching: redelegate from senders to receivers + let mut msgs: Vec> = Vec::new(); + let mut r_idx = 0; + let mut r_remaining = receivers + .first() + .map(|(_, a)| *a) + .unwrap_or(Uint128::zero()); + + for (src_val, surplus) in &senders { + let mut s_remaining = *surplus; + while !s_remaining.is_zero() && r_idx < receivers.len() { + let transfer = std::cmp::min(s_remaining, r_remaining); + if !transfer.is_zero() { + msgs.push(CosmosMsg::Staking(StakingMsg::Redelegate { + src_validator: src_val.clone(), + dst_validator: receivers[r_idx].0.clone(), + amount: Coin { + denom: "inj".to_string(), + amount: transfer, + }, + })); + } + s_remaining -= transfer; + r_remaining -= transfer; + if r_remaining.is_zero() { + r_idx += 1; + if r_idx < receivers.len() { + r_remaining = receivers[r_idx].1; + } + } + } + } + + let mut response = ContractResponse::new() + .add_attribute("action", "rebalance_stake") + .add_attribute("total_delegated", total_delegated.to_string()) + .add_attribute("num_redelegations", msgs.len().to_string()); + + for msg in msgs { + response = response.add_message(msg); + } + + response = response.add_event( + Event::new("chance_rebalance_stake") + .add_attribute("total_delegated", total_delegated.to_string()) + .add_attribute("total_weight", total_weight.to_string()), + ); + + Ok(response) +} + /// M-04 FIX: Helper to validate validator addresses pub fn validate_validator_address(addr: &str) -> Result<(), ContractError> { if !addr.starts_with("injvaloper") { diff --git a/chance-staking/contracts/staking-hub/src/msg.rs b/chance-staking/contracts/staking-hub/src/msg.rs index e4abe2a..b028701 100644 --- a/chance-staking/contracts/staking-hub/src/msg.rs +++ b/chance-staking/contracts/staking-hub/src/msg.rs @@ -70,6 +70,18 @@ pub enum ExecuteMsg { /// Sync TOTAL_INJ_BACKING with actual validator delegations. /// Call after slashing events. Operator only. SyncDelegations {}, + /// Redelegate a specific amount of INJ from one validator to another. + /// Both validators must be in the active validator set. Operator only. + RedelegateStake { + src_validator: String, + dst_validator: String, + amount: Uint128, + }, + /// Rebalance delegations across validators to match target weights. + /// Weights are relative (e.g. [1, 1, 2] = 25%/25%/50%). Operator only. + RebalanceStake { + validator_weights: Vec, + }, } /// Message sent to reward distributor to fund pools. @@ -102,6 +114,8 @@ pub enum QueryMsg { }, #[returns(StakerInfoResponse)] StakerInfo { address: String }, + #[returns(ValidatorDelegationsResponse)] + ValidatorDelegations {}, } #[cw_serde] @@ -123,3 +137,21 @@ pub struct StakerInfoResponse { /// The epoch of this user's most recent stake, or None if they have never staked. pub stake_epoch: Option, } + +#[cw_serde] +pub struct ValidatorWeight { + pub validator: String, + pub weight: u64, +} + +#[cw_serde] +pub struct ValidatorDelegation { + pub validator: String, + pub amount: Uint128, +} + +#[cw_serde] +pub struct ValidatorDelegationsResponse { + pub delegations: Vec, + pub total_delegated: Uint128, +} diff --git a/chance-staking/contracts/staking-hub/src/query.rs b/chance-staking/contracts/staking-hub/src/query.rs index b2d54a1..0d82703 100644 --- a/chance-staking/contracts/staking-hub/src/query.rs +++ b/chance-staking/contracts/staking-hub/src/query.rs @@ -1,7 +1,10 @@ -use cosmwasm_std::{to_json_binary, Binary, Deps, Order, StdResult}; +use cosmwasm_std::{to_json_binary, Binary, Deps, Env, Order, StdResult, Uint128}; use cw_storage_plus::Bound; -use crate::msg::{ExchangeRateResponse, StakerInfoResponse, UnstakeRequestEntry}; +use crate::msg::{ + ExchangeRateResponse, StakerInfoResponse, UnstakeRequestEntry, ValidatorDelegation, + ValidatorDelegationsResponse, +}; use crate::state::{ CONFIG, EPOCH_STATE, EXCHANGE_RATE, TOTAL_CSINJ_SUPPLY, TOTAL_INJ_BACKING, UNSTAKE_REQUESTS, USER_STAKE_EPOCH, @@ -58,3 +61,28 @@ pub fn query_staker_info(deps: Deps, address: String) -> StdResult { stake_epoch, }) } + +pub fn query_validator_delegations(deps: Deps, env: Env) -> StdResult { + let config = CONFIG.load(deps.storage)?; + let mut delegations = Vec::new(); + let mut total_delegated = Uint128::zero(); + + for validator in &config.validators { + let delegation = deps + .querier + .query_delegation(&env.contract.address, validator)?; + let amount = delegation + .map(|d| d.amount.amount) + .unwrap_or(Uint128::zero()); + delegations.push(ValidatorDelegation { + validator: validator.clone(), + amount, + }); + total_delegated += amount; + } + + to_json_binary(&ValidatorDelegationsResponse { + delegations, + total_delegated, + }) +} diff --git a/frontend/src/components/ActivityTicker.tsx b/frontend/src/components/ActivityTicker.tsx index 061b6df..208d9ce 100644 --- a/frontend/src/components/ActivityTicker.tsx +++ b/frontend/src/components/ActivityTicker.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react' import { Trophy } from 'lucide-react' import { useStore } from '../store/useStore' import { formatInj } from '../utils/formatNumber' +import { colors } from '../theme' function truncateAddr(addr: string): string { if (!addr) return '' @@ -46,9 +47,9 @@ export default function ActivityTicker() { ...styles.icon, background: isBig ? 'rgba(244, 114, 182, 0.15)' - : 'rgba(139, 111, 255, 0.15)', + : colors.primaryAlpha(0.15), }}> - + {truncateAddr(draw.winner!)} won diff --git a/frontend/src/components/Confetti.tsx b/frontend/src/components/Confetti.tsx index 0b801e4..360efa1 100644 --- a/frontend/src/components/Confetti.tsx +++ b/frontend/src/components/Confetti.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useCallback } from 'react' import { useStore } from '../store/useStore' +import { colors } from '../theme' interface Particle { x: number @@ -15,7 +16,7 @@ interface Particle { } const COLORS = [ - '#8B6FFF', '#A78BFF', '#6B4FD6', // purple + colors.primary, colors.primaryLight, colors.primaryDark, // gold '#38bdf8', '#0ea5e9', // cyan '#f472b6', '#ec4899', // pink '#f59e0b', '#fbbf24', // gold diff --git a/frontend/src/components/DrawDetail.tsx b/frontend/src/components/DrawDetail.tsx index 317bf93..f0385c7 100644 --- a/frontend/src/components/DrawDetail.tsx +++ b/frontend/src/components/DrawDetail.tsx @@ -4,6 +4,7 @@ import { Hash, Dice1, Trophy, Clock, Loader, } from 'lucide-react' import { useStore } from '../store/useStore' +import { colors } from '../theme' import * as contractsService from '../services/contracts' import type { Draw } from '../services/contracts' import { formatInj } from '../utils/formatNumber' @@ -75,7 +76,7 @@ export default function DrawDetail({ drawId }: { drawId: number }) { {loading && (
- + Loading draw #{drawId}...
)} @@ -93,8 +94,8 @@ export default function DrawDetail({ drawId }: { drawId: number }) { ...styles.typeBadge, background: draw.draw_type === 'big' ? 'rgba(244, 114, 182, 0.12)' - : 'rgba(158, 127, 255, 0.12)', - color: draw.draw_type === 'big' ? '#f472b6' : '#8B6FFF', + : colors.primaryAlpha(0.12), + color: draw.draw_type === 'big' ? '#f472b6' : colors.primary, }}> {draw.draw_type === 'big' ? 'Big Jackpot' : 'Regular Draw'} @@ -139,7 +140,7 @@ export default function DrawDetail({ drawId }: { drawId: number }) { {/* Verification Section */}
- +

Randomness Verification

@@ -436,8 +437,8 @@ const styles: Record = { width: 28, height: 28, borderRadius: '50%', - background: 'rgba(158, 127, 255, 0.12)', - color: '#8B6FFF', + background: colors.primaryAlpha(0.12), + color: colors.primary, fontSize: 13, fontWeight: 700, display: 'flex', @@ -476,7 +477,7 @@ const styles: Record = { hexValue: { fontSize: 13, fontFamily: "'JetBrains Mono', monospace", - color: '#8B6FFF', + color: colors.primary, flex: 1, wordBreak: 'break-all' as const, }, diff --git a/frontend/src/components/DrawsSection.tsx b/frontend/src/components/DrawsSection.tsx index cfcec36..e800e1b 100644 --- a/frontend/src/components/DrawsSection.tsx +++ b/frontend/src/components/DrawsSection.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react' import { Trophy, Clock, ChevronRight, Gift, Users, Coins, Radio } from 'lucide-react' import { useStore } from '../store/useStore' import { formatInj } from '../utils/formatNumber' +import { colors } from '../theme' function truncateAddr(addr: string): string { if (!addr) return '' @@ -63,8 +64,8 @@ export default function DrawsSection({ fullPage = false }: DrawsSectionProps) {

-
- +
+
Regular Pool
@@ -158,8 +159,8 @@ export default function DrawsSection({ fullPage = false }: DrawsSectionProps) { ...styles.drawTypeBadge, background: draw.draw_type === 'big' ? 'rgba(244, 114, 182, 0.1)' - : 'rgba(139, 111, 255, 0.1)', - color: draw.draw_type === 'big' ? '#f472b6' : '#8B6FFF', + : colors.primaryAlpha(0.1), + color: draw.draw_type === 'big' ? '#f472b6' : colors.primary, }}> #{draw.id} @@ -211,8 +212,8 @@ export default function DrawsSection({ fullPage = false }: DrawsSectionProps) { ...styles.drawTypeBadge, background: draw.draw_type === 'big' ? 'rgba(244, 114, 182, 0.1)' - : 'rgba(139, 111, 255, 0.1)', - color: draw.draw_type === 'big' ? '#f472b6' : '#8B6FFF', + : colors.primaryAlpha(0.1), + color: draw.draw_type === 'big' ? '#f472b6' : colors.primary, }}> {draw.draw_type === 'big' ? '🏆' : '✨'} #{draw.id}
@@ -342,7 +343,7 @@ const styles: Record = { poolProgressBar: { height: '100%', borderRadius: 2, - background: 'linear-gradient(90deg, #8B6FFF, #6B4FD6)', + background: `linear-gradient(90deg, ${colors.primary}, ${colors.primaryDark})`, transition: 'width 0.5s ease', }, filterRow: { @@ -362,9 +363,9 @@ const styles: Record = { transition: 'all 0.2s', }, filterTabActive: { - background: 'rgba(139, 111, 255, 0.08)', - color: '#8B6FFF', - border: '1px solid rgba(139, 111, 255, 0.15)', + background: colors.primaryAlpha(0.08), + color: colors.primary, + border: `1px solid ${colors.primaryAlpha(0.15)}`, }, liveSection: { marginBottom: 12, diff --git a/frontend/src/components/EpochCountdown.tsx b/frontend/src/components/EpochCountdown.tsx index c04e287..cee42ff 100644 --- a/frontend/src/components/EpochCountdown.tsx +++ b/frontend/src/components/EpochCountdown.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react' import { Timer } from 'lucide-react' import { useStore } from '../store/useStore' +import { colors } from '../theme' interface EpochCountdownProps { compact?: boolean @@ -103,7 +104,7 @@ export default function EpochCountdown({ compact = false }: EpochCountdownProps) width: `${progress}%`, background: isAlmostDone ? 'linear-gradient(90deg, #f59e0b, #ef4444)' - : 'linear-gradient(90deg, #8B6FFF, #38bdf8)', + : `linear-gradient(90deg, ${colors.primary}, #38bdf8)`, }} />
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 2f5e1fb..3c49b58 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -1,6 +1,7 @@ import React from 'react' import { Sparkles, Github, ExternalLink } from 'lucide-react' import { CONTRACTS, NETWORK } from '../config' +import { colors } from '../theme' const explorerBase = (NETWORK as string).includes('mainnet') ? 'https://explorer.injective.network' @@ -13,7 +14,7 @@ export default function Footer() {
- + Chance.Staking

diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index dbf4c0f..24507e2 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,9 +2,13 @@ import React, { useState, useEffect, useRef } from 'react' import { Sparkles, ChevronDown, Wallet, LogOut, Copy, Check, Menu, X } from 'lucide-react' import { useStore } from '../store/useStore' import type { WalletType } from '../store/useStore' +import { NETWORK } from '../config' +import { colors } from '../theme' import RpcSelector from './RpcSelector' import RpcModal from './RpcModal' +const isTestnet = (NETWORK as string).toLowerCase().includes('testnet') + const navLinks = [ { href: '#/stake', label: 'Stake' }, { href: '#/draws', label: 'Draws' }, @@ -89,13 +93,16 @@ export default function Header() {

@@ -143,7 +150,7 @@ export default function Header() { setShowWalletMenu(false) }} onMouseEnter={(e) => { - (e.currentTarget as HTMLButtonElement).style.background = 'rgba(139, 111, 255, 0.08)' + (e.currentTarget as HTMLButtonElement).style.background = colors.primaryAlpha(0.08) }} onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' @@ -182,7 +189,7 @@ export default function Header() { @@ -949,7 +950,7 @@ const styles: Record = { // Hero hero: { padding: '56px 0 0', - background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + background: `linear-gradient(180deg, ${colors.primaryAlpha(0.04)} 0%, transparent 100%)`, }, heroContainer: { maxWidth: 720, @@ -1034,7 +1035,7 @@ const styles: Record = { }, sidebarItemActive: { color: '#F0F0F5', - background: 'rgba(139, 111, 255, 0.08)', + background: colors.primaryAlpha(0.08), fontWeight: 600, }, @@ -1172,8 +1173,8 @@ const styles: Record = { width: 24, height: 24, borderRadius: '50%', - background: 'rgba(139, 111, 255, 0.12)', - color: '#8B6FFF', + background: colors.primaryAlpha(0.12), + color: colors.primary, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -1221,7 +1222,7 @@ const styles: Record = { flowLabel: { fontSize: 13, fontWeight: 700, - color: '#8B6FFF', + color: colors.primary, marginBottom: 6, }, flowDesc: { diff --git a/frontend/src/pages/DrawsPage.tsx b/frontend/src/pages/DrawsPage.tsx index 6709f8b..048bfd5 100644 --- a/frontend/src/pages/DrawsPage.tsx +++ b/frontend/src/pages/DrawsPage.tsx @@ -3,6 +3,7 @@ import { Trophy, Sparkles } from 'lucide-react' import { useStore } from '../store/useStore' import { formatInj } from '../utils/formatNumber' import DrawsSection from '../components/DrawsSection' +import { colors } from '../theme' export default function DrawsPage() { const regularPoolBalance = useStore((s) => s.regularPoolBalance) @@ -23,10 +24,10 @@ export default function DrawsPage() {
- + Regular Pool
-
+
{formatInj(regularPoolBalance, 2)} INJ
@@ -65,7 +66,7 @@ const styles: Record = { }, hero: { padding: '48px 0 0', - background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + background: `linear-gradient(180deg, ${colors.primaryAlpha(0.04)} 0%, transparent 100%)`, }, heroContainer: { maxWidth: 1280, diff --git a/frontend/src/pages/HowItWorksPage.tsx b/frontend/src/pages/HowItWorksPage.tsx index 3fcc984..64476ce 100644 --- a/frontend/src/pages/HowItWorksPage.tsx +++ b/frontend/src/pages/HowItWorksPage.tsx @@ -4,6 +4,7 @@ import { Shield, Lock, ChevronDown, Eye, Hash, Target, Layers, FileCheck } from 'lucide-react' import { useStore } from '../store/useStore' +import { colors } from '../theme' import { formatNumber } from '../utils/formatNumber' import RewardsCalculator from '../components/RewardsCalculator' @@ -11,9 +12,9 @@ import RewardsCalculator from '../components/RewardsCalculator' const steps = [ { icon: Coins, - color: '#8B6FFF', - bg: 'rgba(139, 111, 255, 0.1)', - borderColor: 'rgba(139, 111, 255, 0.2)', + color: colors.primary, + bg: colors.primaryAlpha(0.1), + borderColor: colors.primaryAlpha(0.2), title: 'Stake INJ', short: 'Deposit INJ and receive csINJ', detail: 'When you stake INJ, the protocol delegates it across multiple validators for native staking. You receive csINJ — a liquid staking token whose exchange rate against INJ increases over time as staking rewards accrue. csINJ can be held, transferred, or used in DeFi while your INJ earns rewards.', @@ -49,7 +50,7 @@ const steps = [ // ── Reward distribution data ── const distributions = [ - { label: 'Regular Draws', pct: 70, color: '#8B6FFF', gradient: 'linear-gradient(90deg, #8B6FFF, #6B4FD6)' }, + { label: 'Regular Draws', pct: 70, color: colors.primary, gradient: `linear-gradient(90deg, ${colors.primary}, ${colors.primaryDark})` }, { label: 'Big Weekly Draw', pct: 20, color: '#f472b6', gradient: 'linear-gradient(90deg, #f472b6, #ec4899)' }, { label: 'Base Yield', pct: 5, color: '#22c55e', gradient: '#22c55e' }, { label: 'Protocol Fee', pct: 5, color: '#f59e0b', gradient: '#f59e0b' }, @@ -66,7 +67,7 @@ const drawSteps = [ }, { icon: Lock, - color: '#8B6FFF', + color: colors.primary, title: '2. Commit', desc: 'The operator commits a hash of their secret along with a target drand round number. This locks in the randomness source before the beacon is produced.', code: 'operator_commit = sha256(secret)', @@ -139,7 +140,8 @@ export default function HowItWorksPage() { const [openFaq, setOpenFaq] = useState(null) const epochHours = epochDurationSeconds / 3600 - const epochDisplay = epochHours >= 24 ? `${(epochHours / 24).toFixed(0)} day${epochHours >= 48 ? 's' : ''}` : `${epochHours.toFixed(0)} hours` + const epochMins = epochDurationSeconds / 60 + const epochDisplay = epochHours >= 24 ? `${(epochHours / 24).toFixed(0)} day${epochHours >= 48 ? 's' : ''}` : `${epochMins.toFixed(0)} minutes` return (
@@ -245,7 +247,7 @@ export default function HowItWorksPage() {
-

Regular Draws (70%) — The majority of rewards fund frequent prize draws. Every epoch a regular draw can occur, giving stakers regular chances to win.

+

Regular Draws (70%) — The majority of rewards fund frequent prize draws. Every epoch a regular draw can occur, giving stakers regular chances to win.

Big Jackpot (20%) — A larger pool accumulates over multiple epochs for bigger, less frequent draws with larger prizes.

Base Yield (5%) — Directly increases the csINJ exchange rate, providing guaranteed returns to all holders regardless of draw outcomes.

Protocol Fee (5%) — Sustains protocol operations, development, and infrastructure.

@@ -266,11 +268,11 @@ export default function HowItWorksPage() {
{epochDisplay}
Epoch Duration
- Each epoch is {formatNumber(epochDurationSeconds, 0)} seconds. At the end of each epoch, staking rewards are claimed, distributed, and a new snapshot can be taken. + Each epoch is {formatNumber(epochDurationSeconds / 60, 0)} minutes. At the end of each epoch, staking rewards are claimed, distributed, and a new snapshot can be taken.
- +
Every {minEpochsRegular || 1} epoch{(minEpochsRegular || 1) > 1 ? 's' : ''}
Regular Draws
@@ -295,7 +297,7 @@ export default function HowItWorksPage() {
{label} {i < 5 &&
} @@ -357,8 +359,8 @@ export default function HowItWorksPage() {

-
- +
+

Commit-Reveal Scheme

@@ -389,7 +391,7 @@ export default function HowItWorksPage() { key={i} style={{ ...styles.faqItem, - borderColor: openFaq === i ? 'rgba(139, 111, 255, 0.3)' : '#2A2A38', + borderColor: openFaq === i ? colors.primaryAlpha(0.3) : '#2A2A38', }} >

- +
{formatNumber(tvl, 1)} INJ @@ -64,7 +65,7 @@ const styles: Record = { }, hero: { padding: '48px 0 0', - background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + background: `linear-gradient(180deg, ${colors.primaryAlpha(0.04)} 0%, transparent 100%)`, }, heroContainer: { maxWidth: 1280, diff --git a/frontend/src/pages/TermsPage.tsx b/frontend/src/pages/TermsPage.tsx index c92e606..c0c2e42 100644 --- a/frontend/src/pages/TermsPage.tsx +++ b/frontend/src/pages/TermsPage.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { colors } from '../theme' export default function TermsPage() { return ( @@ -171,7 +172,7 @@ const styles: Record = { }, hero: { padding: '56px 0 0', - background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + background: `linear-gradient(180deg, ${colors.primaryAlpha(0.04)} 0%, transparent 100%)`, }, heroContainer: { maxWidth: 720, @@ -212,7 +213,7 @@ const styles: Record = { marginBottom: 12, }, link: { - color: '#8B6FFF', + color: colors.primary, textDecoration: 'underline', }, } diff --git a/frontend/src/pages/ValidatorsPage.tsx b/frontend/src/pages/ValidatorsPage.tsx index 1423700..d841c38 100644 --- a/frontend/src/pages/ValidatorsPage.tsx +++ b/frontend/src/pages/ValidatorsPage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { Shield, ExternalLink, Percent, TrendingUp, Coins, PieChart } from 'lucide-react' import { useStore } from '../store/useStore' +import { colors } from '../theme' import { formatNumber } from '../utils/formatNumber' import * as contracts from '../services/contracts' @@ -114,7 +115,7 @@ export default function ValidatorsPage() {
- +
{formatNumber(totalDelegated, 1)} INJ
Total Delegated
@@ -161,7 +162,7 @@ export default function ValidatorsPage() {
- +
{v.moniker}
@@ -210,10 +211,10 @@ export default function ValidatorsPage() {
- + Allocation
- + {formatNumber(v.shareOfTotal, 1)}%
@@ -253,7 +254,7 @@ const styles: Record = { }, hero: { padding: '48px 0 0', - background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + background: `linear-gradient(180deg, ${colors.primaryAlpha(0.04)} 0%, transparent 100%)`, }, heroContainer: { maxWidth: 1280, @@ -337,7 +338,7 @@ const styles: Record = { width: 36, height: 36, borderRadius: 10, - background: 'rgba(139, 111, 255, 0.1)', + background: colors.primaryAlpha(0.1), display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -395,7 +396,7 @@ const styles: Record = { allocFill: { height: '100%', borderRadius: 2, - background: 'linear-gradient(90deg, #8B6FFF, #6B4FD6)', + background: colors.primaryGradient, transition: 'width 0.4s ease', }, skeletonCard: { diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index dd25f09..e649b35 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -236,7 +236,7 @@ export const useStore = create()( if (drawId !== null) { window.location.hash = `#/draws/${drawId}`; } else { - window.location.hash = '#/draws'; + window.location.hash = "#/draws"; } }, @@ -316,8 +316,7 @@ export const useStore = create()( currentEpoch: epochState.current_epoch, epochStartTime: epochState.epoch_start_time, epochDurationSeconds: hubConfig.epoch_duration_seconds, - minEpochsRegular: - hubConfig.min_epochs_regular ?? 0, + minEpochsRegular: hubConfig.min_epochs_regular ?? 0, minEpochsBig: hubConfig.min_epochs_big ?? 0, baseYieldBps: hubConfig.base_yield_bps, regularPoolBps: hubConfig.regular_pool_bps, @@ -397,6 +396,7 @@ export const useStore = create()( startAfter > 0 ? startAfter : undefined, count, ); + console.log(draws); if (draws) { const prevDraws = get().recentDraws; const prevRevealedIds = new Set( diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts new file mode 100644 index 0000000..7dd659b --- /dev/null +++ b/frontend/src/theme.ts @@ -0,0 +1,36 @@ +// ═══════════════════════════════════════════════════════════════ +// Chance.Staking — Centralized Theme Colors +// Change brand colors here; all components import from this file. +// Keep in sync with CSS variables in index.css. +// ═══════════════════════════════════════════════════════════════ + +export const colors = { + // Primary brand + primary: '#FDC70C', + primaryLight: '#E9C46A', + primaryDark: '#e3b209', + + // Primary with opacity helper + primaryAlpha: (opacity: number) => `rgba(253, 199, 12, ${opacity})`, + + // Semantic + secondary: '#38bdf8', + accent: '#f472b6', + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + + // Surfaces + background: '#0F0F13', + surface: '#1A1A22', + surfaceElevated: '#252530', + border: '#2A2A38', + + // Text + text: '#F0F0F5', + textSecondary: '#8E8EA0', + + // Gradients + primaryGradient: 'linear-gradient(135deg, #FDC70C, #e3b209)', + heroGradient: 'linear-gradient(135deg, #26A17B 0%, #FDC70C 50%, #E9C46A 100%)', +}