diff --git a/chance-staking/contracts/staking-hub/src/contract.rs b/chance-staking/contracts/staking-hub/src/contract.rs index 1b2a9d1..e3878fe 100644 --- a/chance-staking/contracts/staking-hub/src/contract.rs +++ b/chance-staking/contracts/staking-hub/src/contract.rs @@ -56,6 +56,8 @@ pub fn instantiate( base_yield_bps: msg.base_yield_bps, regular_pool_bps: msg.regular_pool_bps, big_pool_bps: msg.big_pool_bps, + min_epochs_regular: msg.min_epochs_regular, + min_epochs_big: msg.min_epochs_big, }; CONFIG.save(deps.storage, &config)?; @@ -120,7 +122,18 @@ pub fn execute( admin, operator, protocol_fee_bps, - } => execute::update_config(deps, env, info, admin, operator, protocol_fee_bps), + min_epochs_regular, + min_epochs_big, + } => execute::update_config( + deps, + env, + info, + admin, + operator, + protocol_fee_bps, + min_epochs_regular, + min_epochs_big, + ), ExecuteMsg::UpdateValidators { add, remove } => { execute::update_validators(deps, env, info, add, remove) } @@ -138,6 +151,7 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { start_after, limit, } => query::query_unstake_requests(deps, address, start_after, limit), + QueryMsg::StakerInfo { address } => query::query_staker_info(deps, address), } } @@ -163,6 +177,8 @@ mod tests { regular_pool_bps: 7000, big_pool_bps: 2000, csinj_subdenom: "csINJ".to_string(), + min_epochs_regular: 0, + min_epochs_big: 0, } } @@ -651,4 +667,91 @@ mod tests { .unwrap_err(); assert!(matches!(err, ContractError::NoValidators)); } + + #[test] + fn test_stake_records_and_resets_epoch() { + use crate::state::{EPOCH_STATE, USER_STAKE_EPOCH}; + + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + let user1 = deps.api.addr_make("user1"); + let info = message_info(&user1, &coins(100_000_000, "inj")); + execute(deps.as_mut(), mock_env(), info, ExecuteMsg::Stake {}).unwrap(); + + // Verify stake epoch recorded as epoch 1 + let addr = deps.api.addr_make("user1"); + let stake_epoch = USER_STAKE_EPOCH.load(deps.as_ref().storage, &addr).unwrap(); + assert_eq!(stake_epoch, 1); + + // Simulate epoch advancing to 5 + let mut epoch_state = EPOCH_STATE.load(deps.as_ref().storage).unwrap(); + epoch_state.current_epoch = 5; + EPOCH_STATE + .save(deps.as_mut().storage, &epoch_state) + .unwrap(); + + // Second stake should reset epoch to 5 + let user1 = deps.api.addr_make("user1"); + let info = message_info(&user1, &coins(50_000_000, "inj")); + execute(deps.as_mut(), mock_env(), info, ExecuteMsg::Stake {}).unwrap(); + + let addr = deps.api.addr_make("user1"); + let stake_epoch = USER_STAKE_EPOCH.load(deps.as_ref().storage, &addr).unwrap(); + assert_eq!(stake_epoch, 5); // reset to current epoch + } + + #[test] + fn test_staker_info_query() { + use crate::msg::StakerInfoResponse; + use cosmwasm_std::from_json; + + let mut deps = mock_dependencies(); + setup_contract(deps.as_mut()); + + // Query before staking — should return None + let user1 = deps.api.addr_make("user1"); + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::StakerInfo { + address: user1.to_string(), + }, + ) + .unwrap(); + let info: StakerInfoResponse = from_json(res).unwrap(); + assert_eq!(info.stake_epoch, None); + + // Stake + let user1 = deps.api.addr_make("user1"); + let msg_info = message_info(&user1, &coins(100_000_000, "inj")); + execute(deps.as_mut(), mock_env(), msg_info, ExecuteMsg::Stake {}).unwrap(); + + // Query after staking — should return epoch 1 + let res = query( + deps.as_ref(), + mock_env(), + QueryMsg::StakerInfo { + address: user1.to_string(), + }, + ) + .unwrap(); + let info: StakerInfoResponse = from_json(res).unwrap(); + assert_eq!(info.stake_epoch, Some(1)); + } + + #[test] + fn test_min_epochs_in_config() { + let mut deps = mock_dependencies(); + let mut msg = default_instantiate_msg(); + msg.min_epochs_regular = 3; + msg.min_epochs_big = 7; + let admin = deps.api.addr_make("admin"); + let info = message_info(&admin, &[]); + instantiate(deps.as_mut(), mock_env(), info, msg).unwrap(); + + let config = CONFIG.load(deps.as_ref().storage).unwrap(); + assert_eq!(config.min_epochs_regular, 3); + assert_eq!(config.min_epochs_big, 7); + } } diff --git a/chance-staking/contracts/staking-hub/src/execute.rs b/chance-staking/contracts/staking-hub/src/execute.rs index 49f7d4b..7c5f02b 100644 --- a/chance-staking/contracts/staking-hub/src/execute.rs +++ b/chance-staking/contracts/staking-hub/src/execute.rs @@ -10,7 +10,7 @@ use crate::error::ContractError; use crate::msg::DistributorExecuteMsg; use crate::state::{ UnstakeRequest, CONFIG, EPOCH_STATE, EXCHANGE_RATE, NEXT_UNSTAKE_ID, PENDING_UNSTAKE_TOTAL, - TOTAL_CSINJ_SUPPLY, TOTAL_INJ_BACKING, UNSTAKE_REQUESTS, + TOTAL_CSINJ_SUPPLY, TOTAL_INJ_BACKING, UNSTAKE_REQUESTS, USER_STAKE_EPOCH, }; /// 10^18 — Decimal's internal scaling factor, used for overflow-safe rate arithmetic. @@ -82,6 +82,10 @@ pub fn stake( epoch_state.total_staked = new_backing; EPOCH_STATE.save(deps.storage, &epoch_state)?; + // Record the epoch of this stake (resets on every stake so that newly added + // funds must also satisfy the min_epochs eligibility requirement) + USER_STAKE_EPOCH.save(deps.storage, &info.sender, &epoch_state.current_epoch)?; + // Mint csINJ via Token Factory let mint_msg = create_mint_tokens_msg( env.contract.address.clone(), @@ -499,6 +503,7 @@ pub fn take_snapshot( } /// Update contract configuration. Admin only. +#[allow(clippy::too_many_arguments)] pub fn update_config( deps: DepsMut, _env: Env, @@ -506,6 +511,8 @@ pub fn update_config( admin: Option, operator: Option, protocol_fee_bps: Option, + min_epochs_regular: Option, + min_epochs_big: Option, ) -> Result { let mut config = CONFIG.load(deps.storage)?; @@ -530,6 +537,12 @@ pub fn update_config( } config.protocol_fee_bps = new_fee; } + if let Some(val) = min_epochs_regular { + config.min_epochs_regular = val; + } + if let Some(val) = min_epochs_big { + config.min_epochs_big = val; + } CONFIG.save(deps.storage, &config)?; diff --git a/chance-staking/contracts/staking-hub/src/msg.rs b/chance-staking/contracts/staking-hub/src/msg.rs index dbc5d9a..54b1f15 100644 --- a/chance-staking/contracts/staking-hub/src/msg.rs +++ b/chance-staking/contracts/staking-hub/src/msg.rs @@ -17,6 +17,10 @@ pub struct InstantiateMsg { pub big_pool_bps: u16, /// Subdenom for Token Factory, e.g. "csINJ" pub csinj_subdenom: String, + /// Minimum epochs staked to be eligible for regular draws + pub min_epochs_regular: u64, + /// Minimum epochs staked to be eligible for big draws + pub min_epochs_big: u64, } #[cw_serde] @@ -45,6 +49,8 @@ pub enum ExecuteMsg { admin: Option, operator: Option, protocol_fee_bps: Option, + min_epochs_regular: Option, + min_epochs_big: Option, }, /// Update validator set. Admin only. UpdateValidators { @@ -81,6 +87,8 @@ pub enum QueryMsg { start_after: Option, limit: Option, }, + #[returns(StakerInfoResponse)] + StakerInfo { address: String }, } #[cw_serde] @@ -95,3 +103,10 @@ pub struct UnstakeRequestEntry { pub id: u64, pub request: UnstakeRequest, } + +#[cw_serde] +pub struct StakerInfoResponse { + pub address: String, + /// The epoch of this user's most recent stake, or None if they have never staked. + pub stake_epoch: Option, +} diff --git a/chance-staking/contracts/staking-hub/src/query.rs b/chance-staking/contracts/staking-hub/src/query.rs index 84e6209..b2d54a1 100644 --- a/chance-staking/contracts/staking-hub/src/query.rs +++ b/chance-staking/contracts/staking-hub/src/query.rs @@ -1,9 +1,10 @@ use cosmwasm_std::{to_json_binary, Binary, Deps, Order, StdResult}; use cw_storage_plus::Bound; -use crate::msg::{ExchangeRateResponse, UnstakeRequestEntry}; +use crate::msg::{ExchangeRateResponse, StakerInfoResponse, UnstakeRequestEntry}; use crate::state::{ CONFIG, EPOCH_STATE, EXCHANGE_RATE, TOTAL_CSINJ_SUPPLY, TOTAL_INJ_BACKING, UNSTAKE_REQUESTS, + USER_STAKE_EPOCH, }; pub fn query_config(deps: Deps) -> StdResult { @@ -48,3 +49,12 @@ pub fn query_unstake_requests( to_json_binary(&entries) } + +pub fn query_staker_info(deps: Deps, address: String) -> StdResult { + let addr = deps.api.addr_validate(&address)?; + let stake_epoch = USER_STAKE_EPOCH.may_load(deps.storage, &addr)?; + to_json_binary(&StakerInfoResponse { + address, + stake_epoch, + }) +} diff --git a/chance-staking/contracts/staking-hub/src/state.rs b/chance-staking/contracts/staking-hub/src/state.rs index 4ba2f93..4b84b30 100644 --- a/chance-staking/contracts/staking-hub/src/state.rs +++ b/chance-staking/contracts/staking-hub/src/state.rs @@ -13,6 +13,9 @@ pub const NEXT_UNSTAKE_ID: Map<&Addr, u64> = Map::new("next_unstake_id"); /// Updated on unstake (increment) and claim_unstaked (decrement) to avoid /// iterating all requests on every distribute_rewards() call. pub const PENDING_UNSTAKE_TOTAL: Item = Item::new("pending_unstake"); +/// Tracks the epoch of the user's most recent stake. Resets on every stake +/// so newly added funds must also satisfy the min_epochs eligibility requirement. +pub const USER_STAKE_EPOCH: Map<&Addr, u64> = Map::new("user_stake_epoch"); #[cw_serde] pub struct Config { @@ -33,6 +36,10 @@ pub struct Config { pub regular_pool_bps: u16, /// Big draw pool in basis points (2000 = 20%) pub big_pool_bps: u16, + /// Minimum epochs a user must have been staking to be eligible for regular draws + pub min_epochs_regular: u64, + /// Minimum epochs a user must have been staking to be eligible for big draws + pub min_epochs_big: u64, } #[cw_serde] diff --git a/chance-staking/packages/chance-staking-common/src/types.rs b/chance-staking/packages/chance-staking-common/src/types.rs index 02916c9..65421eb 100644 --- a/chance-staking/packages/chance-staking-common/src/types.rs +++ b/chance-staking/packages/chance-staking-common/src/types.rs @@ -1,7 +1,7 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::Uint128; -/// The type of draw: regular (weighted by csINJ balance) or big (equal weight monthly). +/// The type of draw: regular or big. Both are weighted by csINJ balance. #[cw_serde] pub enum DrawType { Regular, diff --git a/chance-staking/scripts/deploy_testnet.sh b/chance-staking/scripts/deploy_testnet.sh index 9327b2b..d22738f 100755 --- a/chance-staking/scripts/deploy_testnet.sh +++ b/chance-staking/scripts/deploy_testnet.sh @@ -54,6 +54,8 @@ BASE_YIELD_BPS=500 REGULAR_POOL_BPS=7000 BIG_POOL_BPS=2000 CSINJ_SUBDENOM="csINJ" +MIN_EPOCHS_REGULAR=1 +MIN_EPOCHS_BIG=1 # -- Wasm artifacts -- DRAND_ORACLE_WASM="./artifacts/chance_drand_oracle.wasm" @@ -295,7 +297,9 @@ STAKING_HUB_INIT_MSG=$(cat < chance_staking_hub::msg::InstantiateMsg { regular_pool_bps: 7000, big_pool_bps: 2000, csinj_subdenom: "csINJ".to_string(), + min_epochs_regular: 0, + min_epochs_big: 0, } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2342f8f..34dc77b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import StakingPanel from './components/StakingPanel' import PortfolioSection from './components/PortfolioSection' import DrawsSection from './components/DrawsSection' import DrawDetail from './components/DrawDetail' +import RewardsCalculator from './components/RewardsCalculator' import HowItWorks from './components/HowItWorks' import Footer from './components/Footer' import ToastContainer from './components/Toast' @@ -88,6 +89,7 @@ function App() {
+ {isConnected && } diff --git a/frontend/src/components/PortfolioSection.tsx b/frontend/src/components/PortfolioSection.tsx index 77bfddc..6db5553 100644 --- a/frontend/src/components/PortfolioSection.tsx +++ b/frontend/src/components/PortfolioSection.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { Clock, Trophy, Download, Loader, Target } from 'lucide-react' +import { Clock, Trophy, Download, Loader, Target, ShieldCheck, ShieldAlert } from 'lucide-react' import { useStore } from '../store/useStore' import { formatInj, formatNumber } from '../utils/formatNumber' @@ -24,6 +24,10 @@ export default function PortfolioSection() { const csinjBalance = useStore((s) => s.csinjBalance) const snapshotTotalWeight = useStore((s) => s.snapshotTotalWeight) const snapshotNumHolders = useStore((s) => s.snapshotNumHolders) + const stakeEpoch = useStore((s) => s.stakeEpoch) + const currentEpoch = useStore((s) => s.currentEpoch) + const minEpochsRegular = useStore((s) => s.minEpochsRegular) + const minEpochsBig = useStore((s) => s.minEpochsBig) const [tab, setTab] = useState<'overview' | 'unstaking' | 'wins'>('overview') const [claiming, setClaiming] = useState(null) @@ -96,6 +100,76 @@ export default function PortfolioSection() { + {/* Eligibility Card */} + {stakeEpoch !== null && parseFloat(csinjBalance) > 0 && (() => { + const epochsStaked = currentEpoch - stakeEpoch + const regularEligible = epochsStaked >= minEpochsRegular + const bigEligible = epochsStaked >= minEpochsBig + const regularRemaining = Math.max(0, minEpochsRegular - epochsStaked) + const bigRemaining = Math.max(0, minEpochsBig - epochsStaked) + const allEligible = regularEligible && bigEligible + + return ( +
+
+
+ {allEligible + ? + : } +
+
+
+ {allEligible ? 'Eligible for All Draws' : 'Draw Eligibility'} +
+
+ Staked since epoch {stakeEpoch} ({epochsStaked} epoch{epochsStaked !== 1 ? 's' : ''} ago) +
+
+
+
+
+
+
+
Regular Draws
+
+ {regularEligible + ? 'Eligible' + : `${regularRemaining} epoch${regularRemaining !== 1 ? 's' : ''} remaining`} +
+
+
+
+
+
+
Big Jackpot
+
+ {bigEligible + ? 'Eligible' + : `${bigRemaining} epoch${bigRemaining !== 1 ? 's' : ''} remaining`} +
+
+
+
+
+ ) + })()} + {/* Your Odds Card */} {parseFloat(csinjBalance) > 0 && (
@@ -447,6 +521,67 @@ const styles: Record = { fontWeight: 700, fontVariantNumeric: 'tabular-nums', }, + eligibilityCard: { + marginTop: 12, + background: '#1A1A22', + border: '1px solid rgba(245, 158, 11, 0.15)', + borderRadius: 16, + padding: 22, + }, + eligibilityHeader: { + display: 'flex', + alignItems: 'center', + gap: 12, + marginBottom: 16, + }, + eligibilityIconWrap: { + width: 36, + height: 36, + borderRadius: 10, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + eligibilityTitle: { + fontSize: 15, + fontWeight: 700, + color: '#F0F0F5', + }, + eligibilitySubtitle: { + fontSize: 11, + color: '#8E8EA0', + marginTop: 2, + }, + eligibilityBody: { + display: 'flex', + flexDirection: 'column' as const, + gap: 10, + }, + eligibilityItem: { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '10px 14px', + borderRadius: 10, + background: '#0F0F13', + }, + eligibilityDot: { + width: 8, + height: 8, + borderRadius: '50%', + flexShrink: 0, + }, + eligibilityLabel: { + fontSize: 13, + fontWeight: 600, + color: '#F0F0F5', + }, + eligibilityStatus: { + fontSize: 11, + color: '#8E8EA0', + marginTop: 1, + }, oddsCard: { marginTop: 12, background: 'linear-gradient(135deg, rgba(26, 26, 34, 1), rgba(139, 111, 255, 0.03))', diff --git a/frontend/src/components/RewardsCalculator.tsx b/frontend/src/components/RewardsCalculator.tsx new file mode 100644 index 0000000..9d4166f --- /dev/null +++ b/frontend/src/components/RewardsCalculator.tsx @@ -0,0 +1,391 @@ +import React, { useState, useEffect } from 'react' +import { Calculator, TrendingUp, Trophy, Sparkles, Landmark } from 'lucide-react' +import { useStore } from '../store/useStore' +import { formatNumber } from '../utils/formatNumber' + +export default function RewardsCalculator() { + const baseYieldBps = useStore((s) => s.baseYieldBps) + const regularPoolBps = useStore((s) => s.regularPoolBps) + const bigPoolBps = useStore((s) => s.bigPoolBps) + const protocolFeeBps = useStore((s) => s.protocolFeeBps) + const onChainApr = useStore((s) => s.stakingApr) + + const [stakeAmount, setStakeAmount] = useState('1000') + const [apr, setApr] = useState('15') + const [aprSynced, setAprSynced] = useState(false) + + // Sync slider to on-chain APR once it loads + useEffect(() => { + if (onChainApr !== null && !aprSynced) { + setApr(onChainApr.toFixed(1)) + setAprSynced(true) + } + }, [onChainApr, aprSynced]) + + const stake = parseFloat(stakeAmount) || 0 + const aprPct = parseFloat(apr) || 0 + const totalBps = baseYieldBps + regularPoolBps + bigPoolBps + protocolFeeBps + const annualRewards = stake * (aprPct / 100) + + const baseYield = totalBps > 0 ? annualRewards * (baseYieldBps / totalBps) : 0 + const regularPool = totalBps > 0 ? annualRewards * (regularPoolBps / totalBps) : 0 + const bigPool = totalBps > 0 ? annualRewards * (bigPoolBps / totalBps) : 0 + const protocolFee = totalBps > 0 ? annualRewards * (protocolFeeBps / totalBps) : 0 + const effectiveBaseApr = stake > 0 ? (baseYield / stake) * 100 : 0 + + const segments = [ + { + label: 'Base Yield', + desc: 'Exchange rate appreciation', + value: baseYield, + bps: baseYieldBps, + color: '#22c55e', + icon: , + }, + { + label: 'Regular Draw Pool', + desc: 'Weighted by csINJ balance', + value: regularPool, + bps: regularPoolBps, + color: '#8B6FFF', + icon: , + }, + { + label: 'Big Jackpot Pool', + desc: 'Equal odds per holder', + value: bigPool, + bps: bigPoolBps, + color: '#f472b6', + icon: , + }, + { + label: 'Protocol Fee', + desc: 'Sustains the protocol', + value: protocolFee, + bps: protocolFeeBps, + color: '#8E8EA0', + icon: , + }, + ] + + return ( +
+
+
+
+ +
+
+

Rewards Calculator

+

+ See how staking rewards are allocated across prize pools and base yield +

+
+
+ +
+ {/* Inputs */} +
+
+ + setStakeAmount(e.target.value)} + placeholder="1000" + style={styles.input} + /> +
+
+ +
+ setApr(e.target.value)} + style={styles.slider} + /> +
{apr}%
+
+
+ + {/* Annual summary */} +
+
+ Annual Staking Rewards + + {formatNumber(annualRewards, 2)} INJ + +
+
+
+ Effective Base APR + + {formatNumber(effectiveBaseApr, 2)}% + +
+
+ Prize Pool Contribution + + {formatNumber(regularPool + bigPool, 2)} INJ/yr + +
+
+
+ + {/* Breakdown */} +
+ {/* Stacked bar */} +
+
+ {segments.map((seg) => ( +
0 ? (seg.bps / totalBps) * 100 : 0}%`, + background: seg.color, + transition: 'width 0.4s ease', + }} + /> + ))} +
+
+ + {/* Segment details */} +
+ {segments.map((seg) => ( +
+
+
+ {seg.icon} +
+
+
{seg.label}
+
{seg.desc}
+
+
+
+
+ {formatNumber(seg.value, 2)} INJ +
+
+ {totalBps > 0 + ? formatNumber((seg.bps / totalBps) * 100, 1) + : 0} + % +
+
+
+ ))} +
+
+
+
+
+ ) +} + +const styles: Record = { + section: { + padding: '0 0 80px', + }, + container: { + maxWidth: 800, + margin: '0 auto', + padding: '0 24px', + }, + header: { + display: 'flex', + alignItems: 'center', + gap: 14, + marginBottom: 24, + }, + headerIcon: { + width: 44, + height: 44, + borderRadius: 12, + background: 'rgba(139, 111, 255, 0.1)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + title: { + fontSize: 24, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + }, + subtitle: { + fontSize: 13, + color: '#8E8EA0', + marginTop: 2, + }, + layout: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: 16, + alignItems: 'start', + }, + inputCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 22, + display: 'flex', + flexDirection: 'column' as const, + gap: 18, + }, + inputGroup: { + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + }, + inputLabel: { + fontSize: 11, + fontWeight: 500, + color: '#8E8EA0', + textTransform: 'uppercase' as const, + letterSpacing: '0.06em', + }, + input: { + background: '#0F0F13', + border: '1px solid #2A2A38', + borderRadius: 10, + padding: '12px 14px', + color: '#F0F0F5', + fontSize: 18, + fontWeight: 700, + outline: 'none', + letterSpacing: '-0.02em', + width: '100%', + boxSizing: 'border-box' as const, + }, + sliderRow: { + display: 'flex', + alignItems: 'center', + gap: 12, + }, + slider: { + flex: 1, + height: 4, + appearance: 'auto' as const, + accentColor: '#8B6FFF', + cursor: 'pointer', + }, + aprBadge: { + background: '#0F0F13', + border: '1px solid #2A2A38', + borderRadius: 8, + padding: '6px 12px', + fontSize: 14, + fontWeight: 700, + color: '#F0F0F5', + minWidth: 52, + textAlign: 'center' as const, + }, + summaryCard: { + background: '#0F0F13', + borderRadius: 12, + padding: 16, + display: 'flex', + flexDirection: 'column' as const, + gap: 10, + }, + summaryRow: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + summaryLabel: { + fontSize: 12, + color: '#8E8EA0', + }, + summaryValue: { + fontSize: 13, + fontWeight: 700, + color: '#F0F0F5', + fontVariantNumeric: 'tabular-nums', + }, + summaryDivider: { + height: 1, + background: '#2A2A38', + }, + breakdownCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 22, + }, + barContainer: { + marginBottom: 20, + }, + barTrack: { + display: 'flex', + height: 8, + borderRadius: 4, + overflow: 'hidden', + background: '#0F0F13', + }, + segmentList: { + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + }, + segmentRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '10px 12px', + borderRadius: 10, + background: '#0F0F13', + }, + segmentLeft: { + display: 'flex', + alignItems: 'center', + gap: 10, + }, + segmentIcon: { + width: 30, + height: 30, + borderRadius: 8, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + segmentLabel: { + fontSize: 13, + fontWeight: 600, + color: '#F0F0F5', + }, + segmentDesc: { + fontSize: 10, + color: '#8E8EA0', + marginTop: 1, + }, + segmentRight: { + textAlign: 'right' as const, + }, + segmentValue: { + fontSize: 14, + fontWeight: 700, + fontVariantNumeric: 'tabular-nums', + }, + segmentPct: { + fontSize: 10, + color: '#8E8EA0', + marginTop: 1, + }, +} diff --git a/frontend/src/services/contracts.ts b/frontend/src/services/contracts.ts index 50c09d5..1260c94 100644 --- a/frontend/src/services/contracts.ts +++ b/frontend/src/services/contracts.ts @@ -1,6 +1,8 @@ import { ChainGrpcWasmApi, ChainGrpcBankApi, + ChainGrpcStakingApi, + ChainGrpcMintApi, MsgExecuteContractCompat, } from "@injectivelabs/sdk-ts"; import { CONTRACTS, ENDPOINTS, INJ_DENOM, getCsinjDenom } from "../config"; @@ -8,6 +10,8 @@ import { CONTRACTS, ENDPOINTS, INJ_DENOM, getCsinjDenom } from "../config"; // ---------- API instances ---------- const wasmApi = new ChainGrpcWasmApi(ENDPOINTS.grpc); const bankApi = new ChainGrpcBankApi(ENDPOINTS.grpc); +const stakingApi = new ChainGrpcStakingApi(ENDPOINTS.grpc); +const mintApi = new ChainGrpcMintApi(ENDPOINTS.grpc); // ---------- Types ---------- export interface ExchangeRateResponse { @@ -81,6 +85,13 @@ export interface StakingHubConfig { base_yield_bps: number; regular_pool_bps: number; big_pool_bps: number; + min_epochs_regular: number; + min_epochs_big: number; +} + +export interface StakerInfoResponse { + address: string; + stake_epoch: number | null; } export interface UserWinsResponse { @@ -154,6 +165,14 @@ export async function fetchUnstakeRequests( }); } +export async function fetchStakerInfo( + address: string, +): Promise { + return queryContract(CONTRACTS.stakingHub, { + staker_info: { address }, + }); +} + // ---------- Reward Distributor Queries ---------- export async function fetchDrawState(): Promise { return queryContract(CONTRACTS.rewardDistributor, { @@ -221,6 +240,40 @@ export async function fetchStakingHubConfig(): Promise { }); } +// ---------- On-chain staking APR ---------- +export async function fetchStakingApr( + validators: string[], +): Promise { + try { + const [provisions, pool, ...validatorResults] = await Promise.all([ + mintApi.fetchAnnualProvisions(), + stakingApi.fetchPool(), + ...validators.map((addr) => + stakingApi.fetchValidator(addr).catch(() => null), + ), + ]); + + const annualProvisions = parseFloat(provisions.annualProvisions); + const bondedTokens = parseFloat(pool.bondedTokens); + if (!bondedTokens || !annualProvisions) return null; + + const nominalApr = annualProvisions / bondedTokens; + + // Average commission across the contract's validators + const commissions = validatorResults + .filter((v): v is NonNullable => v !== null) + .map((v) => parseFloat(v.commission.commissionRates.rate)); + + if (commissions.length === 0) return nominalApr * 100; + + const avgCommission = + commissions.reduce((a, b) => a + b, 0) / commissions.length; + return nominalApr * (1 - avgCommission) * 100; + } catch { + return null; + } +} + // ---------- Execute message builders ---------- export function buildStakeMsg(sender: string, amount: string) { return MsgExecuteContractCompat.fromJSON({ diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index 9f61eed..452f488 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -40,6 +40,13 @@ interface ContractState { totalRewardsDistributed: string; snapshotTotalWeight: string; snapshotNumHolders: number; + minEpochsRegular: number; + minEpochsBig: number; + baseYieldBps: number; + regularPoolBps: number; + bigPoolBps: number; + protocolFeeBps: number; + stakingApr: number | null; } interface ConfettiState { @@ -65,6 +72,7 @@ interface UserState { unstakeRequests: contracts.UnstakeRequestEntry[]; userWins: contracts.UserWinsResponse | null; userWinDraws: contracts.Draw[]; + stakeEpoch: number | null; } interface DrawsState { @@ -158,6 +166,13 @@ export const useStore = create()( totalRewardsDistributed: "0", snapshotTotalWeight: "0", snapshotNumHolders: 0, + minEpochsRegular: 0, + minEpochsBig: 0, + baseYieldBps: 500, + regularPoolBps: 7000, + bigPoolBps: 2000, + protocolFeeBps: 500, + stakingApr: null, // Confetti state showConfetti: false, @@ -166,6 +181,7 @@ export const useStore = create()( unstakeRequests: [], userWins: null, userWinDraws: [], + stakeEpoch: null, // Initial draws state recentDraws: [], @@ -255,6 +271,7 @@ export const useStore = create()( unstakeRequests: [], userWins: null, userWinDraws: [], + stakeEpoch: null, error: "", }); }, @@ -283,11 +300,26 @@ export const useStore = create()( currentEpoch: epochState.current_epoch, epochStartTime: epochState.epoch_start_time, epochDurationSeconds: hubConfig.epoch_duration_seconds, + minEpochsRegular: + hubConfig.min_epochs_regular ?? 0, + minEpochsBig: hubConfig.min_epochs_big ?? 0, + baseYieldBps: hubConfig.base_yield_bps, + regularPoolBps: hubConfig.regular_pool_bps, + bigPoolBps: hubConfig.big_pool_bps, + protocolFeeBps: hubConfig.protocol_fee_bps, snapshotTotalWeight: epochState.snapshot_total_weight || "0", snapshotNumHolders: epochState.snapshot_num_holders || 0, }); + + // Fetch on-chain staking APR in background (non-blocking) + contracts + .fetchStakingApr(hubConfig.validators) + .then((apr) => { + if (apr !== null) set({ stakingApr: apr }); + }) + .catch(() => {}); } catch (err: any) { console.error("Failed to fetch contract data:", err); } @@ -311,8 +343,8 @@ export const useStore = create()( const { injectiveAddress } = get(); if (!injectiveAddress || !CONTRACTS.stakingHub) return; try { - const [unstakeReqs, userWins, winDraws] = await Promise.all( - [ + const [unstakeReqs, userWins, winDraws, stakerInfo] = + await Promise.all([ contracts.fetchUnstakeRequests(injectiveAddress), CONTRACTS.rewardDistributor ? contracts.fetchUserWins(injectiveAddress) @@ -322,12 +354,13 @@ export const useStore = create()( injectiveAddress, ) : [], - ], - ); + contracts.fetchStakerInfo(injectiveAddress), + ]); set({ unstakeRequests: unstakeReqs, userWins: userWins, userWinDraws: winDraws || [], + stakeEpoch: stakerInfo.stake_epoch, }); } catch (err: any) { console.error("Failed to fetch user data:", err); @@ -415,6 +448,7 @@ export const useStore = create()( await Promise.all([ get().fetchBalances(), get().fetchContractData(), + get().fetchUserData(), ]); } catch (err: any) { set({ error: err?.message || "Stake failed" });