diff --git a/CLAUDE.md b/CLAUDE.md index a64f520..619d2e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,7 +22,7 @@ chance-staking/ ```bash cd chance-staking -cargo test # all 51 tests +cargo test # all 96 tests cargo test -p chance-drand-oracle # oracle unit tests cargo test -p chance-staking-hub # staking hub unit tests cargo test -p chance-reward-distributor # distributor unit tests @@ -66,6 +66,9 @@ Stores and verifies drand quicknet beacons (BLS signatures). Used by reward-dist // Update operator list (admin only) { "update_operators": { "add": ["inj1..."], "remove": [] } } + +// Update admin (admin only) +{ "update_admin": { "new_admin": "inj1..." } } ``` #### Query @@ -117,6 +120,7 @@ Manages INJ staking, csINJ (liquid staking token) minting/burning, epoch advance ```jsonc // Stake INJ to receive csINJ (send INJ in funds) // csINJ_minted = inj_amount / exchange_rate +// Rejects if amount < min_stake_amount. Resets user's epoch eligibility timer. { "stake": {} } // funds: [{ "denom": "inj", "amount": "1000000000000000000" }] @@ -133,8 +137,9 @@ Manages INJ staking, csINJ (liquid staking token) minting/burning, epoch advance { "claim_rewards": {} } // Step 2: Distribute claimed rewards and advance epoch (operator only) +// Enforces epoch_duration_seconds has elapsed since epoch start. // Reads contract INJ balance, subtracts reserved unstake amounts, -// splits surplus: base_yield_bps -> backing, protocol_fee_bps -> treasury, +// splits surplus: base_yield_bps -> delegated to validators, protocol_fee_bps -> treasury, // regular_pool_bps -> regular pool, big_pool_bps -> big pool { "distribute_rewards": {} } @@ -148,15 +153,27 @@ Manages INJ staking, csINJ (liquid staking token) minting/burning, epoch advance } } // Update config (admin only) +// Note: BPS fields must sum to 10000 (regular + big + base_yield + protocol_fee) { "update_config": { "admin": "inj1...", // optional "operator": "inj1...", // optional - "protocol_fee_bps": 500 // optional + "protocol_fee_bps": 500, // optional + "base_yield_bps": 500, // optional + "regular_pool_bps": 7000, // optional + "big_pool_bps": 2000, // optional + "min_epochs_regular": 1, // optional, min epochs staked for regular draw eligibility + "min_epochs_big": 4, // optional, min epochs staked for big draw eligibility + "min_stake_amount": "1000" // optional, Uint128 (0 = no minimum) } } // Update validator set (admin only) // Removed validators are automatically redelegated to remaining validators +// Validator addresses must start with "injvaloper" { "update_validators": { "add": ["injvaloper1..."], "remove": [] } } + +// Sync backing with actual validator delegations after slashing (operator only) +// Updates TOTAL_INJ_BACKING, EPOCH_STATE.total_staked, and exchange rate +{ "sync_delegations": {} } ``` #### Query @@ -177,7 +194,10 @@ Manages INJ staking, csINJ (liquid staking token) minting/burning, epoch advance // "treasury": "inj1...", // "base_yield_bps": 500, // "regular_pool_bps": 7000, -// "big_pool_bps": 2000 +// "big_pool_bps": 2000, +// "min_epochs_regular": 1, +// "min_epochs_big": 4, +// "min_stake_amount": "1000" // } // Get current epoch state @@ -215,6 +235,14 @@ Manages INJ staking, csINJ (liquid staking token) minting/burning, epoch advance // "claimed": false // } // }] + +// Get staker eligibility info +{ "staker_info": { "address": "inj1..." } } +// Returns: StakerInfoResponse +// { +// "address": "inj1...", +// "stake_epoch": 5 | null // epoch of most recent stake, null if never staked +// } ``` --- @@ -273,10 +301,11 @@ Manages prize draw lifecycle: commit-reveal with drand randomness, merkle-proof { "expire_draw": { "draw_id": 0 } } // Update config (admin only) +// reveal_deadline_seconds must be between 300 (5 min) and 86400 (24 hours) { "update_config": { "operator": "inj1...", // optional "staking_hub": "inj1...", // optional - "reveal_deadline_seconds": 3600, // optional + "reveal_deadline_seconds": 3600, // optional (300-86400) "epochs_between_regular": 1, // optional "epochs_between_big": 7 // optional } } @@ -399,13 +428,15 @@ interface SnapshotEntry { ## Merkle Tree -The merkle tree uses **sorted-pair hashing** (smaller hash first when combining siblings). +The merkle tree uses **sorted-pair hashing** (smaller hash first when combining siblings) with **domain separation** prefixes to prevent second pre-image attacks. -**Leaf hash**: `sha256(address_bytes || cumulative_start_be_u128 || cumulative_end_be_u128)` +**Leaf hash**: `sha256(0x00 || address_bytes || cumulative_start_be_u128 || cumulative_end_be_u128)` +- `0x00`: leaf domain separator prefix byte - `address_bytes`: raw UTF-8 bytes of the bech32 address string - `cumulative_start` / `cumulative_end`: big-endian 16-byte u128 -**Internal nodes**: `sha256(min(left, right) || max(left, right))` +**Internal nodes**: `sha256(0x01 || min(left, right) || max(left, right))` +- `0x01`: internal node domain separator prefix byte The frontend needs to: 1. Build the tree from the snapshot entries @@ -423,6 +454,7 @@ The frontend needs to: | Recent draws | reward-distributor | `draw_history` | | Specific draw result | reward-distributor | `draw` | | User's win history | reward-distributor | `user_wins` / `user_win_details` | +| Staker eligibility info | staking-hub | `staker_info` | | Latest drand round | drand-oracle | `latest_round` | ## Key Frontend Actions @@ -435,6 +467,25 @@ The frontend needs to: --- +## Validation Rules (Post-Audit) + +- **BPS sum**: `regular_pool_bps + big_pool_bps + base_yield_bps + protocol_fee_bps` must equal 10000. Enforced at instantiation and `update_config`. +- **Validator addresses**: Must start with `injvaloper` and be reasonable length. Enforced at instantiation and `update_validators`. +- **Merkle root**: Must be exactly 64 hex characters (32 bytes). Validated in `take_snapshot`. +- **Reveal deadline**: Must be between 300 seconds (5 min) and 86400 seconds (24 hours). Enforced at instantiation and `update_config`. +- **Epoch duration**: `distribute_rewards` enforces that `epoch_duration_seconds` has elapsed since epoch start. +- **Snapshot overwrite**: Cannot overwrite a snapshot for an epoch that already has one. +- **Zero weight**: Snapshots with `total_weight = 0` are rejected at `commit_draw`. +- **Balance check**: `reveal_draw` verifies contract has sufficient balance before payout. +- **Min stake**: Stake amount must be >= `min_stake_amount` (configurable, 0 = no minimum). +- **Draw epoch**: `commit_draw` validates the epoch matches the latest snapshot epoch. + +## Contract Migration + +All three contracts support `migrate()` via `MigrateMsg {}` (empty message). Uses cw2 for contract name/version validation. + +--- + ## Known Design Trade-offs ### Draw Reveal Discretion (L-02) @@ -460,3 +511,20 @@ donates to all current stakers and is not a security risk. - Direct transfers are treated as additional staking rewards - The INJ is split according to BPS configuration (pools, treasury, base yield) - This is by design and allows for voluntary contributions to the reward pool + +### Re-staking Resets Eligibility (V2-I-01) + +Any new stake resets the user's epoch eligibility timer (`USER_STAKE_EPOCH`). +This means adding more INJ restarts the `min_epochs_regular` / `min_epochs_big` +countdown for draw eligibility. + +**Implications:** + +- Frontend should warn users that additional stakes reset their eligibility timer +- Users who want to remain eligible should avoid staking more until after a draw + +### No Minimum Stake Enforced by Default (V2-L-03) + +The `min_stake_amount` config defaults to 0 (no minimum). Dust stakes are allowed +since winning probability is proportional to stake weight, making the expected +value for tiny stakes negligible. Operators can set a minimum via `update_config`. diff --git a/README.md b/README.md index 11fdb07..7d4c1c0 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Optimized `.wasm` files output to `chance-staking/artifacts/`. ```bash cd chance-staking -cargo test # all tests +cargo test # all 96 tests cargo test -p chance-drand-oracle # oracle unit tests cargo test -p chance-staking-hub # staking hub unit tests cargo test -p chance-reward-distributor # distributor unit tests @@ -239,10 +239,23 @@ Chain: `injective-888` (Injective Testnet) ## Key Concepts - **csINJ**: Liquid staking token minted via Token Factory. Exchange rate starts at 1.0 and increases as base yield accrues: `rate = total_inj_backing / total_csinj_supply` -- **Epochs**: Time periods (configurable, default 24h) after which rewards are harvested and distributed -- **Merkle Tree**: Sorted-pair hashing (`sha256(min(left,right) || max(left,right))`) for snapshot verification. Leaf: `sha256(address_bytes || cumulative_start_be_u128 || cumulative_end_be_u128)` +- **Epochs**: Time periods (configurable, default 24h) after which rewards are harvested and distributed. Epoch duration is enforced on-chain. Users must be staked for `min_epochs_regular` / `min_epochs_big` epochs to be eligible for draws +- **Merkle Tree**: Sorted-pair hashing with domain separation. Leaf: `sha256(0x00 || address_bytes || cumulative_start_be_u128 || cumulative_end_be_u128)`. Internal: `sha256(0x01 || min(left,right) || max(left,right))` - **Commit-Reveal**: Two-phase draw to prevent manipulation. Operator commits before randomness is known, reveals after the drand beacon is available - **Unstaking**: 21-day unbonding period (Injective native). Users call `unstake` then `claim_unstaked` after the lock expires +- **Minimum Stake**: Configurable `min_stake_amount` per transaction (default 0 = no minimum). Re-staking resets the user's epoch eligibility timer +- **Contract Migration**: All three contracts support on-chain migration via `MigrateMsg {}` + +## Security Audits + +Two security audits have been completed with all 24 findings remediated: + +- **Audit V1**: 17 findings (2 critical, 5 high, 5 medium, 5 low) — all fixed +- **Audit V2**: 7 findings (3 medium, 3 low, 1 informational) — all fixed + +Key security improvements include: BPS sum validation, epoch duration enforcement, merkle domain separation, validator address validation, reveal deadline bounds, slashing detection (`sync_delegations`), snapshot overwrite prevention, and contract migration support. + +Full reports and fix tracking are in [`chance-staking/docs/`](chance-staking/docs/). ## License diff --git a/chance-staking/scripts/deploy_testnet.sh b/chance-staking/scripts/deploy_testnet.sh index 0fe0721..d3a2238 100755 --- a/chance-staking/scripts/deploy_testnet.sh +++ b/chance-staking/scripts/deploy_testnet.sh @@ -43,7 +43,7 @@ QUICKNET_GENESIS_TIME=1692803367 QUICKNET_PERIOD_SECONDS=3 # -- Reward distributor config -- -REVEAL_DEADLINE_SECONDS=180 +REVEAL_DEADLINE_SECONDS=300 EPOCHS_BETWEEN_REGULAR=1 EPOCHS_BETWEEN_BIG=6 @@ -66,9 +66,9 @@ STAKING_HUB_WASM="./artifacts/chance_staking_hub.wasm" # -- Optional: reuse existing code IDs (set to skip uploading) -- # If set, the script will skip storing that contract's wasm and use the given ID. # Leave empty to upload fresh. -EXISTING_DRAND_CODE_ID="" -EXISTING_DISTRIBUTOR_CODE_ID="" -EXISTING_STAKING_HUB_CODE_ID="" +EXISTING_DRAND_CODE_ID="39250" +EXISTING_DISTRIBUTOR_CODE_ID="39251" +EXISTING_STAKING_HUB_CODE_ID="39252" ################################################################################ # HELPERS # diff --git a/deployed.md b/deployed.md index cbb2297..8227e2f 100644 --- a/deployed.md +++ b/deployed.md @@ -1,13 +1,13 @@ # Testnet - drand-oracle code ID: 39244 + drand-oracle code ID: 39250 - drand-oracle address: inj12dg907vrnw3zdsh8hjvf4ywqky8gw7e3v7lwf7 + drand-oracle address: inj1jwztm5q5gnaq0jgt36v8wkskx6ryyul9nx4q6a - reward-distributor code ID: 39245 + reward-distributor code ID: 39251 - reward-distributor address: inj1thz9kqf74w4a8yakpx62xmnll3nf032rnnukyy + reward-distributor address: inj1pzl6p4el05lum6qd3h2e78gfsnaztll8g54fmr - staking-hub code ID: 39246 + staking-hub code ID: 39252 - staking-hub address: inj15vq83p8l6wl7qneulzgnt66dwheh2ecpprj0kn + staking-hub address: inj17l2r0vgfuv4sl6j2m47fhl8fypa6jezne5hdav diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34dc77b..ee6be82 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useState, useRef } from 'react' import { useStore } from './store/useStore' import Header from './components/Header' import HeroSection from './components/HeroSection' @@ -11,6 +11,54 @@ import HowItWorks from './components/HowItWorks' import Footer from './components/Footer' import ToastContainer from './components/Toast' import Confetti from './components/Confetti' +import StakingPage from './pages/StakingPage' +import DrawsPage from './pages/DrawsPage' +import HowItWorksPage from './pages/HowItWorksPage' +import ValidatorsPage from './pages/ValidatorsPage' +import DocsPage from './pages/DocsPage' +import ContractsPage from './pages/ContractsPage' +import AuditPage from './pages/AuditPage' +import TermsPage from './pages/TermsPage' +import PrivacyPage from './pages/PrivacyPage' + +// ── Router ── +type Route = + | { page: 'home'; anchor?: string } + | { page: 'stake' } + | { page: 'draws' } + | { page: 'draw-detail'; drawId: number } + | { page: 'how-it-works' } + | { page: 'validators' } + | { page: 'docs' } + | { page: 'contracts' } + | { page: 'audit' } + | { page: 'terms' } + | { page: 'privacy' } + +function parseRoute(hash: string): Route { + const h = hash || '' + // Page routes (with leading slash) + if (h === '#/' || h === '' || h === '#') return { page: 'home' } + if (h === '#/stake') return { page: 'stake' } + if (h === '#/draws') return { page: 'draws' } + if (h === '#/how-it-works') return { page: 'how-it-works' } + if (h === '#/validators') return { page: 'validators' } + if (h === '#/docs') return { page: 'docs' } + if (h === '#/contracts') return { page: 'contracts' } + if (h === '#/audit') return { page: 'audit' } + if (h === '#/terms') return { page: 'terms' } + if (h === '#/privacy') return { page: 'privacy' } + + // Draw detail: support #/draws/N and legacy #draw/N + const drawMatch = h.match(/^#\/?draws?\/(\d+)$/) + if (drawMatch) return { page: 'draw-detail', drawId: parseInt(drawMatch[1]) } + + // Section anchors on home page (#stake, #draws, #how-it-works, #portfolio) + const anchorMatch = h.match(/^#([a-z-]+)$/) + if (anchorMatch) return { page: 'home', anchor: anchorMatch[1] } + + return { page: 'home' } +} function App() { const isConnected = useStore((s) => s.isConnected) @@ -20,8 +68,31 @@ function App() { const fetchDraws = useStore((s) => s.fetchDraws) const fetchBalances = useStore((s) => s.fetchBalances) const fetchUserData = useStore((s) => s.fetchUserData) - const selectedDrawId = useStore((s) => s.selectedDrawId) - const selectDraw = useStore((s) => s.selectDraw) + + const [route, setRoute] = useState(() => parseRoute(window.location.hash)) + const prevPageRef = useRef(route.page) + + // Listen for hash changes + useEffect(() => { + const handleHashChange = () => { + setRoute(parseRoute(window.location.hash)) + } + window.addEventListener('hashchange', handleHashChange) + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + // Scroll to top when page changes; scroll to section anchor on home + useEffect(() => { + if (route.page !== prevPageRef.current) { + window.scrollTo(0, 0) + prevPageRef.current = route.page + } + if (route.page === 'home' && 'anchor' in route && route.anchor) { + setTimeout(() => { + document.getElementById(route.anchor!)?.scrollIntoView({ behavior: 'smooth' }) + }, 50) + } + }, [route]) // On mount: fetch global contract data + draws useEffect(() => { @@ -57,45 +128,49 @@ function App() { return () => clearInterval(interval) }, [isConnected]) - // Handle hash-based routing for draw detail pages - useEffect(() => { - const handleHashChange = () => { - const hash = window.location.hash - const match = hash.match(/^#draw\/(\d+)$/) - if (match) { - selectDraw(parseInt(match[1])) - } else if (selectedDrawId !== null && !hash.startsWith('#draw/')) { - // Only clear if we're navigating away from a draw page - useStore.setState({ selectedDrawId: null }) - } + const renderPage = () => { + switch (route.page) { + case 'stake': + return + case 'draws': + return + case 'draw-detail': + return + case 'how-it-works': + return + case 'validators': + return + case 'docs': + return + case 'contracts': + return + case 'audit': + return + case 'terms': + return + case 'privacy': + return + case 'home': + default: + return ( + <> + + + + {isConnected && } + + + + ) } - - handleHashChange() // Check on mount - window.addEventListener('hashchange', handleHashChange) - return () => window.removeEventListener('hashchange', handleHashChange) - }, []) + } return (
- - {selectedDrawId !== null ? ( -
- -
- ) : ( -
- - - - {isConnected && } - - -
- )} - +
{renderPage()}
) diff --git a/frontend/src/components/DrawDetail.tsx b/frontend/src/components/DrawDetail.tsx index af548a1..317bf93 100644 --- a/frontend/src/components/DrawDetail.tsx +++ b/frontend/src/components/DrawDetail.tsx @@ -64,10 +64,10 @@ export default function DrawDetail({ drawId }: { drawId: number }) { return (
-
+
-

Draw #{draw.id}

+

Draw #{draw.id}

{draw.revealed_at ? `Revealed ${timeAgo(draw.revealed_at)} · ${formatTimestamp(draw.revealed_at)}` diff --git a/frontend/src/components/DrawsSection.tsx b/frontend/src/components/DrawsSection.tsx index 365024d..cfcec36 100644 --- a/frontend/src/components/DrawsSection.tsx +++ b/frontend/src/components/DrawsSection.tsx @@ -19,7 +19,11 @@ function timeAgo(timestampNanos: string): string { return `${Math.floor(diff / 86400)} days ago` } -export default function DrawsSection() { +interface DrawsSectionProps { + fullPage?: boolean +} + +export default function DrawsSection({ fullPage = false }: DrawsSectionProps) { const recentDraws = useStore((s) => s.recentDraws) const regularPoolBalance = useStore((s) => s.regularPoolBalance) const bigPoolBalance = useStore((s) => s.bigPoolBalance) @@ -92,7 +96,7 @@ export default function DrawsSection() {
- Monthly draws + Weekly draws Weighted by balance @@ -182,7 +186,10 @@ export default function DrawsSection() { )} {/* Revealed draws list - scrollable */} -
+
{revealedDraws.length === 0 && committedDraws.length === 0 && (
No draws yet. Draws appear here once the first epoch completes. diff --git a/frontend/src/components/EpochCountdown.tsx b/frontend/src/components/EpochCountdown.tsx index e02a102..c04e287 100644 --- a/frontend/src/components/EpochCountdown.tsx +++ b/frontend/src/components/EpochCountdown.tsx @@ -2,7 +2,11 @@ import React, { useState, useEffect } from 'react' import { Timer } from 'lucide-react' import { useStore } from '../store/useStore' -export default function EpochCountdown() { +interface EpochCountdownProps { + compact?: boolean +} + +export default function EpochCountdown({ compact = false }: EpochCountdownProps) { const currentEpoch = useStore((s) => s.currentEpoch) const epochStartTime = useStore((s) => s.epochStartTime) const epochDurationSeconds = useStore((s) => s.epochDurationSeconds) @@ -38,8 +42,21 @@ export default function EpochCountdown() { const pad = (n: number) => String(n).padStart(2, '0') const isAlmostDone = progress > 90 + if (compact) { + return ( +
+
+ Epoch {currentEpoch} +
+
+ {remaining.d > 0 && `${remaining.d}d `}{pad(remaining.h)}h {pad(remaining.m)}m +
+
+ ) + } + return ( -
+
{remaining.d > 0 && (
- {remaining.d} + {remaining.d} d
)}
- {pad(remaining.h)} + {pad(remaining.h)} h
:
- {pad(remaining.m)} + {pad(remaining.m)} m
:
- {pad(remaining.s)} + {pad(remaining.s)} s
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 15e70ed..2f5e1fb 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -1,5 +1,10 @@ import React from 'react' import { Sparkles, Github, ExternalLink } from 'lucide-react' +import { CONTRACTS, NETWORK } from '../config' + +const explorerBase = (NETWORK as string).includes('mainnet') + ? 'https://explorer.injective.network' + : 'https://testnet.explorer.injective.network' export default function Footer() { return ( @@ -20,21 +25,22 @@ export default function Footer() {

Community

- Discord - Twitter - + {/* Discord + Twitter */} + GitHub @@ -47,9 +53,9 @@ export default function Footer() { 2025 Chance.Staking. Built on Injective. diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 85d6222..aa322d2 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,13 +1,22 @@ import React, { useState, useEffect, useRef } from 'react' -import { Sparkles, ChevronDown, Wallet, LogOut, Copy, Check } from 'lucide-react' +import { Sparkles, ChevronDown, Wallet, LogOut, Copy, Check, Menu, X } from 'lucide-react' import { useStore } from '../store/useStore' import type { WalletType } from '../store/useStore' +const navLinks = [ + { href: '#/stake', label: 'Stake' }, + { href: '#/draws', label: 'Draws' }, + { href: '#/how-it-works', label: 'How It Works' }, + { href: '#/validators', label: 'Validators' }, +] + export default function Header() { const { isConnected, address, injectiveAddress, walletType, isConnecting, connect, disconnect } = useStore() const [showWalletMenu, setShowWalletMenu] = useState(false) const [showAccountMenu, setShowAccountMenu] = useState(false) + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [copied, setCopied] = useState(false) + const [currentHash, setCurrentHash] = useState(window.location.hash) const walletRef = useRef(null) const accountRef = useRef(null) @@ -22,6 +31,13 @@ export default function Header() { setTimeout(() => setCopied(false), 2000) } + // Track current hash for active nav highlighting + useEffect(() => { + const onHash = () => setCurrentHash(window.location.hash) + window.addEventListener('hashchange', onHash) + return () => window.removeEventListener('hashchange', onHash) + }, []) + // Close dropdowns on outside click useEffect(() => { const handler = (e: MouseEvent) => { @@ -32,6 +48,27 @@ export default function Header() { return () => document.removeEventListener('mousedown', handler) }, []) + // Lock body scroll when mobile menu is open + useEffect(() => { + if (mobileMenuOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { document.body.style.overflow = '' } + }, [mobileMenuOpen]) + + // Close mobile menu on resize to desktop + useEffect(() => { + const handleResize = () => { + if (window.innerWidth > 768 && mobileMenuOpen) { + setMobileMenuOpen(false) + } + } + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, [mobileMenuOpen]) + const wallets: { id: WalletType; name: string; icon: string }[] = [ { id: 'keplr', name: 'Keplr', icon: '🔑' }, { id: 'leap', name: 'Leap', icon: '🦘' }, @@ -39,115 +76,262 @@ export default function Header() { { id: 'rabby', name: 'Rabby', icon: '🐰' }, ] + const handleNavClick = () => { + setMobileMenuOpen(false) + } + return ( -
-
- + <> +
+
+ +
+
+ +
+
+ Chance + . + Staking +
+
+
+ + {/* Desktop nav */} + + + {/* Desktop wallet section */} +
+ {!isConnected ? ( +
+ + {showWalletMenu && ( +
+ {wallets.map((w) => ( + + ))} +
+ )} +
+ ) : ( +
+ + {showAccountMenu && ( +
+
+ Connected via {walletType} + + {truncateAddress(injectiveAddress || address)} + +
+ + +
+ )} +
+ )} +
+ + {/* Mobile burger button */} + +
+
+ + {/* Mobile slide-out overlay */} + {mobileMenuOpen && ( +
setMobileMenuOpen(false)} + /> + )} + + {/* Mobile slide-out drawer */} +
+
- +
- Chance - . - Staking + Chance + . + Staking
- + +
-
+ ) } @@ -337,4 +521,150 @@ const styles: Record = { cursor: 'pointer', transition: 'all 0.15s', }, + + // Burger button (hidden on desktop via CSS) + burgerButton: { + display: 'none', + alignItems: 'center', + justifyContent: 'center', + width: 40, + height: 40, + borderRadius: 10, + background: 'transparent', + border: '1px solid #2A2A38', + color: '#F0F0F5', + cursor: 'pointer', + transition: 'border-color 0.2s', + }, + + // Mobile overlay + mobileOverlay: { + position: 'fixed' as const, + inset: 0, + background: 'rgba(0, 0, 0, 0.6)', + zIndex: 150, + backdropFilter: 'blur(4px)', + animation: 'fadeIn 0.2s ease-out', + }, + + // Mobile drawer + mobileDrawer: { + position: 'fixed' as const, + top: 0, + right: 0, + bottom: 0, + width: 300, + maxWidth: 'calc(100vw - 48px)', + background: '#13131a', + borderLeft: '1px solid #2A2A38', + zIndex: 200, + display: 'flex', + flexDirection: 'column' as const, + transition: 'transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)', + overflowY: 'auto' as const, + }, + + mobileDrawerHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '16px 20px', + borderBottom: '1px solid rgba(42, 42, 56, 0.5)', + }, + + closeButton: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 36, + height: 36, + borderRadius: 10, + background: 'transparent', + border: '1px solid #2A2A38', + color: '#8E8EA0', + cursor: 'pointer', + transition: 'all 0.2s', + }, + + mobileNav: { + display: 'flex', + flexDirection: 'column' as const, + padding: '16px 12px', + gap: 2, + }, + + mobileNavLink: { + display: 'flex', + alignItems: 'center', + padding: '14px 16px', + borderRadius: 12, + fontSize: 15, + fontWeight: 500, + color: '#C8C8D4', + textDecoration: 'none', + transition: 'all 0.15s', + }, + + mobileDrawerDivider: { + height: 1, + background: 'rgba(42, 42, 56, 0.5)', + margin: '4px 20px', + }, + + mobileWalletSection: { + padding: '16px 20px', + }, + + mobileWalletLabel: { + display: 'block', + fontSize: 12, + fontWeight: 600, + color: '#8E8EA0', + textTransform: 'uppercase' as const, + letterSpacing: '0.06em', + marginBottom: 12, + }, + + mobileWalletGrid: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: 8, + }, + + mobileWalletOption: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + gap: 6, + padding: '16px 8px', + borderRadius: 12, + background: '#1A1A22', + border: '1px solid #2A2A38', + cursor: 'pointer', + transition: 'all 0.2s', + }, + + mobileAccountCard: { + padding: '14px 16px', + borderRadius: 12, + background: '#1A1A22', + border: '1px solid #2A2A38', + }, + + mobileActionButton: { + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + padding: '10px 12px', + borderRadius: 10, + background: 'transparent', + border: '1px solid #2A2A38', + color: '#8E8EA0', + fontSize: 13, + fontWeight: 500, + cursor: 'pointer', + transition: 'all 0.2s', + }, } diff --git a/frontend/src/components/HeroSection.tsx b/frontend/src/components/HeroSection.tsx index 12eb02c..60eed90 100644 --- a/frontend/src/components/HeroSection.tsx +++ b/frontend/src/components/HeroSection.tsx @@ -41,7 +41,7 @@ export default function HeroSection() { Start Staking - + diff --git a/frontend/src/components/HowItWorks.tsx b/frontend/src/components/HowItWorks.tsx index 543b05f..ada2dd1 100644 --- a/frontend/src/components/HowItWorks.tsx +++ b/frontend/src/components/HowItWorks.tsx @@ -38,7 +38,7 @@ const steps = [ const distributions = [ { label: 'Regular Draws', pct: 70, color: '#8B6FFF', gradient: 'linear-gradient(90deg, #8B6FFF, #6B4FD6)' }, - { label: 'Big Monthly Draw', pct: 20, color: '#f472b6', gradient: 'linear-gradient(90deg, #f472b6, #ec4899)' }, + { 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' }, ] diff --git a/frontend/src/components/RewardsCalculator.tsx b/frontend/src/components/RewardsCalculator.tsx index b4a5e01..7c130bf 100644 --- a/frontend/src/components/RewardsCalculator.tsx +++ b/frontend/src/components/RewardsCalculator.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { Calculator, TrendingUp, Trophy, Sparkles, Landmark, Dices, Shield, Target, Zap } from 'lucide-react' +import { Calculator, TrendingUp, Trophy, Sparkles, Landmark, Dices, Shield, Target, Zap, CloudRain } from 'lucide-react' import { useStore } from '../store/useStore' import { formatNumber } from '../utils/formatNumber' @@ -14,6 +14,9 @@ export default function RewardsCalculator() { const minEpochsRegular = useStore((s) => s.minEpochsRegular) const minEpochsBig = useStore((s) => s.minEpochsBig) const snapshotNumHolders = useStore((s) => s.snapshotNumHolders) + const csinjBalance = useStore((s) => s.csinjBalance) + const exchangeRate = useStore((s) => s.exchangeRate) + const isConnected = useStore((s) => s.isConnected) const [stakeAmount, setStakeAmount] = useState('1000') const [apr, setApr] = useState('15') @@ -38,6 +41,15 @@ export default function RewardsCalculator() { const protocolFee = totalBps > 0 ? annualRewards * (protocolFeeBps / totalBps) : 0 const effectiveBaseApr = stake > 0 ? (baseYield / stake) * 100 : 0 + // ── Connected wallet position ── + const walletCsinjHuman = parseFloat(csinjBalance) / 1e18 + const rate = parseFloat(exchangeRate) || 1 + const walletInjValue = walletCsinjHuman * rate + const hasWalletPosition = isConnected && walletInjValue > 0.01 + + // Use wallet position when connected, otherwise fall back to calculator input + const oddsStake = hasWalletPosition ? walletInjValue : stake + // ── Prize draw probability calculations ── const totalInjBackingHuman = parseFloat(totalInjBacking) / 1e18 @@ -46,7 +58,7 @@ export default function RewardsCalculator() { const bigDrawsPerYear = epochsPerYear / Math.max(minEpochsBig, 1) // Include user's hypothetical stake in the pool for realistic prize sizes - const poolWithUser = totalInjBackingHuman + stake + const poolWithUser = hasWalletPosition ? totalInjBackingHuman : totalInjBackingHuman + stake const totalSysRewards = poolWithUser * (aprPct / 100) const regPoolAnnual = totalBps > 0 ? totalSysRewards * (regularPoolBps / totalBps) : 0 const bigPoolAnnual = totalBps > 0 ? totalSysRewards * (bigPoolBps / totalBps) : 0 @@ -54,10 +66,11 @@ export default function RewardsCalculator() { const bigPrizePerDraw = bigDrawsPerYear > 0 ? bigPoolAnnual / bigDrawsPerYear : 0 // Win probability per draw: user's share of total pool - const winProbPerDraw = poolWithUser > 0 && stake > 0 ? stake / poolWithUser : 0 + const winProbPerDraw = poolWithUser > 0 && oddsStake > 0 ? oddsStake / poolWithUser : 0 // Annual prize distribution using normal approximation of binomial // Each draw is a Bernoulli trial: win with prob p, prize = prizePerDraw + const oddsBaseYield = totalBps > 0 ? oddsStake * (aprPct / 100) * (baseYieldBps / totalBps) : 0 const expectedRegTotal = regularDrawsPerYear * winProbPerDraw * regPrizePerDraw const expectedBigTotal = bigDrawsPerYear * winProbPerDraw * bigPrizePerDraw const expectedTotalPrize = expectedRegTotal + expectedBigTotal @@ -67,22 +80,29 @@ export default function RewardsCalculator() { const bigVar = bigDrawsPerYear * winProbPerDraw * (1 - winProbPerDraw) * bigPrizePerDraw ** 2 const totalPrizeStdDev = Math.sqrt(regVar + bigVar) - // Scenario payouts (annual) - const expectedPayout = baseYield + expectedTotalPrize - const luckyPayout = baseYield + expectedTotalPrize + 1.28 * totalPrizeStdDev - const jackpotPayout = baseYield + expectedTotalPrize + 2.33 * totalPrizeStdDev + // Scenario payouts (annual) — all based on oddsStake + const floorPayout = oddsBaseYield + const expectedPayout = oddsBaseYield + expectedTotalPrize + const unluckyPayout = Math.max(oddsBaseYield, oddsBaseYield + expectedTotalPrize - 1.28 * totalPrizeStdDev) + const luckyPayout = oddsBaseYield + expectedTotalPrize + 1.28 * totalPrizeStdDev + const jackpotPayout = oddsBaseYield + expectedTotalPrize + 2.33 * totalPrizeStdDev // Scenario APRs - const expectedApr = stake > 0 ? (expectedPayout / stake) * 100 : 0 - const luckyApr = stake > 0 ? (luckyPayout / stake) * 100 : 0 - const jackpotApr = stake > 0 ? (jackpotPayout / stake) * 100 : 0 + const floorApr = oddsStake > 0 ? (floorPayout / oddsStake) * 100 : 0 + const expectedApr = oddsStake > 0 ? (expectedPayout / oddsStake) * 100 : 0 + const unluckyApr = oddsStake > 0 ? (unluckyPayout / oddsStake) * 100 : 0 + const luckyApr = oddsStake > 0 ? (luckyPayout / oddsStake) * 100 : 0 + const jackpotApr = oddsStake > 0 ? (jackpotPayout / oddsStake) * 100 : 0 // Show variance scenarios only when there's meaningful spread - const hasVariance = totalPrizeStdDev > 0.05 * Math.max(expectedTotalPrize, 0.01) + const hasVariance = totalPrizeStdDev > 0.0005 * Math.max(expectedTotalPrize, 0.01) + + // Pool share percentage + const poolSharePct = poolWithUser > 0 && oddsStake > 0 ? (oddsStake / poolWithUser) * 100 : 0 const formatPct = (p: number): string => { const pct = p * 100 - if (pct >= 10) return `${pct.toFixed(0)}%` + if (pct >= 10) return `${pct.toFixed(2)}%` if (pct >= 1) return `${pct.toFixed(1)}%` if (pct >= 0.1) return `${pct.toFixed(2)}%` if (pct >= 0.01) return `${pct.toFixed(3)}%` @@ -93,11 +113,21 @@ export default function RewardsCalculator() { { label: 'Guaranteed Floor', desc: 'Base yield only — no wins needed', - apr: effectiveBaseApr, - payout: baseYield, + apr: floorApr, + payout: floorPayout, color: '#22c55e', icon: , }, + ...(hasVariance ? [ + { + label: 'Unlucky Year', + desc: 'Bottom 10% of outcomes', + apr: unluckyApr, + payout: unluckyPayout, + color: '#f87171', + icon: , + }, + ] : []), { label: 'Typical Year', desc: 'Expected mathematical average', @@ -176,7 +206,7 @@ export default function RewardsCalculator() {
-
+
{/* Inputs */}
@@ -286,38 +316,111 @@ export default function RewardsCalculator() {
{/* Prize Draw Odds */} - {stake > 0 && ( + {oddsStake > 0 && (
-
Prize Draw Odds
+
Your Prize Draw Odds
- How lucky draws could boost your returns - {snapshotNumHolders > 0 && ` · ${snapshotNumHolders} current stakers`} + {hasWalletPosition + ? `Based on your ${formatNumber(walletCsinjHuman, 2)} csINJ (${formatNumber(walletInjValue, 2)} INJ)` + : `Based on ${formatNumber(stake, 0)} INJ simulated stake`} + {snapshotNumHolders > 0 && ` · ${snapshotNumHolders} stakers in pool`}
-
+ {/* Pool position stats */} +
+
+
{formatNumber(poolSharePct, poolSharePct < 1 ? 3 : 2)}%
+
Your pool share
+
{formatPct(winProbPerDraw)}
Win chance / draw
-
{formatNumber(regPoolAnnual, 1)} INJ
-
Regular prizes / yr
+
~{formatNumber(regPrizePerDraw, 1)} INJ
+
Per regular prize
-
{formatNumber(bigPoolAnnual, 1)} INJ
-
Big prizes / yr
+
~{formatNumber(bigPrizePerDraw, 1)} INJ
+
Per big prize
+ {/* Outcome spectrum bar */} + {hasVariance && ( +
+
Annual outcome range
+
+ {(() => { + const min = floorPayout + const max = jackpotPayout + const range = max - min || 1 + const markers = [ + { payout: floorPayout, color: '#22c55e', label: 'Floor' }, + { payout: unluckyPayout, color: '#f87171', label: 'Unlucky' }, + { payout: expectedPayout, color: '#60a5fa', label: 'Typical' }, + { payout: luckyPayout, color: '#8B6FFF', label: 'Lucky' }, + { payout: jackpotPayout, color: '#fbbf24', label: 'Jackpot' }, + ] + return ( + <> + {/* Gradient bar */} +
+ {/* Filled portion up to expected */} +
+ {/* Markers */} + {markers.map((m) => ( +
+ ))} + + ) + })()} +
+
+ + {formatNumber(floorPayout, 1)} INJ + + + {formatNumber(jackpotPayout, 1)} INJ + +
+
+ )} + + {/* Scenario rows */}
{oddsScenarios.map((s) => (
@@ -343,11 +446,13 @@ export default function RewardsCalculator() {
- Normal staking: {formatNumber(aprPct, 1)}% APR. Your expected return - {' '}≈ chain APR minus the {formatNumber((protocolFeeBps / totalBps) * 100, 1)}% protocol fee. + {hasWalletPosition + ? `Your ${formatNumber(poolSharePct, poolSharePct < 1 ? 3 : 2)}% pool share gives you a ${formatPct(winProbPerDraw)} chance to win each draw.` + : `Normal staking: ${formatNumber(aprPct, 1)}% APR.`} + {' '}Guaranteed floor is base yield with zero wins. {hasVariance - ? ' Prize draws add variance — you could earn less or significantly more than normal staking.' - : ' As the pool grows with more stakers, individual prizes get larger and outcomes diverge — that\'s where the real excitement comes from.'} + ? ` In an unlucky year you\'d still beat the floor. In a lucky year, prize draws can significantly boost your returns.` + : ' As the pool grows with more stakers, individual prizes get larger and outcomes diverge.'}
)} @@ -563,7 +668,7 @@ const styles: Record = { }, statsRow: { display: 'grid', - gridTemplateColumns: '1fr 1fr 1fr', + gridTemplateColumns: 'repeat(4, 1fr)', gap: 8, marginBottom: 16, }, @@ -598,4 +703,27 @@ const styles: Record = { background: '#0F0F13', borderRadius: 8, }, + spectrumContainer: { + marginBottom: 16, + }, + spectrumLabel: { + fontSize: 10, + color: '#8E8EA0', + textTransform: 'uppercase' as const, + letterSpacing: '0.06em', + fontWeight: 500, + marginBottom: 8, + }, + spectrumTrack: { + position: 'relative' as const, + height: 8, + borderRadius: 4, + background: '#0F0F13', + overflow: 'visible', + }, + spectrumLabels: { + display: 'flex', + justifyContent: 'space-between', + marginTop: 6, + }, } diff --git a/frontend/src/config.ts b/frontend/src/config.ts index b8f63dd..108b336 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -8,9 +8,9 @@ export const EVM_CHAIN_ID = EvmChainId.Injective; export const ENDPOINTS = getNetworkEndpoints(NETWORK); export const CONTRACTS = { - drandOracle: "inj125aaphw8dgut3d4ju3myqmyel76jc4tsccnstw", - rewardDistributor: "inj184vlqxmfjsva9hewmj9ddqkvl5kdmcjetk94hy", - stakingHub: "inj1n2pvkp3mcslsydq8uvxcrp5jeyerqmkqxucm2e", + drandOracle: "inj1jwztm5q5gnaq0jgt36v8wkskx6ryyul9nx4q6a", + rewardDistributor: "inj1pzl6p4el05lum6qd3h2e78gfsnaztll8g54fmr", + stakingHub: "inj17l2r0vgfuv4sl6j2m47fhl8fypa6jezne5hdav", } as const; export const INJ_DECIMALS = 18; diff --git a/frontend/src/index.css b/frontend/src/index.css index 2b34839..e75d24b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -214,19 +214,38 @@ input:focus, textarea:focus { 100% { transform: translateX(-50%); } } +/* ═══ Mobile burger button — hidden on desktop ═══ */ +.header-burger { + display: none !important; +} + +/* ═══ Mobile nav drawer hover states ═══ */ +.mobile-nav-drawer a:hover, +.mobile-nav-drawer a:active { + background: rgba(139, 111, 255, 0.08); + color: #F0F0F5; +} + +.mobile-nav-drawer button:hover { + border-color: rgba(139, 111, 255, 0.3); +} + /* ═══ Mobile Responsive ═══ */ @media (max-width: 768px) { - /* Header */ + /* Header — hide desktop nav + wallet, show burger */ .header-inner { padding: 0 16px !important; height: 60px !important; } - .header-nav { - gap: 16px !important; + .header-nav-desktop { + display: none !important; + } + .header-wallet-desktop { + display: none !important; } - .header-nav a { - font-size: 12px !important; + .header-burger { + display: flex !important; } /* Hero */ @@ -238,6 +257,7 @@ input:focus, textarea:focus { } .hero-subtitle { font-size: 15px !important; + max-width: 100% !important; } .hero-stats-row { flex-direction: column !important; @@ -276,6 +296,15 @@ input:focus, textarea:focus { grid-template-columns: 1fr !important; } + /* Rewards Calculator */ + .rewards-calc-layout { + grid-template-columns: 1fr !important; + } + .rewards-calc-stats-row { + grid-template-columns: 1fr 1fr !important; + gap: 8px !important; + } + /* How it Works */ .hiw-steps-grid { flex-direction: column !important; @@ -285,7 +314,9 @@ input:focus, textarea:focus { display: none !important; } .hiw-step-card { - max-width: 320px !important; + max-width: 100% !important; + width: 100% !important; + flex: 1 1 100% !important; } .hiw-title { font-size: 28px !important; @@ -315,6 +346,23 @@ input:focus, textarea:focus { flex-direction: column !important; gap: 12px !important; } + .draw-detail-container { + padding: 0 16px !important; + } + .draw-detail-title { + font-size: 24px !important; + } + .draw-detail-container .verify-step-title { + flex-wrap: wrap !important; + } + + /* Epoch countdown */ + .epoch-countdown-wrapper { + padding: 12px 12px !important; + } + .epoch-unit-value { + font-size: 20px !important; + } } @media (max-width: 480px) { @@ -327,10 +375,15 @@ input:focus, textarea:focus { } .hero-cta-row { flex-direction: column !important; - align-items: stretch !important; + align-items: center !important; + } + .hero-cta-row a { + width: 100% !important; + display: block !important; } .hero-cta-primary, .hero-cta-secondary { justify-content: center !important; + width: 100% !important; } .footer-links-grid { flex-direction: column !important; @@ -341,3 +394,95 @@ input:focus, textarea:focus { text-align: center !important; } } + +/* ── New pages responsive ── */ +@media (max-width: 768px) { + /* Validators */ + .validators-grid { + grid-template-columns: 1fr !important; + } + .validators-page-stats { + flex-direction: column !important; + align-items: center !important; + } + /* How It Works page */ + .hiw-page-math-steps { + grid-template-columns: 1fr !important; + } + .hiw-page-security-grid { + grid-template-columns: 1fr !important; + } + .hiw-page-timing-grid { + grid-template-columns: 1fr !important; + } + .hiw-page-steps { + flex-direction: column !important; + align-items: center !important; + } + .hiw-page-step-arrow { + transform: rotate(90deg); + padding-top: 0 !important; + padding: 4px 0 !important; + } + .hiw-page-timeline { + flex-direction: column !important; + align-items: flex-start !important; + gap: 8px !important; + } + .hiw-page-split-legend { + flex-direction: column !important; + align-items: center !important; + } + /* Staking page */ + .staking-page-stats { + flex-direction: column !important; + align-items: center !important; + } + /* Draws page */ + .draws-page-stats { + flex-direction: column !important; + align-items: center !important; + } + /* Docs page */ + .docs-layout { + flex-direction: column !important; + } + .docs-sidebar { + position: static !important; + width: 100% !important; + max-height: none !important; + border-right: none !important; + border-bottom: 1px solid #2A2A38 !important; + padding-right: 0 !important; + padding-bottom: 16px !important; + margin-bottom: 24px !important; + display: none !important; + } + .docs-sidebar.open { + display: flex !important; + } + .docs-sidebar-toggle { + display: flex !important; + } + /* Contracts page */ + .contracts-stats { + flex-direction: column !important; + align-items: center !important; + } + .contracts-query-buttons { + flex-wrap: wrap !important; + } + /* Audit page */ + .audit-stats { + flex-direction: column !important; + align-items: center !important; + } + .audit-finding-header { + flex-direction: column !important; + align-items: flex-start !important; + gap: 8px !important; + } + .audit-finding-right { + flex-wrap: wrap !important; + } +} diff --git a/frontend/src/pages/AuditPage.tsx b/frontend/src/pages/AuditPage.tsx new file mode 100644 index 0000000..cba1aff --- /dev/null +++ b/frontend/src/pages/AuditPage.tsx @@ -0,0 +1,869 @@ +import React, { useState } from 'react' +import { + Shield, CheckCircle, FileCheck, Layers, ChevronDown, + AlertTriangle, Info +} from 'lucide-react' + +// ── Severity styling ── +const severityColors: Record = { + critical: { bg: 'rgba(239, 68, 68, 0.12)', text: '#ef4444' }, + high: { bg: 'rgba(245, 158, 11, 0.12)', text: '#f59e0b' }, + medium: { bg: 'rgba(139, 111, 255, 0.12)', text: '#8B6FFF' }, + low: { bg: 'rgba(56, 189, 248, 0.12)', text: '#38bdf8' }, + informational: { bg: 'rgba(142, 142, 160, 0.12)', text: '#8E8EA0' }, +} + +// ── V1 Findings ── +interface Finding { + id: string + severity: string + contract: string + title: string + description: string + impact: string + fix: string +} + +const v1Findings: Finding[] = [ + { + id: 'C-01', severity: 'critical', contract: 'staking-hub', + title: 'Base yield INJ double-counted across epochs', + description: 'Base yield added to backing but never delegated to validators. The INJ remained in the contract balance and got re-distributed as "new" rewards in subsequent epochs, compounding over time.', + impact: 'Exchange rate would appreciate faster than actual staking yields, creating an undercollateralized system. Late unstakers could find insufficient INJ.', + fix: 'Base yield is now delegated to validators in distribute_rewards, removing it from the contract balance.', + }, + { + id: 'C-02', severity: 'critical', contract: 'staking-hub', + title: 'BPS invariant not enforced in update_config', + description: 'During instantiation, BPS fields were validated to sum to 10000, but update_config only checked individual fields. The sum could become invalid after updates.', + impact: 'Sum > 10000 would cause every epoch advancement to fail. Sum < 10000 would cause undistributed rewards to double-count.', + fix: 'BPS sum is now validated in update_config. All four BPS fields can be updated atomically.', + }, + { + id: 'H-01', severity: 'high', contract: 'staking-hub', + title: 'No epoch duration enforcement', + description: 'The EpochNotReady error was defined but never used. The operator could advance epochs as fast as blocks are produced.', + impact: 'A compromised operator could advance epochs rapidly, manipulating draw eligibility and timing.', + fix: 'Time check now enforced in distribute_rewards — epoch_duration_seconds must have elapsed since epoch start.', + }, + { + id: 'H-02', severity: 'high', contract: 'reward-distributor', + title: 'commit_draw does not validate epoch is current', + description: 'The operator could commit draws using snapshots from any past epoch, not just the current one.', + impact: 'An operator could use old snapshots where a particular user had a larger weight, manipulating win probability.', + fix: 'LATEST_SNAPSHOT_EPOCH is tracked and validated at commit time. Draws can only use the most recent snapshot.', + }, + { + id: 'H-03', severity: 'high', contract: 'reward-distributor', + title: 'Zero total_weight snapshot causes division-by-zero panic', + description: 'If a snapshot has total_weight = 0, the winning ticket computation (% 0) panics in Rust. A committed draw with a zero-weight snapshot could never be revealed.', + impact: 'Draw funds locked until the reveal deadline expires. Repeated commits could create a temporary DoS.', + fix: 'Zero total_weight is now rejected at commit_draw time.', + }, + { + id: 'H-04', severity: 'high', contract: 'staking-hub', + title: 'Silent underflow in unstake accounting', + description: 'When unstaking, the backing was computed with checked_sub().unwrap_or(zero) — silently setting backing to zero on underflow instead of erroring.', + impact: 'Masked underlying accounting bugs. If backing reached zero with non-zero supply, the exchange rate would break.', + fix: 'Replaced unwrap_or(zero) with proper error propagation via ContractError::InsufficientBalance.', + }, + { + id: 'H-05', severity: 'high', contract: 'staking-hub', + title: 'No slashing detection or accounting', + description: 'TOTAL_INJ_BACKING was tracked independently from actual validator delegations. Slashing events would reduce real delegations but not the tracked backing.', + impact: 'Exchange rate would overstate real backing. Last unstakers could find insufficient INJ.', + fix: 'sync_delegations function added — operator can reconcile backing with actual validator delegations after slashing.', + }, + { + id: 'M-01', severity: 'medium', contract: 'reward-distributor', + title: 'Snapshots can be overwritten after draw commit', + description: 'set_snapshot unconditionally saved for a given epoch, with no check for existing snapshots or committed draws.', + impact: 'If set_snapshot was called twice for the same epoch, committed draws would become un-revealable.', + fix: 'Duplicate check added — cannot overwrite a snapshot for an epoch that already has one.', + }, + { + id: 'M-02', severity: 'medium', contract: 'common', + title: 'No domain separation in merkle tree hashing', + description: 'Leaf and internal node hashes both used plain SHA-256 without domain separation prefixes. Vulnerable to second-preimage attacks in theory.', + impact: 'An attacker could potentially craft a leaf whose hash collides with an internal node.', + fix: 'Domain separation added: leaf prefix 0x00, internal node prefix 0x01.', + }, + { + id: 'M-03', severity: 'medium', contract: 'all', + title: 'No migrate entry point on any contract', + description: 'None of the three contracts implemented a migrate entry point. Contracts could not be upgraded after deployment.', + impact: 'Any post-deployment bug would require deploying entirely new contracts and migrating all state manually.', + fix: 'All 3 contracts now have migrate() with cw2 version validation.', + }, + { + id: 'M-04', severity: 'medium', contract: 'staking-hub', + title: 'Validator addresses stored as unvalidated strings', + description: 'Validator addresses were not validated as proper bech32 addresses during instantiation or update_validators.', + impact: 'Invalid validator addresses would cause delegation messages to fail, effectively bricking the contract.', + fix: '"injvaloper" prefix and length checks are now enforced on all validator addresses.', + }, + { + id: 'M-05', severity: 'medium', contract: 'staking-hub', + title: 'distribute_rewards does not re-stake base yield', + description: 'Base yield was added to TOTAL_INJ_BACKING but the actual INJ remained undelegated. Over time, actual delegations diverged from tracked backing.', + impact: 'Undelegation messages could request more than what was actually delegated, causing unstake failures.', + fix: 'Base yield is now delegated to validators using the same round-robin distribution as stake.', + }, + { + id: 'L-01', severity: 'low', contract: 'drand-oracle', + title: 'No admin rotation mechanism', + description: 'The drand-oracle had no update_admin function. The admin was permanently set at instantiation.', + impact: 'If the admin key was compromised or lost, the operator list could never be updated.', + fix: 'UpdateAdmin message added, gated to the current admin.', + }, + { + id: 'L-02', severity: 'low', contract: 'reward-distributor', + title: 'Operator has free option to abort unfavorable draws', + description: 'After committing to a draw, the operator can see the result before revealing. Unfavorable draws can be let to expire with no penalty.', + impact: 'Operator can bias outcomes by selectively revealing only favorable results. Funds return to pool, not stolen.', + fix: 'Acknowledged design trade-off. Future versions may implement operator bonding or public reveals.', + }, + { + id: 'L-03', severity: 'low', contract: 'staking-hub', + title: 'Direct INJ transfers inflate next epoch\'s rewards', + description: 'Any INJ sent directly to the contract (outside staking flow) is treated as staking rewards in the next epoch.', + impact: 'Could be used to manipulate exchange rate or prize pool sizes. However, this effectively donates to stakers.', + fix: 'Documented as an intentional feature — allows voluntary contributions to the reward pool.', + }, + { + id: 'L-04', severity: 'low', contract: 'staking-hub', + title: 'Rounding dust from BPS calculations accumulates', + description: 'Each BPS share was calculated independently with multiply_ratio. The four amounts might not sum exactly to total_rewards.', + impact: 'Negligible per epoch but accumulates over time. More of a correctness issue than security.', + fix: 'Treasury fee is now calculated as the remainder after other shares, eliminating rounding dust.', + }, + { + id: 'L-05', severity: 'low', contract: 'reward-distributor', + title: 'No check that contract balance covers reward payout', + description: 'reveal_draw did not verify the contract held sufficient INJ before attempting to send the reward.', + impact: 'If tracked pool balances diverged from actual INJ held, the reveal transaction would fail.', + fix: 'Contract balance is now verified before reward payout in reveal_draw.', + }, +] + +const v2Findings: Finding[] = [ + { + id: 'V2-M-01', severity: 'medium', contract: 'staking-hub', + title: 'BpsSumMismatch error truncates total to u16', + description: 'The BpsSumMismatch error stored the total field as u16, but BPS sum was computed as u32. Values exceeding 65535 would display truncated in error messages.', + impact: 'Misleading error messages complicate debugging. The logic check itself was correct.', + fix: 'Changed total field from u16 to u32 and removed the truncating cast.', + }, + { + id: 'V2-M-02', severity: 'medium', contract: 'staking-hub', + title: 'sync_delegations doesn\'t update EPOCH_STATE.total_staked', + description: 'When reconciling after slashing, sync_delegations updated TOTAL_INJ_BACKING but not EPOCH_STATE.total_staked. The epoch_state query returned stale TVL data.', + impact: 'Frontend would display incorrect total staked amount after a slashing event.', + fix: 'EPOCH_STATE.total_staked is now also updated in sync_delegations.', + }, + { + id: 'V2-M-03', severity: 'medium', contract: 'reward-distributor', + title: 'No validation on reveal_deadline_seconds', + description: 'reveal_deadline_seconds could be set to any value — zero (draws immediately expirable) or extremely high (funds locked indefinitely).', + impact: 'Misconfiguration could permanently lock pool funds or make draws impossible to complete.', + fix: 'Bounds added: minimum 300 seconds (5 min), maximum 86400 seconds (24 hours). Enforced in both instantiate and update_config.', + }, + { + id: 'V2-L-01', severity: 'low', contract: 'staking-hub', + title: 'take_snapshot doesn\'t validate merkle_root format', + description: 'The merkle_root parameter was stored without validating it was valid hex encoding or the correct length (64 chars / 32 bytes).', + impact: 'An invalid merkle root would silently break draw reveals for the affected epoch.', + fix: 'Validation added: merkle_root must be valid hex and exactly 64 characters.', + }, + { + id: 'V2-L-02', severity: 'low', contract: 'staking-hub', + title: 'Treasury fee calculation uses silent fallback', + description: 'The remainder calculation used chained checked_sub().unwrap_or(zero) instead of explicit saturating_sub. While current math is correct, the pattern could mask future bugs.', + impact: 'Low risk — current math is sound. Concern is about maintainability.', + fix: 'Changed to saturating_sub chain to make the intent explicit.', + }, + { + id: 'V2-L-03', severity: 'low', contract: 'staking-hub', + title: 'No minimum stake amount enforced', + description: 'Users could stake as little as 1 wei of INJ. Dust stakes have negligible winning probability but add minor overhead.', + impact: 'No direct security risk. Winning probability is proportional to stake weight.', + fix: 'Configurable min_stake_amount added (default 0 = no minimum). Operators can set a minimum via update_config.', + }, + { + id: 'V2-I-01', severity: 'informational', contract: 'staking-hub', + title: 'Re-staking resets eligibility clock entirely', + description: 'Any new stake overwrites USER_STAKE_EPOCH with the current epoch, restarting the eligibility countdown for both regular and big draws.', + impact: 'Informational — by design, but could frustrate users unaware of this behavior.', + fix: 'Documented as intentional behavior. Frontend warns users before additional stakes.', + }, +] + +// ── Known trade-offs ── +const tradeoffs = [ + { + title: 'Draw Reveal Discretion (L-02)', + description: 'The operator may choose not to reveal unfavorable draw results by letting them expire. While this doesn\'t result in fund loss (expired draws return funds to pool), it could bias the distribution of winners.', + implication: 'Operator can selectively reveal only favorable outcomes. Funds are not stolen but fairness may be compromised. Future versions may implement public reveal mechanisms or operator bonding.', + }, + { + title: 'Direct INJ Transfers (L-03)', + description: 'Sending INJ directly to the staking-hub contract (outside normal staking flow) will cause that INJ to be distributed as rewards in the next epoch.', + implication: 'Direct transfers are treated as additional staking rewards. The INJ is split according to BPS configuration. This is by design and allows voluntary contributions to the reward pool.', + }, + { + title: 'Re-staking Resets Eligibility (V2-I-01)', + description: 'Any new stake resets the user\'s epoch eligibility timer. Adding more INJ restarts the min_epochs_regular / min_epochs_big countdown.', + implication: 'Users who want to remain eligible for draws should avoid staking more until after a draw. The frontend warns users about this behavior.', + }, + { + title: 'No Minimum Stake by Default (V2-L-03)', + description: 'The min_stake_amount config defaults to 0 (no minimum). Dust stakes are allowed since winning probability is proportional to stake weight.', + implication: 'Expected value for tiny stakes is negligible. Operators can set a minimum via update_config if desired.', + }, +] + +// ── Severity count helpers ── +const v1Counts = { critical: 2, high: 5, medium: 5, low: 5 } +const v2Counts = { medium: 3, low: 3, informational: 1 } + +function SeverityBadge({ severity }: { severity: string }) { + const c = severityColors[severity] || severityColors.low + return ( + + {severity} + + ) +} + +function SeverityBar({ counts, total }: { counts: Record; total: number }) { + const items = Object.entries(counts).map(([sev, count]) => ({ + severity: sev, + count, + pct: (count / total) * 100, + color: severityColors[sev]?.text || '#8E8EA0', + })) + + return ( +
+
+ {items.map((item) => ( +
+ {item.count} +
+ ))} +
+
+ {items.map((item) => ( +
+
+ {item.severity} + ({item.count}) +
+ ))} +
+
+ ) +} + +export default function AuditPage() { + const [openFinding, setOpenFinding] = useState(null) + + const toggleFinding = (id: string) => { + setOpenFinding(openFinding === id ? null : id) + } + + return ( +
+ {/* Hero */} +
+
+

Audit Report

+

+ Security audit findings and current status of the Chance.Staking protocol +

+ +
+
+ +
+
24
+
Total Findings
+
+
+
+ +
+
All Fixed
+
Remediation
+
+
+
+ +
+
96
+
Test Cases
+
+
+
+ +
+
2
+
Audit Rounds
+
+
+
+
+
+ + {/* Audit Process */} +
+
+

Audit Process

+

+ Two rounds of security review covering all smart contracts and shared packages +

+ +
+
+
Round 1
+

Initial Audit

+

+ Manual code review of all Rust source files, cross-contract interaction analysis, + and integration test review. Identified 17 findings including 2 critical and 5 high severity issues. +

+
+ 17 findings + All remediated +
+
+
+
Round 2
+

Post-Remediation Review

+

+ Verified all V1 fixes were correctly implemented. Performed additional review + and identified 7 new findings (3 medium, 3 low, 1 informational). All have been addressed. +

+
+ 7 findings + All remediated +
+
+
+
+
+ + {/* V1 Findings */} +
+
+

Round 1 Findings

+

+ 17 findings across all severity levels — all fixed +

+ + + +
+ {v1Findings.map((f) => ( +
+ + {openFinding === f.id && ( +
+

{f.description}

+

+ Impact: {f.impact} +

+

+ Fix: {f.fix} +

+
+ )} +
+ ))} +
+
+
+ + {/* V2 Findings */} +
+
+

Round 2 Findings

+

+ 7 findings with no critical or high severity issues — all addressed +

+ + + +
+ {v2Findings.map((f) => ( +
+ + {openFinding === f.id && ( +
+

{f.description}

+

+ Impact: {f.impact} +

+

+ Fix: {f.fix} +

+
+ )} +
+ ))} +
+
+
+ + {/* Known Trade-offs */} +
+
+

Known Design Trade-offs

+

+ Documented trade-offs that are understood and accepted +

+ +
+ {tradeoffs.map((t, i) => ( +
+
+ +

{t.title}

+
+

{t.description}

+
+ + {t.implication} +
+
+ ))} +
+
+
+ + {/* Conclusion */} +
+ +
+
+ ) +} + +const styles: Record = { + page: { + paddingTop: 64, + }, + + // Hero + hero: { + padding: '56px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 720, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 46, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 16, + }, + heroSubtitle: { + fontSize: 16, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 32, + }, + statsRow: { + display: 'flex', + justifyContent: 'center', + gap: 12, + flexWrap: 'wrap' as const, + }, + statCard: { + display: 'flex', + alignItems: 'center', + gap: 10, + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 12, + padding: '12px 18px', + }, + statValue: { + fontSize: 16, + fontWeight: 800, + color: '#F0F0F5', + }, + statLabel: { + fontSize: 11, + color: '#8E8EA0', + fontWeight: 500, + }, + + // Section + section: { + padding: '64px 0', + }, + container: { + maxWidth: 960, + margin: '0 auto', + padding: '0 24px', + }, + sectionTitle: { + fontSize: 32, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + textAlign: 'center', + marginBottom: 8, + }, + sectionSubtitle: { + fontSize: 14, + color: '#8E8EA0', + textAlign: 'center', + marginBottom: 40, + }, + + // Process grid + processGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gap: 16, + }, + processCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 24, + }, + processRound: { + fontSize: 11, + fontWeight: 700, + color: '#8B6FFF', + textTransform: 'uppercase' as const, + letterSpacing: '0.08em', + marginBottom: 8, + }, + processTitle: { + fontSize: 18, + fontWeight: 700, + color: '#F0F0F5', + marginBottom: 10, + }, + processDesc: { + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 16, + }, + processMeta: { + display: 'flex', + gap: 16, + fontSize: 12, + color: '#22c55e', + fontWeight: 600, + }, + + // Severity bar + barContainer: { + marginBottom: 28, + }, + bar: { + display: 'flex', + height: 32, + gap: 3, + borderRadius: 8, + overflow: 'hidden', + marginBottom: 12, + }, + barLegend: { + display: 'flex', + justifyContent: 'center', + gap: 16, + flexWrap: 'wrap' as const, + }, + barLegendItem: { + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 12, + color: '#8E8EA0', + }, + + // Findings list + findingsList: { + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + }, + findingCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 12, + overflow: 'hidden', + }, + findingHeader: { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + padding: '14px 18px', + background: 'transparent', + border: 'none', + cursor: 'pointer', + textAlign: 'left' as const, + flexWrap: 'wrap' as const, + }, + findingLeft: { + display: 'flex', + alignItems: 'center', + gap: 10, + flex: 1, + minWidth: 0, + }, + findingRight: { + display: 'flex', + alignItems: 'center', + gap: 10, + flexShrink: 0, + }, + findingId: { + fontSize: 12, + fontWeight: 700, + color: '#F0F0F5', + fontFamily: "'JetBrains Mono', monospace", + flexShrink: 0, + }, + findingTitle: { + fontSize: 13, + color: '#F0F0F5', + fontWeight: 500, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' as const, + }, + contractBadge: { + fontSize: 11, + fontWeight: 600, + color: '#8E8EA0', + background: '#0F0F13', + padding: '3px 8px', + borderRadius: 5, + whiteSpace: 'nowrap' as const, + }, + statusBadge: { + display: 'flex', + alignItems: 'center', + gap: 4, + fontSize: 11, + fontWeight: 600, + color: '#22c55e', + whiteSpace: 'nowrap' as const, + }, + + // Finding body + findingBody: { + padding: '0 18px 18px', + borderTop: '1px solid #2A2A38', + paddingTop: 14, + }, + findingDesc: { + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 10, + }, + findingField: { + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 6, + }, + + // Trade-offs + tradeoffsList: { + display: 'flex', + flexDirection: 'column' as const, + gap: 12, + }, + tradeoffCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 14, + padding: 22, + }, + tradeoffHeader: { + display: 'flex', + alignItems: 'center', + gap: 10, + marginBottom: 10, + }, + tradeoffTitle: { + fontSize: 15, + fontWeight: 700, + color: '#F0F0F5', + }, + tradeoffDesc: { + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 12, + }, + tradeoffImplication: { + display: 'flex', + alignItems: 'flex-start', + gap: 8, + fontSize: 12, + color: '#8E8EA0', + lineHeight: 1.6, + padding: '10px 14px', + background: 'rgba(56, 189, 248, 0.04)', + borderRadius: 8, + border: '1px solid rgba(56, 189, 248, 0.1)', + }, + + // Conclusion + conclusionCard: { + background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.06), rgba(56, 189, 248, 0.04))', + border: '1px solid rgba(34, 197, 94, 0.2)', + borderRadius: 20, + padding: '48px 32px', + textAlign: 'center' as const, + maxWidth: 680, + margin: '0 auto', + }, + conclusionTitle: { + fontSize: 28, + fontWeight: 800, + color: '#F0F0F5', + marginTop: 12, + marginBottom: 12, + }, + conclusionDesc: { + fontSize: 14, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 24, + }, + conclusionLinks: { + display: 'flex', + justifyContent: 'center', + gap: 12, + }, + ctaPrimary: { + display: 'inline-flex', + alignItems: 'center', + padding: '12px 28px', + borderRadius: 10, + background: 'linear-gradient(135deg, #8B6FFF, #6B4FD6)', + color: '#FFFFFF', + fontSize: 14, + fontWeight: 600, + textDecoration: 'none', + }, + ctaSecondary: { + display: 'inline-flex', + alignItems: 'center', + padding: '12px 28px', + borderRadius: 10, + background: 'transparent', + border: '1px solid #2A2A38', + color: '#F0F0F5', + fontSize: 14, + fontWeight: 600, + textDecoration: 'none', + }, +} diff --git a/frontend/src/pages/ContractsPage.tsx b/frontend/src/pages/ContractsPage.tsx new file mode 100644 index 0000000..0a0f520 --- /dev/null +++ b/frontend/src/pages/ContractsPage.tsx @@ -0,0 +1,816 @@ +import React, { useState, useEffect } from 'react' +import { + Layers, Trophy, Zap, Copy, Check, ExternalLink, + Play, Loader, Shield, Code, AlertCircle, User, + Clock, CheckCircle, XCircle, ArrowUpRight, + type LucideIcon, +} from 'lucide-react' +import { CONTRACTS, INJ_DECIMALS } from '../config' +import { + fetchStakingHubConfig, + fetchExchangeRate, + fetchEpochState, + fetchDistributorConfig, + fetchDrawState, + fetchPoolBalances, + fetchOracleConfig, + fetchLatestRound, + fetchInjBalance, + fetchAccountTransactions, +} from '../services/contracts' + +const EXPLORER_BASE = 'https://testnet.explorer.injective.network' + +interface QueryDef { + key: string + label: string + fn: () => Promise +} + +interface ContractDef { + key: string + name: string + description: string + address: string + icon: LucideIcon + color: string + queries: QueryDef[] +} + +const contractCards: ContractDef[] = [ + { + key: 'stakingHub', + name: 'Staking Hub', + description: 'Manages INJ staking, csINJ minting/burning via Token Factory, epoch advancement, and reward distribution to prize pools.', + address: CONTRACTS.stakingHub, + icon: Layers, + color: '#8B6FFF', + queries: [ + { key: 'sh-config', label: 'Config', fn: fetchStakingHubConfig }, + { key: 'sh-exchange', label: 'Exchange Rate', fn: fetchExchangeRate }, + { key: 'sh-epoch', label: 'Epoch State', fn: fetchEpochState }, + ], + }, + { + key: 'rewardDistributor', + name: 'Reward Distributor', + description: 'Prize draw commit-reveal lifecycle, merkle-proof winner verification, and reward payouts.', + address: CONTRACTS.rewardDistributor, + icon: Trophy, + color: '#f472b6', + queries: [ + { key: 'rd-config', label: 'Config', fn: fetchDistributorConfig }, + { key: 'rd-draw-state', label: 'Draw State', fn: fetchDrawState }, + { key: 'rd-pools', label: 'Pool Balances', fn: fetchPoolBalances }, + ], + }, + { + key: 'drandOracle', + name: 'drand Oracle', + description: 'Stores and verifies drand quicknet BLS beacons for publicly verifiable randomness.', + address: CONTRACTS.drandOracle, + icon: Zap, + color: '#38bdf8', + queries: [ + { key: 'do-config', label: 'Config', fn: fetchOracleConfig }, + { key: 'do-latest', label: 'Latest Round', fn: fetchLatestRound }, + ], + }, +] + +interface QueryResult { + loading: boolean + data: any | null + error: string | null +} + +interface OperatorData { + address: string | null + balance: string | null + transactions: any[] | null + loading: boolean + error: string | null +} + +function formatInj(raw: string): string { + const num = parseFloat(raw) / Math.pow(10, INJ_DECIMALS) + return num.toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 }) +} + +function truncateHash(hash: string): string { + if (hash.length <= 16) return hash + return hash.slice(0, 10) + '...' + hash.slice(-6) +} + +function timeAgo(timestamp: string): string { + const ms = Date.now() - new Date(timestamp).getTime() + if (ms < 0) return 'just now' + const secs = Math.floor(ms / 1000) + if (secs < 60) return `${secs}s ago` + const mins = Math.floor(secs / 60) + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +function extractMsgType(messages: any[]): string { + if (!messages || messages.length === 0) return 'Unknown' + const msg = messages[0] + // For MsgExecuteContract, extract the first key from the inner msg object + const innerMsg = msg?.value?.msg + if (innerMsg && typeof innerMsg === 'object') { + const key = Object.keys(innerMsg)[0] + if (key) return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) + } + // Fallback to the outer message type + const type = msg?.type || msg?.['@type'] || '' + const short = type.split(/[./]/).pop() || type + return short.replace(/^Msg/, '').replace(/([A-Z])/g, ' $1').trim() +} + +export default function ContractsPage() { + const [results, setResults] = useState>({}) + const [copiedKey, setCopiedKey] = useState(null) + const [operator, setOperator] = useState({ + address: null, balance: null, transactions: null, loading: true, error: null, + }) + + // Load operator data on mount + useEffect(() => { + (async () => { + try { + const config = await fetchStakingHubConfig() + const operatorAddr = config.operator + const [balance, txResult] = await Promise.all([ + fetchInjBalance(operatorAddr).catch(() => '0'), + fetchAccountTransactions(operatorAddr, 15).catch(() => null), + ]) + const txs = txResult && 'txs' in txResult ? txResult.txs : [] + setOperator({ + address: operatorAddr, + balance, + transactions: txs, + loading: false, + error: null, + }) + } catch (err: any) { + setOperator(prev => ({ + ...prev, + loading: false, + error: err?.message || 'Failed to load operator data', + })) + } + })() + }, []) + + const runQuery = async (queryKey: string, fn: () => Promise) => { + setResults(prev => ({ ...prev, [queryKey]: { loading: true, data: null, error: null } })) + try { + const data = await fn() + setResults(prev => ({ ...prev, [queryKey]: { loading: false, data, error: null } })) + } catch (err: any) { + setResults(prev => ({ + ...prev, + [queryKey]: { loading: false, data: null, error: err?.message || 'Query failed' }, + })) + } + } + + const copyAddress = (key: string, address: string) => { + navigator.clipboard.writeText(address) + setCopiedKey(key) + setTimeout(() => setCopiedKey(null), 2000) + } + + + return ( +
+ {/* Hero */} +
+
+

Smart Contracts

+

+ Explore and query the Chance.Staking protocol contracts on Injective testnet +

+ +
+
+ +
+
3
+
Contracts
+
+
+
+ +
+
Audited
+
Security
+
+
+
+ +
+
Testnet
+
Network
+
+
+
+
+
+ + {/* Operator Section */} +
+
+
+
+
+ +
+
+

Protocol Operator

+

+ The operator manages epoch advancement, snapshot submission, and draw lifecycle operations. +

+
+
+ + {operator.loading ? ( +
+ + Loading operator data... +
+ ) : operator.error ? ( +
+ + {operator.error} +
+ ) : ( + <> + {/* Address + Balance */} +
+
+
+
Operator Address
+
+ {operator.address} + + + + Explorer + +
+
+
+
INJ Balance
+
+ {operator.balance ? formatInj(operator.balance) : '0'} INJ +
+
+
+
+ + {/* Recent Transactions */} +
+
Recent Transactions
+ {operator.transactions && operator.transactions.length > 0 ? ( +
+ {operator.transactions.map((tx: any, i: number) => { + const hash = tx.hash || tx.txHash || '' + const success = tx.code === 0 || tx.code === undefined + const msgType = extractMsgType(tx.messages || []) + const timestamp = tx.blockTimestamp || tx.timestamp || '' + return ( + +
+ {success ? ( + + ) : ( + + )} + {truncateHash(hash)} + {msgType} +
+
+ {timestamp && ( + + + {timeAgo(timestamp)} + + )} + +
+
+ ) + })} +
+ ) : ( +
No recent transactions found
+ )} +
+ + )} +
+
+
+ + {/* Contract Cards */} +
+
+ {contractCards.map((contract) => { + const Icon = contract.icon + return ( +
+ {/* Header */} +
+
+ +
+
+

{contract.name}

+

{contract.description}

+
+
+ + {/* Address */} +
+
Contract Address
+
+ {contract.address} + + + + Explorer + +
+
+ + {/* Queries */} +
+
Queries
+
+ {contract.queries.map((q) => { + const result = results[q.key] + const isLoading = result?.loading + return ( + + ) + })} +
+ + {/* Results */} + {contract.queries.map((q) => { + const result = results[q.key] + if (!result || result.loading) return null + return ( +
+
+ {q.label} + {result.error ? ( + + Error + + ) : ( + + Success + + )} +
+
+ {result.error ? ( +
{result.error}
+ ) : ( +
+                              
+                                {JSON.stringify(result.data, null, 2)}
+                              
+                            
+ )} +
+
+ ) + })} +
+
+ ) + })} +
+
+
+ ) +} + +const styles: Record = { + page: { + paddingTop: 64, + }, + + // Hero + hero: { + padding: '56px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 720, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 46, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 16, + }, + heroSubtitle: { + fontSize: 16, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 32, + }, + statsRow: { + display: 'flex', + justifyContent: 'center', + gap: 16, + marginBottom: 0, + flexWrap: 'wrap' as const, + }, + statCard: { + display: 'flex', + alignItems: 'center', + gap: 10, + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 12, + padding: '12px 20px', + }, + statValue: { + fontSize: 16, + fontWeight: 800, + color: '#F0F0F5', + }, + statLabel: { + fontSize: 11, + color: '#8E8EA0', + fontWeight: 500, + }, + + // Content + content: { + padding: '40px 0 80px', + }, + container: { + maxWidth: 960, + margin: '0 auto', + padding: '0 24px', + display: 'flex', + flexDirection: 'column' as const, + gap: 24, + }, + + // Contract card + contractCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 28, + }, + + // Card header + cardHeader: { + display: 'flex', + alignItems: 'flex-start', + gap: 16, + marginBottom: 24, + }, + cardIcon: { + width: 48, + height: 48, + borderRadius: 14, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + cardTitle: { + fontSize: 20, + fontWeight: 800, + color: '#F0F0F5', + marginBottom: 4, + }, + cardDesc: { + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.6, + }, + + // Address section + addressSection: { + marginBottom: 24, + paddingBottom: 20, + borderBottom: '1px solid #2A2A38', + }, + addressLabel: { + fontSize: 11, + fontWeight: 700, + color: '#8E8EA0', + textTransform: 'uppercase' as const, + letterSpacing: '0.06em', + marginBottom: 8, + }, + addressRow: { + display: 'flex', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap' as const, + }, + addressText: { + fontSize: 13, + fontFamily: "'JetBrains Mono', monospace", + color: '#F0F0F5', + background: '#0F0F13', + padding: '8px 14px', + borderRadius: 8, + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' as const, + }, + iconBtn: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 36, + height: 36, + borderRadius: 8, + border: '1px solid #2A2A38', + background: 'transparent', + cursor: 'pointer', + flexShrink: 0, + transition: 'border-color 0.2s', + }, + explorerBtn: { + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '8px 14px', + borderRadius: 8, + border: '1px solid #2A2A38', + background: 'transparent', + color: '#8E8EA0', + fontSize: 12, + fontWeight: 600, + textDecoration: 'none', + flexShrink: 0, + transition: 'color 0.2s, border-color 0.2s', + }, + + // Query section + querySection: {}, + queryLabel: { + fontSize: 11, + fontWeight: 700, + color: '#8E8EA0', + textTransform: 'uppercase' as const, + letterSpacing: '0.06em', + marginBottom: 10, + }, + queryButtons: { + display: 'flex', + gap: 8, + flexWrap: 'wrap' as const, + marginBottom: 16, + }, + queryBtn: { + display: 'flex', + alignItems: 'center', + gap: 7, + padding: '9px 16px', + borderRadius: 9, + border: '1px solid #2A2A38', + background: '#0F0F13', + color: '#F0F0F5', + fontSize: 13, + fontWeight: 600, + cursor: 'pointer', + transition: 'all 0.2s', + }, + + // Result + resultContainer: { + marginBottom: 12, + borderRadius: 10, + border: '1px solid #2A2A38', + overflow: 'hidden', + }, + resultHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '8px 14px', + background: '#252530', + borderBottom: '1px solid #2A2A38', + }, + resultLabel: { + fontSize: 12, + fontWeight: 700, + color: '#F0F0F5', + }, + resultSuccess: { + display: 'flex', + alignItems: 'center', + gap: 4, + fontSize: 11, + fontWeight: 600, + color: '#22c55e', + }, + resultError: { + display: 'flex', + alignItems: 'center', + gap: 4, + fontSize: 11, + fontWeight: 600, + color: '#ef4444', + }, + resultBody: { + padding: '12px 14px', + background: '#0F0F13', + maxHeight: 400, + overflow: 'auto', + }, + resultPre: { + margin: 0, + }, + resultCode: { + fontSize: 12, + fontFamily: "'JetBrains Mono', monospace", + color: '#38bdf8', + lineHeight: 1.6, + whiteSpace: 'pre' as const, + }, + errorText: { + fontSize: 13, + color: '#ef4444', + fontFamily: "'JetBrains Mono', monospace", + }, + + // Operator section + operatorSection: { + padding: '40px 0 0', + }, + operatorLoading: { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '20px 0', + }, + operatorError: { + display: 'flex', + alignItems: 'center', + gap: 8, + fontSize: 13, + color: '#ef4444', + padding: '12px 0', + }, + operatorMeta: { + display: 'flex', + gap: 20, + alignItems: 'flex-end', + flexWrap: 'wrap' as const, + }, + balanceCard: { + background: '#0F0F13', + borderRadius: 10, + padding: '10px 18px', + flexShrink: 0, + }, + balanceLabel: { + fontSize: 11, + fontWeight: 700, + color: '#8E8EA0', + textTransform: 'uppercase' as const, + letterSpacing: '0.06em', + marginBottom: 4, + }, + balanceValue: { + fontSize: 18, + fontWeight: 800, + color: '#F0F0F5', + fontFamily: "'JetBrains Mono', monospace", + }, + + // Transaction list + txList: { + borderRadius: 10, + border: '1px solid #2A2A38', + overflow: 'hidden', + }, + txRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '10px 14px', + borderBottom: '1px solid #1A1A22', + background: '#0F0F13', + textDecoration: 'none', + transition: 'background 0.15s', + gap: 12, + }, + txLeft: { + display: 'flex', + alignItems: 'center', + gap: 10, + minWidth: 0, + }, + txHash: { + fontSize: 12, + fontFamily: "'JetBrains Mono', monospace", + color: '#38bdf8', + }, + txType: { + fontSize: 12, + color: '#8E8EA0', + fontWeight: 500, + whiteSpace: 'nowrap' as const, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + txRight: { + display: 'flex', + alignItems: 'center', + gap: 10, + flexShrink: 0, + }, + txTime: { + display: 'flex', + alignItems: 'center', + gap: 4, + fontSize: 11, + color: '#525260', + whiteSpace: 'nowrap' as const, + }, + noTxs: { + fontSize: 13, + color: '#525260', + padding: '16px 0', + }, +} diff --git a/frontend/src/pages/DocsPage.tsx b/frontend/src/pages/DocsPage.tsx new file mode 100644 index 0000000..7c03d3d --- /dev/null +++ b/frontend/src/pages/DocsPage.tsx @@ -0,0 +1,1250 @@ +import React, { useState } from 'react' +import { + BookOpen, Layers, Trophy, Zap, GitBranch, Hash, FileText, + Shield, ChevronRight, Menu, X, ArrowRight, ArrowDown, + type LucideIcon, +} from 'lucide-react' + +// ── Sidebar navigation ── +interface NavItem { + key: string + title: string + icon?: LucideIcon + parent?: string +} + +const navItems: NavItem[] = [ + { key: 'overview', title: 'Overview', icon: BookOpen }, + { key: 'staking-hub', title: 'Staking Hub', icon: Layers }, + { key: 'sh-execute', title: 'Execute Messages', parent: 'staking-hub' }, + { key: 'sh-query', title: 'Query Messages', parent: 'staking-hub' }, + { key: 'reward-distributor', title: 'Reward Distributor', icon: Trophy }, + { key: 'rd-execute', title: 'Execute Messages', parent: 'reward-distributor' }, + { key: 'rd-query', title: 'Query Messages', parent: 'reward-distributor' }, + { key: 'drand-oracle', title: 'drand Oracle', icon: Zap }, + { key: 'do-execute', title: 'Execute Messages', parent: 'drand-oracle' }, + { key: 'do-query', title: 'Query Messages', parent: 'drand-oracle' }, + { key: 'interactions', title: 'Contract Interactions', icon: GitBranch }, + { key: 'merkle-tree', title: 'Merkle Tree', icon: Hash }, + { key: 'shared-types', title: 'Shared Types', icon: FileText }, + { key: 'validation', title: 'Validation Rules', icon: Shield }, +] + +function CodeBlock({ children }: { children: string }) { + return ( +
+
{children}
+
+ ) +} + +function MsgCard({ title, description, children }: { title: string; description: string; children: string }) { + return ( +
+

{title}

+

{description}

+ {children} +
+ ) +} + +function SectionHeading({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function SubHeading({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function Paragraph({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function InlineCode({ children }: { children: string }) { + return {children} +} + +// ── Section renderers ── + +function OverviewSection() { + return ( +
+ Overview + + Chance.Staking is a prize-linked staking protocol on Injective. Users stake INJ, receive csINJ + (a liquid staking token), and are automatically entered into regular and big prize draws funded + by staking rewards. Your principal stays safe — only rewards are gamified. + + + Architecture + + The protocol consists of three smart contracts that work together: + + +
+
+ +

Staking Hub

+

+ Core contract. Manages INJ staking, csINJ minting/burning via Token Factory, + epoch advancement, and reward distribution to pools. +

+
+
+
+ +

Reward Distributor

+

+ Prize draw lifecycle: commit-reveal with drand randomness, + merkle-proof winner verification, and reward payouts. +

+
+
+
+ +

drand Oracle

+

+ Stores and verifies drand quicknet beacons (BLS threshold signatures). + Provides verifiable randomness for winner selection. +

+
+
+ + csINJ Token + + csINJ is a liquid staking token created via Injective's Token Factory with + denom {'factory/{staking_hub_address}/csINJ'}. The exchange rate + between csINJ and INJ increases over time as staking rewards accrue: + + {'exchange_rate = total_inj_backing / total_csinj_supply'} + + When you stake, you receive csINJ at the current rate. When you unstake, your csINJ is burned + and you receive more INJ than you originally staked (after the 21-day unbonding period). + + + Deployment Order + Contracts must be deployed in this order due to cross-contract dependencies: +
+
+ 1 +
+ drand Oracle — No dependencies +
+
+
+ 2 +
+ Reward Distributor — Needs drand oracle address; uses placeholder for staking hub +
+
+
+ 3 +
+ Staking Hub — Needs reward distributor + drand oracle addresses +
+
+
+ 4 +
+ UpdateConfig — Set real staking hub address on reward distributor +
+
+
+ + Reward Distribution + + Each epoch, staking rewards are claimed and split according to configurable BPS values + (must sum to 10,000): + +
+
+
+ Regular Pool — 70% (7000 bps) +
+
+
+ Big Pool — 20% (2000 bps) +
+
+
+ Base Yield — 5% (500 bps) — increases csINJ exchange rate +
+
+
+ Protocol Fee — 5% (500 bps) — sent to treasury +
+
+
+ ) +} + +function StakingHubSection() { + return ( +
+ Staking Hub + + The Staking Hub is the core contract. It manages INJ staking via native delegation to validators, + csINJ minting/burning through Injective's Token Factory, epoch lifecycle, and reward distribution + to the prize pools. + + + Key Concepts + + Exchange Rate: {'total_inj_backing / total_csinj_supply'}. Starts + at 1.0, increases as base yield accrues. + + + Unstake Lock: 21 days (Injective unbonding period). + + + Eligibility: Users must hold csINJ for min_epochs_regular epochs + for regular draws and min_epochs_big epochs for big draws. Re-staking resets the eligibility timer. + +
+ ) +} + +function StakingHubExecuteSection() { + return ( +
+ Staking Hub — Execute Messages + + {`{ "stake": {} } +// funds: [{ "denom": "inj", "amount": "1000000000000000000" }]`} + + {`{ "unstake": {} } +// funds: [{ "denom": "factory/{contract}/csINJ", "amount": "1000000" }]`} + + {`{ "claim_unstaked": { "request_ids": [0, 1] } }`} + + {`{ "claim_rewards": {} }`} + + {`{ "distribute_rewards": {} }`} + + {`{ "take_snapshot": { + "merkle_root": "abcdef...", + "total_weight": "1000000", + "num_holders": 42, + "snapshot_uri": "https://..." +} }`} + + {`{ "update_config": { + "admin": "inj1...", + "operator": "inj1...", + "protocol_fee_bps": 500, + "base_yield_bps": 500, + "regular_pool_bps": 7000, + "big_pool_bps": 2000, + "min_epochs_regular": 1, + "min_epochs_big": 4, + "min_stake_amount": "1000" +} }`} + + {`{ "update_validators": { + "add": ["injvaloper1..."], + "remove": [] +} }`} + + {`{ "sync_delegations": {} }`} +
+ ) +} + +function StakingHubQuerySection() { + return ( +
+ Staking Hub — Query Messages + + {`{ "config": {} } + +// Returns: Config +{ + "admin": "inj1...", + "operator": "inj1...", + "reward_distributor": "inj1...", + "drand_oracle": "inj1...", + "csinj_denom": "factory/inj1.../csINJ", + "validators": ["injvaloper1..."], + "epoch_duration_seconds": 86400, + "protocol_fee_bps": 500, + "treasury": "inj1...", + "base_yield_bps": 500, + "regular_pool_bps": 7000, + "big_pool_bps": 2000, + "min_epochs_regular": 1, + "min_epochs_big": 4, + "min_stake_amount": "1000" +}`} + + {`{ "epoch_state": {} } + +// Returns: EpochState +{ + "current_epoch": 5, + "epoch_start_time": "1234567890.000000000", + "total_staked": "100000000", + "snapshot_merkle_root": "abcdef..." | null, + "snapshot_finalized": true, + "snapshot_total_weight": "100000000", + "snapshot_num_holders": 42, + "snapshot_uri": "https://..." | null +}`} + + {`{ "exchange_rate": {} } + +// Returns: ExchangeRateResponse +{ + "rate": "1.05", + "total_inj_backing": "105000000", + "total_csinj_supply": "100000000" +}`} + + {`{ "unstake_requests": { "address": "inj1..." } } + +// Returns: UnstakeRequestEntry[] +[{ + "id": 0, + "request": { + "inj_amount": "50000000", + "csinj_burned": "47619047", + "unlock_time": "1234567890.000000000", + "claimed": false + } +}]`} + + {`{ "staker_info": { "address": "inj1..." } } + +// Returns: StakerInfoResponse +{ + "address": "inj1...", + "stake_epoch": 5 | null +}`} +
+ ) +} + +function RewardDistributorSection() { + return ( +
+ Reward Distributor + + The Reward Distributor manages the prize draw lifecycle using a commit-reveal scheme with + drand randomness. It holds the regular and big prize pool balances, verifies merkle proofs + for winner inclusion, and handles reward payouts. + + + Draw Lifecycle +
+
+ 1 +
+ Fund Pools — Staking Hub sends INJ via FundRegularPool / FundBigPool during AdvanceEpoch +
+
+
+ 2 +
+ Set Snapshot — Staking Hub forwards merkle root via SetSnapshot during TakeSnapshot +
+
+
+ 3 +
+ Commit — Operator commits sha256(secret) and target drand round +
+
+
+ 4 +
+ Reveal — After drand beacon, operator reveals secret + computed winner with merkle proof +
+
+
+ 5 +
+ Expire — If operator fails to reveal within deadline, anyone can expire to return funds to pool +
+
+
+ + Winner Selection + {`final_randomness = drand_randomness XOR sha256(operator_secret) +winning_ticket = u128_from_be(final_randomness[0..16]) % total_weight + +// Winner is the holder whose [cumulative_start, cumulative_end) +// range contains the winning ticket`} +
+ ) +} + +function RewardDistributorExecuteSection() { + return ( +
+ Reward Distributor — Execute Messages + + {`{ "fund_regular_pool": {} } +// funds: [{ "denom": "inj", "amount": "..." }]`} + + {`{ "fund_big_pool": {} } +// funds: [{ "denom": "inj", "amount": "..." }]`} + + {`{ "set_snapshot": { + "epoch": 1, + "merkle_root": "abcdef...", + "total_weight": "1000000", + "num_holders": 42 +} }`} + + {`{ "commit_draw": { + "draw_type": "regular", + "operator_commit": "sha256hex", + "target_drand_round": 1000, + "epoch": 1 +} }`} + + {`{ "reveal_draw": { + "draw_id": 0, + "operator_secret_hex": "hex_of_secret_bytes", + "winner_address": "inj1...", + "winner_cumulative_start": "100", + "winner_cumulative_end": "350", + "merkle_proof": ["hex_hash_1", "hex_hash_2"] +} }`} + + {`{ "expire_draw": { "draw_id": 0 } }`} + + {`{ "update_config": { + "operator": "inj1...", + "staking_hub": "inj1...", + "reveal_deadline_seconds": 3600, + "epochs_between_regular": 1, + "epochs_between_big": 7 +} }`} +
+ ) +} + +function RewardDistributorQuerySection() { + return ( +
+ Reward Distributor — Query Messages + + {`{ "config": {} } + +// Returns: DistributorConfig +{ + "admin": "inj1...", + "operator": "inj1...", + "staking_hub": "inj1...", + "drand_oracle": "inj1...", + "reveal_deadline_seconds": 3600, + "epochs_between_regular": 1, + "epochs_between_big": 7 +}`} + + {`{ "draw_state": {} } + +// Returns: DrawStateInfo +{ + "next_draw_id": 5, + "regular_pool_balance": "40000000", + "big_pool_balance": "200000000", + "total_draws_completed": 4, + "total_rewards_distributed": "40000000", + "last_regular_draw_epoch": 4 | null, + "last_big_draw_epoch": 1 | null +}`} + + {`{ "draw": { "draw_id": 0 } } + +// Returns: Draw +{ + "id": 0, + "draw_type": "regular", + "epoch": 1, + "status": "revealed", + "operator_commit": "sha256hex", + "target_drand_round": 1000, + "winner": "inj1..." | null, + "reward_amount": "10000000", + "created_at": "1234567890.000000000", + "revealed_at": "1234567900.000000000" | null, + "reveal_deadline": "1234571490.000000000" +}`} + + {`{ "draw_history": { "start_after": 0, "limit": 10 } } + +// Returns: { "draws": [Draw, ...] }`} + + {`{ "pool_balances": {} } + +// Returns: +{ "regular_pool": "40000000", "big_pool": "200000000" }`} + + {`{ "user_wins": { "address": "inj1..." } } + +// Returns: UserWinsResponse +{ + "address": "inj1...", + "total_wins": 2, + "total_won_amount": "20000000", + "draw_ids": [0, 3] +}`} + + {`{ "verify_inclusion": { + "merkle_root": "abcdef...", + "proof": ["hex1", "hex2"], + "leaf_address": "inj1...", + "cumulative_start": "100", + "cumulative_end": "350" +} } + +// Returns: bool`} + + {`{ "snapshot": { "epoch": 1 } } + +// Returns: Snapshot | null +{ + "epoch": 1, + "merkle_root": "abcdef...", + "total_weight": "1000000", + "num_holders": 42, + "submitted_at": "1234567890.000000000" +}`} +
+ ) +} + +function DrandOracleSection() { + return ( +
+ drand Oracle + + The drand Oracle stores and verifies drand quicknet beacons. These BLS threshold signatures + from the League of Entropy provide publicly verifiable randomness used by the Reward Distributor + for winner selection. + + + drand quicknet produces a new beacon every 3 seconds. The oracle contract verifies the BLS + signature against the hardcoded quicknet public key before storing a beacon. + +
+ ) +} + +function DrandOracleExecuteSection() { + return ( +
+ drand Oracle — Execute Messages + + {`{ "submit_beacon": { + "round": 1000, + "signature_hex": "b446..." +} }`} + + {`{ "update_operators": { + "add": ["inj1..."], + "remove": [] +} }`} + + {`{ "update_admin": { "new_admin": "inj1..." } }`} +
+ ) +} + +function DrandOracleQuerySection() { + return ( +
+ drand Oracle — Query Messages + + {`{ "config": {} } + +// Returns: OracleConfig +{ + "admin": "inj1...", + "operators": ["inj1..."], + "quicknet_pubkey": [/* bytes */], + "chain_hash": "52db...", + "genesis_time": 1692803367, + "period_seconds": 3 +}`} + + {`{ "beacon": { "round": 1000 } } + +// Returns: StoredBeacon | null +{ + "round": 1000, + "randomness": [/* 32 bytes */], + "signature": [/* 48 bytes */], + "verified": true, + "submitted_at": "1234567890.000000000", + "submitted_by": "inj1..." +}`} + + {`{ "latest_round": {} } + +// Returns: u64`} +
+ ) +} + +function InteractionsSection() { + return ( +
+ Contract Interactions + + The three contracts communicate via cross-contract messages during the epoch lifecycle: + + + Epoch Flow +
+
+
1. Claim Rewards
+
+ staking-hub.claim_rewards() +
Sends WithdrawDelegatorReward to all validators +
+
+ +
+
2. Distribute Rewards
+
+ staking-hub.distribute_rewards() +
Advances epoch, splits rewards by BPS config +
Calls reward-distributor.fund_regular_pool() and reward-distributor.fund_big_pool() +
+
+ +
+
3. Take Snapshot
+
+ staking-hub.take_snapshot() +
Forwards merkle root to reward-distributor.set_snapshot() +
+
+ +
+
4. Commit Draw
+
+ reward-distributor.commit_draw() +
Operator commits hash + target drand round +
+
+ +
+
5. Submit Beacon
+
+ drand-oracle.submit_beacon() +
Beacon verified and stored on-chain +
+
+ +
+
6. Reveal Draw
+
+ reward-distributor.reveal_draw() +
Queries drand-oracle.beacon() for randomness +
Verifies merkle proof, pays winner +
+
+
+
+ ) +} + +function MerkleTreeSection() { + return ( +
+ Merkle Tree + + The merkle tree uses sorted-pair hashing (smaller + hash first when combining siblings) with domain separation prefixes + to prevent second pre-image attacks. + + + Leaf Hash + {`sha256(0x00 || address_bytes || cumulative_start_be_u128 || cumulative_end_be_u128) + +// 0x00: leaf domain separator prefix byte +// address_bytes: raw UTF-8 bytes of the bech32 address string +// cumulative_start / cumulative_end: big-endian 16-byte u128`} + + Internal Node Hash + {`sha256(0x01 || min(left, right) || max(left, right)) + +// 0x01: internal node domain separator prefix byte +// Sorted pair: smaller hash comes first`} + + Frontend Integration + The frontend needs to: +
+
+ 1 +
Build the tree from snapshot entries
+
+
+ 2 +
Generate proofs for the winner during reveal_draw
+
+
+ 3 +
Optionally verify proofs via the verify_inclusion query
+
+
+
+ ) +} + +function SharedTypesSection() { + return ( +
+ Shared Types + + Types shared across contracts, defined in the chance-staking-common package: + + + + {`type DrawType = "regular" | "big"`} + + + + {`type DrawStatus = "committed" | "revealed" | "expired"`} + + + + {`interface SnapshotEntry { + address: string; // bech32 address + balance: string; // Uint128 csINJ balance + cumulative_start: string; // Uint128 + cumulative_end: string; // Uint128 +}`} + +
+ ) +} + +function ValidationSection() { + return ( +
+ Validation Rules + + Post-audit validation rules enforced by the contracts: + + +
+ {[ + { title: 'BPS Sum', desc: 'regular_pool_bps + big_pool_bps + base_yield_bps + protocol_fee_bps must equal 10000. Enforced at instantiation and update_config.' }, + { title: 'Validator Addresses', desc: 'Must start with "injvaloper" and be reasonable length. Enforced at instantiation and update_validators.' }, + { title: 'Merkle Root', desc: 'Must be exactly 64 hex characters (32 bytes). Validated in take_snapshot.' }, + { title: 'Reveal Deadline', desc: 'Must be between 300 seconds (5 min) and 86400 seconds (24 hours). Enforced at instantiation and update_config.' }, + { title: 'Epoch Duration', desc: 'distribute_rewards enforces that epoch_duration_seconds has elapsed since epoch start.' }, + { title: 'Snapshot Overwrite', desc: 'Cannot overwrite a snapshot for an epoch that already has one.' }, + { title: 'Zero Weight', desc: 'Snapshots with total_weight = 0 are rejected at commit_draw.' }, + { title: 'Balance Check', desc: 'reveal_draw verifies contract has sufficient balance before payout.' }, + { title: 'Min Stake', desc: 'Stake amount must be >= min_stake_amount (configurable, 0 = no minimum).' }, + { title: 'Draw Epoch', desc: 'commit_draw validates the epoch matches the latest snapshot epoch.' }, + ].map((rule, i) => ( +
+ +
+ {rule.title} + — {rule.desc} +
+
+ ))} +
+
+ ) +} + +// ── Section renderer map ── +const sectionComponents: Record = { + 'overview': OverviewSection, + 'staking-hub': StakingHubSection, + 'sh-execute': StakingHubExecuteSection, + 'sh-query': StakingHubQuerySection, + 'reward-distributor': RewardDistributorSection, + 'rd-execute': RewardDistributorExecuteSection, + 'rd-query': RewardDistributorQuerySection, + 'drand-oracle': DrandOracleSection, + 'do-execute': DrandOracleExecuteSection, + 'do-query': DrandOracleQuerySection, + 'interactions': InteractionsSection, + 'merkle-tree': MerkleTreeSection, + 'shared-types': SharedTypesSection, + 'validation': ValidationSection, +} + +export default function DocsPage() { + const [activeSection, setActiveSection] = useState('overview') + const [sidebarOpen, setSidebarOpen] = useState(false) + + const ActiveComponent = sectionComponents[activeSection] || OverviewSection + + return ( +
+ {/* Hero */} +
+
+

Documentation

+

+ Technical reference for the Chance.Staking protocol smart contracts +

+
+
+ + {/* Docs layout */} +
+ {/* Mobile toggle */} + + + {/* Sidebar */} + + + {/* Content */} +
+ +
+
+
+ ) +} + +const styles: Record = { + page: { + paddingTop: 64, + }, + + // Hero + hero: { + padding: '56px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 720, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 46, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 16, + }, + heroSubtitle: { + fontSize: 16, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 0, + }, + + // Docs layout + docsLayout: { + display: 'flex', + maxWidth: 1120, + margin: '0 auto', + padding: '40px 24px 80px', + gap: 40, + }, + + // Sidebar toggle (mobile) + sidebarToggle: { + display: 'none', + alignItems: 'center', + gap: 8, + padding: '10px 16px', + borderRadius: 10, + border: '1px solid #2A2A38', + background: '#1A1A22', + color: '#F0F0F5', + fontSize: 13, + fontWeight: 600, + cursor: 'pointer', + marginBottom: 16, + width: '100%', + justifyContent: 'center', + }, + + // Sidebar + sidebar: { + width: 240, + flexShrink: 0, + position: 'sticky' as const, + top: 80, + maxHeight: 'calc(100vh - 96px)', + overflowY: 'auto' as const, + paddingRight: 16, + borderRight: '1px solid #2A2A38', + display: 'flex', + flexDirection: 'column' as const, + gap: 2, + }, + sidebarItem: { + display: 'flex', + alignItems: 'center', + gap: 8, + width: '100%', + padding: '9px 14px', + borderRadius: 8, + background: 'transparent', + border: 'none', + color: '#8E8EA0', + fontSize: 13, + fontWeight: 500, + cursor: 'pointer', + textAlign: 'left' as const, + transition: 'all 0.15s', + }, + sidebarSubItem: { + paddingLeft: 32, + fontSize: 12, + }, + sidebarItemActive: { + color: '#F0F0F5', + background: 'rgba(139, 111, 255, 0.08)', + fontWeight: 600, + }, + + // Content + docsContent: { + flex: 1, + minWidth: 0, + }, + + // Content elements + sectionHeading: { + fontSize: 28, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.02em', + marginBottom: 16, + paddingBottom: 12, + borderBottom: '1px solid #2A2A38', + }, + subHeading: { + fontSize: 18, + fontWeight: 700, + color: '#F0F0F5', + marginTop: 32, + marginBottom: 12, + }, + paragraph: { + fontSize: 14, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 16, + }, + inlineCode: { + background: '#0F0F13', + padding: '2px 7px', + borderRadius: 5, + fontSize: 12, + fontFamily: "'JetBrains Mono', monospace", + color: '#38bdf8', + }, + + // Code block + codeBlock: { + background: '#0F0F13', + borderRadius: 10, + padding: '14px 18px', + overflow: 'auto', + marginBottom: 16, + border: '1px solid #1A1A22', + }, + codePre: { + margin: 0, + }, + code: { + fontSize: 12, + fontFamily: "'JetBrains Mono', monospace", + color: '#38bdf8', + lineHeight: 1.7, + whiteSpace: 'pre' as const, + }, + + // Message card + msgCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 14, + padding: 20, + marginBottom: 14, + }, + msgTitle: { + fontSize: 15, + fontWeight: 700, + color: '#F0F0F5', + marginBottom: 6, + }, + msgDesc: { + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.6, + marginBottom: 12, + }, + + // Architecture grid + archGrid: { + display: 'flex', + alignItems: 'stretch', + gap: 0, + marginBottom: 24, + flexWrap: 'wrap' as const, + justifyContent: 'center', + }, + archCard: { + flex: '1 1 200px', + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 14, + padding: 20, + textAlign: 'center' as const, + minWidth: 180, + }, + archTitle: { + fontSize: 15, + fontWeight: 700, + color: '#F0F0F5', + marginTop: 10, + marginBottom: 6, + }, + archDesc: { + fontSize: 12, + color: '#8E8EA0', + lineHeight: 1.6, + }, + archArrow: { + display: 'flex', + alignItems: 'center', + padding: '0 8px', + }, + + // Deploy list + deployList: { + display: 'flex', + flexDirection: 'column' as const, + gap: 10, + marginBottom: 24, + }, + deployStep: { + display: 'flex', + alignItems: 'flex-start', + gap: 12, + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.6, + }, + deployNum: { + width: 24, + height: 24, + borderRadius: '50%', + background: 'rgba(139, 111, 255, 0.12)', + color: '#8B6FFF', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 12, + fontWeight: 700, + flexShrink: 0, + }, + + // BPS grid + bpsGrid: { + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + marginBottom: 24, + }, + bpsItem: { + display: 'flex', + alignItems: 'center', + gap: 10, + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.6, + }, + bpsDot: { + width: 10, + height: 10, + borderRadius: '50%', + flexShrink: 0, + }, + + // Flow container + flowContainer: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 14, + padding: 24, + marginBottom: 24, + }, + flowStep: { + padding: '12px 16px', + borderRadius: 10, + background: '#0F0F13', + border: '1px solid #2A2A38', + }, + flowLabel: { + fontSize: 13, + fontWeight: 700, + color: '#8B6FFF', + marginBottom: 6, + }, + flowDesc: { + fontSize: 12, + color: '#8E8EA0', + lineHeight: 1.8, + }, + + // Rules list + rulesList: { + display: 'flex', + flexDirection: 'column' as const, + gap: 12, + }, + ruleItem: { + display: 'flex', + alignItems: 'flex-start', + gap: 10, + fontSize: 13, + lineHeight: 1.6, + padding: '12px 16px', + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 10, + }, +} diff --git a/frontend/src/pages/DrawsPage.tsx b/frontend/src/pages/DrawsPage.tsx new file mode 100644 index 0000000..6709f8b --- /dev/null +++ b/frontend/src/pages/DrawsPage.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import { Trophy, Sparkles } from 'lucide-react' +import { useStore } from '../store/useStore' +import { formatInj } from '../utils/formatNumber' +import DrawsSection from '../components/DrawsSection' + +export default function DrawsPage() { + const regularPoolBalance = useStore((s) => s.regularPoolBalance) + const bigPoolBalance = useStore((s) => s.bigPoolBalance) + const totalDrawsCompleted = useStore((s) => s.totalDrawsCompleted) + const totalRewardsDistributed = useStore((s) => s.totalRewardsDistributed) + + return ( +
+ {/* Page hero */} +
+
+

Prize Draws

+

+ Verifiable on-chain prize draws powered by drand randomness beacons and commit-reveal schemes. +

+ +
+
+
+ + Regular Pool +
+
+ {formatInj(regularPoolBalance, 2)} INJ +
+
+
+
+ + Big Jackpot Pool +
+
+ {formatInj(bigPoolBalance, 2)} INJ +
+
+
+
+ Draws Completed + {totalDrawsCompleted} +
+
+
+ Total Distributed + {formatInj(totalRewardsDistributed, 2)} INJ +
+
+
+
+
+ + +
+ ) +} + +const styles: Record = { + page: { + paddingTop: 64, + }, + hero: { + padding: '48px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 1280, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 42, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 12, + }, + heroSubtitle: { + fontSize: 16, + color: '#8E8EA0', + maxWidth: 560, + margin: '0 auto 32px', + lineHeight: 1.6, + }, + statsRow: { + display: 'flex', + justifyContent: 'center', + gap: 16, + marginBottom: 40, + flexWrap: 'wrap' as const, + }, + poolCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 14, + padding: '16px 24px', + minWidth: 180, + }, + poolHeader: { + display: 'flex', + alignItems: 'center', + gap: 8, + marginBottom: 8, + justifyContent: 'center', + }, + poolLabel: { + fontSize: 12, + fontWeight: 600, + color: '#8E8EA0', + textTransform: 'uppercase' as const, + letterSpacing: '0.04em', + }, + poolValue: { + fontSize: 20, + fontWeight: 800, + fontVariantNumeric: 'tabular-nums', + }, + summaryCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 14, + padding: '16px 24px', + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + justifyContent: 'center', + }, + summaryRow: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: 24, + }, + summaryLabel: { + fontSize: 12, + color: '#8E8EA0', + }, + summaryValue: { + fontSize: 14, + fontWeight: 700, + color: '#F0F0F5', + fontVariantNumeric: 'tabular-nums', + }, + summaryDivider: { + height: 1, + background: '#2A2A38', + }, +} diff --git a/frontend/src/pages/HowItWorksPage.tsx b/frontend/src/pages/HowItWorksPage.tsx new file mode 100644 index 0000000..3fcc984 --- /dev/null +++ b/frontend/src/pages/HowItWorksPage.tsx @@ -0,0 +1,874 @@ +import React, { useState } from 'react' +import { + Coins, TrendingUp, Shuffle, Trophy, ArrowRight, Clock, Zap, + Shield, Lock, ChevronDown, Eye, Hash, Target, Layers, FileCheck +} from 'lucide-react' +import { useStore } from '../store/useStore' +import { formatNumber } from '../utils/formatNumber' +import RewardsCalculator from '../components/RewardsCalculator' + +// ── Step data ── +const steps = [ + { + icon: Coins, + color: '#8B6FFF', + bg: 'rgba(139, 111, 255, 0.1)', + borderColor: 'rgba(139, 111, 255, 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.', + }, + { + icon: TrendingUp, + color: '#22c55e', + bg: 'rgba(34, 197, 94, 0.1)', + borderColor: 'rgba(34, 197, 94, 0.2)', + title: 'Earn Base Yield', + short: '5% of rewards boost csINJ value', + detail: '5% of all staking rewards are added to the INJ backing pool, increasing the csINJ exchange rate for all holders. This provides a guaranteed minimum yield regardless of draw outcomes. The exchange rate is calculated as total_inj_backing / total_csinj_supply.', + }, + { + icon: Shuffle, + color: '#38bdf8', + bg: 'rgba(56, 189, 248, 0.1)', + borderColor: 'rgba(56, 189, 248, 0.2)', + title: 'Enter Prize Draws', + short: '90% of rewards fund prize pools', + detail: 'The remaining 90% of staking rewards (after protocol fees) are split between regular and big draw prize pools. Your csINJ balance at the time of the snapshot determines your ticket weight — more csINJ means more chances to win. Every holder is automatically entered.', + }, + { + icon: Trophy, + color: '#f59e0b', + bg: 'rgba(245, 158, 11, 0.1)', + borderColor: 'rgba(245, 158, 11, 0.2)', + title: 'Win Prizes', + short: 'Verifiable randomness selects winners', + detail: 'Winners are selected using drand randomness beacons combined with an operator commit-reveal scheme. This ensures the result is publicly verifiable and tamper-proof. The winning ticket is mapped to a holder\'s cumulative weight range, verified via merkle proofs.', + }, +] + +// ── Reward distribution data ── +const distributions = [ + { label: 'Regular Draws', pct: 70, color: '#8B6FFF', gradient: 'linear-gradient(90deg, #8B6FFF, #6B4FD6)' }, + { 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' }, +] + +// ── Draw lifecycle steps ── +const drawSteps = [ + { + icon: Layers, + color: '#38bdf8', + title: '1. Snapshot', + desc: 'All csINJ holders\' balances are recorded as cumulative weight ranges. A merkle root of the snapshot is published on-chain.', + code: 'leaf = sha256(address || start_be128 || end_be128)', + }, + { + icon: Lock, + color: '#8B6FFF', + 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)', + }, + { + icon: Zap, + color: '#f59e0b', + title: '3. Beacon', + desc: 'The drand quicknet network produces a publicly verifiable randomness beacon at the target round. Anyone can verify the BLS threshold signature.', + code: 'drand_randomness = verify(round, signature)', + }, + { + icon: Eye, + color: '#22c55e', + title: '4. Reveal', + desc: 'The operator reveals their secret. The final randomness is computed by XOR-ing the drand beacon with the hash of the operator secret.', + code: 'final = drand_randomness XOR sha256(operator_secret)', + }, + { + icon: Hash, + color: '#f472b6', + title: '5. Selection', + desc: 'The winning ticket number is derived from the final randomness. This is a uniform random number within the total weight range.', + code: 'ticket = u128(final[0..16]) % total_weight', + }, + { + icon: Target, + color: '#fbbf24', + title: '6. Winner', + desc: 'The holder whose cumulative weight range [start, end) contains the winning ticket is the winner. Verified via merkle inclusion proof.', + code: 'winner: start <= ticket < end', + }, +] + +// ── FAQ data ── +const faqItems = [ + { + q: 'Is my staked INJ at risk?', + a: 'No. Your principal is natively staked with Injective validators — the same delegation mechanism used by all Injective stakers. The protocol never lends, farms, or puts your INJ at additional risk. You can always unstake and receive your INJ back after the 21-day unbonding period.', + }, + { + q: 'How do I increase my chances of winning?', + a: 'Your win probability is directly proportional to your csINJ balance relative to the total pool. Staking more INJ gives you more csINJ, which increases your share of the ticket weight. There\'s no minimum stake required.', + }, + { + q: 'What happens when I unstake?', + a: 'When you unstake, your csINJ is burned and the equivalent INJ enters a 21-day unbonding period (standard Injective unbonding). During unbonding, you are no longer eligible for prize draws. After 21 days, you can claim your INJ.', + }, + { + q: 'Can the operator cheat?', + a: 'The commit-reveal scheme combined with drand randomness makes it computationally infeasible for the operator to predict or manipulate the draw outcome. The operator commits to their secret before the drand beacon is produced, and the beacon is verified on-chain. However, the operator could choose not to reveal an unfavorable result (letting the draw expire), which returns funds to the pool rather than paying out. Future versions may address this.', + }, + { + q: 'What is csINJ?', + a: 'csINJ is a liquid staking token created via Injective\'s Token Factory. It represents your staked INJ position. The exchange rate between csINJ and INJ increases over time as staking rewards accrue to the backing pool. 1 csINJ is always redeemable for its current exchange rate worth of INJ.', + }, + { + q: 'How is the exchange rate calculated?', + a: 'The exchange rate is total_inj_backing / total_csinj_supply. As base yield (5% of staking rewards) is added to the backing, the rate increases. When you stake, you receive csINJ at the current rate. When you unstake, your csINJ is worth more INJ than when you staked.', + }, +] + +export default function HowItWorksPage() { + const epochDurationSeconds = useStore((s) => s.epochDurationSeconds) + const minEpochsRegular = useStore((s) => s.minEpochsRegular) + const minEpochsBig = useStore((s) => s.minEpochsBig) + + const [hoveredStep, setHoveredStep] = useState(null) + const [hoveredDist, setHoveredDist] = useState(null) + 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` + + return ( +
+ {/* ── Hero ── */} +
+
+

How Chance.Staking Works

+

+ Chance.Staking is a prize-linked staking protocol on Injective. Stake INJ to earn + base yield while your staking rewards fund verifiable on-chain prize draws. Your + principal stays safe — only rewards are gamified. +

+
+
+ + {/* ── Visual Flow ── */} +
+
+

Four Steps to Winning

+

From staking to prize draws in a simple flow

+ +
+ {steps.map((step, i) => ( + +
setHoveredStep(i)} + onMouseLeave={() => setHoveredStep(null)} + > +
{String(i + 1).padStart(2, '0')}
+
+ +
+

{step.title}

+

{step.short}

+

{step.detail}

+
+ {i < steps.length - 1 && ( +
+ +
+ )} +
+ ))} +
+
+
+ + {/* ── Reward Distribution ── */} +
+
+

Reward Distribution

+

How staking rewards are allocated each epoch

+ +
+
+ {distributions.map((d, i) => ( +
setHoveredDist(i)} + onMouseLeave={() => setHoveredDist(null)} + > + {d.pct >= 10 && ( + {d.pct}% + )} +
+ ))} +
+
+ {distributions.map((d, i) => ( +
setHoveredDist(i)} + onMouseLeave={() => setHoveredDist(null)} + > +
+ {d.label} + ({d.pct}%) +
+ ))} +
+ +
+

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.

+
+
+
+
+ + {/* ── Epoch & Draw Timing ── */} +
+
+

Epoch & Draw Timing

+

Live on-chain parameters that govern draw frequency

+ +
+
+ +
{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. +
+
+
+ +
Every {minEpochsRegular || 1} epoch{(minEpochsRegular || 1) > 1 ? 's' : ''}
+
Regular Draws
+
+ Regular draws can occur every {minEpochsRegular || 1} epoch{(minEpochsRegular || 1) > 1 ? 's' : ''} ({formatNumber((minEpochsRegular || 1) * epochHours, 0)} hours). The full regular pool balance is awarded as the prize. +
+
+
+ +
Every {minEpochsBig || 7} epoch{(minEpochsBig || 7) > 1 ? 's' : ''}
+
Big Jackpot Draws
+
+ Big draws occur every {minEpochsBig || 7} epoch{(minEpochsBig || 7) > 1 ? 's' : ''} ({formatNumber((minEpochsBig || 7) * epochHours, 0)} hours). The pool accumulates over multiple epochs for larger prizes. +
+
+
+ + {/* Timeline */} +
+
Draw Lifecycle
+
+ {['Epoch Start', 'Rewards Claimed', 'Snapshot Taken', 'Draw Committed', 'drand Beacon', 'Draw Revealed'].map((label, i) => ( +
+
+ {label} + {i < 5 &&
} +
+ ))} +
+
+
+
+ + {/* ── Winner Selection Math ── */} +
+
+

How Winners Are Selected

+

+ A step-by-step breakdown of the verifiable random winner selection process +

+ +
+ {drawSteps.map((step, i) => ( +
+
+
+ +
+

{step.title}

+
+

{step.desc}

+
+ {step.code} +
+
+ ))} +
+
+
+ + {/* ── Rewards Calculator ── */} +
+ +
+ + {/* ── Security & Randomness ── */} +
+
+

Security & Randomness

+

+ Multiple layers of cryptographic security ensure fair, verifiable draws +

+ +
+
+
+ +
+

drand Randomness

+

+ Randomness comes from drand quicknet, operated by the League of Entropy — a consortium including Cloudflare, Protocol Labs, and university researchers. Each beacon uses BLS threshold signatures requiring a quorum of independent parties, making it impossible for any single entity to predict or manipulate the output. +

+
+
+
+ +
+

Commit-Reveal Scheme

+

+ The operator commits a hash of their secret before the drand beacon is produced. The final randomness is the XOR of both sources. This prevents the operator from choosing a favorable secret after seeing the beacon, and prevents the drand network from biasing results since the operator's contribution is hidden until reveal. +

+
+
+
+ +
+

Merkle Proof Verification

+

+ Winner inclusion is verified via sorted-pair merkle proofs. Each leaf is sha256(address || start || end) where start/end are big-endian u128 cumulative weights. Anyone can independently verify that the declared winner's weight range contains the winning ticket. +

+
+
+
+
+ + {/* ── FAQ ── */} +
+
+

Frequently Asked Questions

+ +
+ {faqItems.map((item, i) => ( +
+ + {openFaq === i && ( +
+ {item.a} +
+ )} +
+ ))} +
+
+
+ + {/* ── CTA ── */} +
+
+
+

Ready to start?

+

+ Stake INJ and start earning base yield while entering verifiable prize draws. +

+ +
+
+
+
+ ) +} + +const styles: Record = { + page: { + paddingTop: 64, + }, + + // ── Hero ── + hero: { + padding: '56px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 720, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 46, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 16, + }, + heroSubtitle: { + fontSize: 16, + color: '#8E8EA0', + lineHeight: 1.7, + marginBottom: 0, + }, + + // ── Section ── + section: { + padding: '64px 0', + }, + container: { + maxWidth: 960, + margin: '0 auto', + padding: '0 24px', + }, + sectionTitle: { + fontSize: 32, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + textAlign: 'center', + marginBottom: 8, + }, + sectionSubtitle: { + fontSize: 14, + color: '#8E8EA0', + textAlign: 'center', + marginBottom: 40, + }, + + // ── Steps ── + stepsGrid: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + gap: 0, + flexWrap: 'wrap' as const, + }, + stepCard: { + flex: '0 0 200px', + textAlign: 'center' as const, + padding: 20, + borderRadius: 16, + border: '1px solid #2A2A38', + transition: 'all 0.3s ease', + cursor: 'default', + }, + stepNumber: { + fontSize: 11, + fontWeight: 700, + color: '#525260', + letterSpacing: '0.1em', + marginBottom: 12, + }, + stepIcon: { + width: 48, + height: 48, + borderRadius: 14, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: '0 auto 12px', + }, + stepTitle: { + fontSize: 15, + fontWeight: 700, + color: '#F0F0F5', + marginBottom: 6, + }, + stepShort: { + fontSize: 12, + color: '#8B6FFF', + fontWeight: 600, + marginBottom: 8, + }, + stepDetail: { + fontSize: 12, + lineHeight: 1.6, + color: '#8E8EA0', + }, + stepArrow: { + display: 'flex', + alignItems: 'center', + paddingTop: 60, + }, + + // ── Distribution ── + splitCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 20, + padding: 28, + maxWidth: 720, + margin: '0 auto', + }, + splitBar: { + display: 'flex', + height: 40, + borderRadius: 10, + overflow: 'hidden', + gap: 2, + marginBottom: 20, + }, + splitSegment: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 6, + transformOrigin: 'bottom', + }, + splitLabel: { + fontSize: 12, + fontWeight: 700, + color: '#FFFFFF', + textShadow: '0 1px 3px rgba(0,0,0,0.4)', + }, + splitLegend: { + display: 'flex', + justifyContent: 'center', + gap: 20, + flexWrap: 'wrap' as const, + marginBottom: 24, + }, + legendItem: { + display: 'flex', + alignItems: 'center', + gap: 7, + fontSize: 12, + cursor: 'default', + transition: 'color 0.2s', + }, + legendDot: { + width: 8, + height: 8, + borderRadius: '50%', + flexShrink: 0, + }, + distDetail: { + display: 'flex', + flexDirection: 'column' as const, + gap: 10, + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.6, + borderTop: '1px solid #2A2A38', + paddingTop: 20, + }, + + // ── Timing ── + timingGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: 16, + marginBottom: 32, + }, + timingCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 22, + textAlign: 'center' as const, + }, + timingValue: { + fontSize: 20, + fontWeight: 800, + color: '#F0F0F5', + margin: '12px 0 4px', + }, + timingLabel: { + fontSize: 12, + fontWeight: 600, + color: '#8E8EA0', + textTransform: 'uppercase' as const, + letterSpacing: '0.04em', + marginBottom: 10, + }, + timingDetail: { + fontSize: 12, + color: '#8E8EA0', + lineHeight: 1.6, + }, + + // ── Timeline ── + timelineCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: '20px 24px', + }, + timelineTitle: { + fontSize: 13, + fontWeight: 700, + color: '#F0F0F5', + marginBottom: 16, + textAlign: 'center' as const, + }, + timeline: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexWrap: 'wrap' as const, + gap: 0, + }, + timelineStep: { + display: 'flex', + alignItems: 'center', + gap: 6, + }, + timelineDot: { + width: 8, + height: 8, + borderRadius: '50%', + flexShrink: 0, + }, + timelineLabel: { + fontSize: 11, + color: '#8E8EA0', + whiteSpace: 'nowrap' as const, + }, + timelineLine: { + width: 24, + height: 1, + background: '#2A2A38', + margin: '0 4px', + flexShrink: 0, + }, + + // ── Math / Draw Steps ── + mathGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gap: 16, + }, + mathCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 20, + }, + mathHeader: { + display: 'flex', + alignItems: 'center', + gap: 10, + marginBottom: 10, + }, + mathIcon: { + width: 32, + height: 32, + borderRadius: 8, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + mathTitle: { + fontSize: 14, + fontWeight: 700, + color: '#F0F0F5', + }, + mathDesc: { + fontSize: 12, + color: '#8E8EA0', + lineHeight: 1.6, + marginBottom: 10, + }, + codeBlock: { + background: '#0F0F13', + borderRadius: 8, + padding: '8px 12px', + overflow: 'auto', + }, + code: { + fontSize: 11, + fontFamily: "'JetBrains Mono', monospace", + color: '#38bdf8', + }, + + // ── Security ── + securityGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: 16, + }, + securityCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 24, + }, + securityIcon: { + width: 44, + height: 44, + borderRadius: 12, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 14, + }, + securityTitle: { + fontSize: 16, + fontWeight: 700, + color: '#F0F0F5', + marginBottom: 8, + }, + securityDesc: { + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.7, + }, + inlineCode: { + background: '#0F0F13', + padding: '2px 6px', + borderRadius: 4, + fontSize: 11, + fontFamily: "'JetBrains Mono', monospace", + color: '#38bdf8', + }, + + // ── FAQ ── + faqList: { + maxWidth: 720, + margin: '0 auto', + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + }, + faqItem: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 12, + overflow: 'hidden', + transition: 'border-color 0.2s', + }, + faqQuestion: { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + padding: '16px 18px', + background: 'transparent', + border: 'none', + color: '#F0F0F5', + fontSize: 14, + fontWeight: 600, + cursor: 'pointer', + textAlign: 'left' as const, + }, + faqAnswer: { + padding: '0 18px 16px', + fontSize: 13, + color: '#8E8EA0', + lineHeight: 1.7, + }, + + // ── CTA ── + ctaSection: { + padding: '0 0 80px', + }, + ctaCard: { + background: 'linear-gradient(135deg, rgba(139, 111, 255, 0.08), rgba(56, 189, 248, 0.05))', + border: '1px solid rgba(139, 111, 255, 0.2)', + borderRadius: 20, + padding: '48px 32px', + textAlign: 'center' as const, + maxWidth: 600, + margin: '0 auto', + }, + ctaTitle: { + fontSize: 28, + fontWeight: 800, + color: '#F0F0F5', + marginBottom: 10, + }, + ctaDesc: { + fontSize: 14, + color: '#8E8EA0', + marginBottom: 24, + lineHeight: 1.6, + }, + ctaButtons: { + display: 'flex', + justifyContent: 'center', + gap: 12, + }, + ctaPrimary: { + display: 'inline-flex', + alignItems: 'center', + padding: '12px 28px', + borderRadius: 10, + background: 'linear-gradient(135deg, #8B6FFF, #6B4FD6)', + color: '#FFFFFF', + fontSize: 14, + fontWeight: 600, + textDecoration: 'none', + transition: 'opacity 0.2s', + }, + ctaSecondary: { + display: 'inline-flex', + alignItems: 'center', + padding: '12px 28px', + borderRadius: 10, + background: 'transparent', + border: '1px solid #2A2A38', + color: '#F0F0F5', + fontSize: 14, + fontWeight: 600, + textDecoration: 'none', + transition: 'border-color 0.2s', + }, +} diff --git a/frontend/src/pages/PrivacyPage.tsx b/frontend/src/pages/PrivacyPage.tsx new file mode 100644 index 0000000..91f991d --- /dev/null +++ b/frontend/src/pages/PrivacyPage.tsx @@ -0,0 +1,218 @@ +import React from 'react' + +export default function PrivacyPage() { + return ( +
+
+
+

Privacy Policy

+

Last updated: February 2025

+
+
+ +
+
+

+ This Privacy Policy describes how the Chance.Staking protocol ("Protocol") handles + information when you use our website and interact with our smart contracts on the + Injective blockchain. We are committed to transparency about our data practices. +

+
+ +
+

+ The Protocol is a decentralized application. We do not collect, store, or process: +

+
    +
  • Personal identification information (name, email, phone number)
  • +
  • Account credentials or passwords
  • +
  • Private keys or seed phrases
  • +
  • Financial information beyond what is publicly visible on-chain
  • +
+
+ +
+

+ When you interact with the Protocol's smart contracts, your transactions are recorded + on the Injective blockchain. This data is publicly accessible and includes: +

+
    +
  • Your wallet address
  • +
  • Transaction amounts and timestamps
  • +
  • Staking and unstaking activity
  • +
  • Prize draw participation and results
  • +
+

+ This on-chain data is inherent to blockchain technology and is not controlled by us. + It is permanently recorded on the public blockchain and cannot be deleted or modified. +

+
+ +
+

+ Our website may use minimal, privacy-respecting analytics to understand general + usage patterns (such as page views and visitor counts). We do not use invasive + tracking technologies, fingerprinting, or cross-site tracking. No personal data + is collected through analytics. +

+
+ +
+

+ When you connect your wallet (e.g., MetaMask, Keplr) to the Protocol, the + connection is handled entirely by your wallet provider. We only receive your + public wallet address, which is necessary to interact with the smart contracts. + We do not have access to your private keys or wallet credentials. +

+
+ +
+

+ The website may use local storage (browser storage) to remember your preferences + such as wallet connection state. This data is stored locally on your device and + is not transmitted to any server. You can clear this data at any time through + your browser settings. +

+
+ +
+

+ The Protocol may interact with third-party services including: +

+
    +
  • + Injective blockchain nodes — for + submitting and querying transactions +
  • +
  • + drand network — for verifiable + randomness used in prize draws +
  • +
  • + Wallet providers — for signing + transactions (MetaMask, Keplr, etc.) +
  • +
+

+ Each of these services has its own privacy policy. We encourage you to review + their respective policies. +

+
+ +
+

+ Since the Protocol does not collect or store personal data on centralized servers, + there is no centralized database to breach. All interactions occur directly between + your browser/wallet and the Injective blockchain. Smart contract security is + maintained through code audits and on-chain transparency. +

+
+ +
+

+ The Protocol is not intended for use by individuals under the age of 18. We do not + knowingly collect information from minors. +

+
+ +
+

+ We may update this Privacy Policy from time to time. Changes will be reflected by + updating the "Last updated" date at the top of this page. We encourage you to + review this policy periodically. +

+
+ +
+

+ For questions about this Privacy Policy, please reach out via our community channels + or open an issue on our{' '} + + GitHub repository + . +

+
+
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +function P({ children }: { children: React.ReactNode }) { + return

{children}

+} + +const styles: Record = { + page: { + paddingTop: 64, + }, + hero: { + padding: '56px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 720, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 46, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 16, + }, + heroSubtitle: { + fontSize: 14, + color: '#8E8EA0', + marginBottom: 0, + }, + content: { + maxWidth: 720, + margin: '0 auto', + padding: '40px 24px 80px', + }, + section: { + marginBottom: 32, + }, + sectionTitle: { + fontSize: 18, + fontWeight: 700, + color: '#F0F0F5', + marginBottom: 12, + }, + paragraph: { + fontSize: 14, + color: '#8E8EA0', + lineHeight: 1.8, + marginBottom: 12, + }, + list: { + margin: '0 0 12px 0', + paddingLeft: 20, + }, + listItem: { + fontSize: 14, + color: '#8E8EA0', + lineHeight: 1.8, + marginBottom: 4, + }, + link: { + color: '#8B6FFF', + textDecoration: 'underline', + }, +} diff --git a/frontend/src/pages/StakingPage.tsx b/frontend/src/pages/StakingPage.tsx new file mode 100644 index 0000000..b7c7f33 --- /dev/null +++ b/frontend/src/pages/StakingPage.tsx @@ -0,0 +1,116 @@ +import React from 'react' +import { TrendingUp, Clock, Layers } from 'lucide-react' +import { useStore } from '../store/useStore' +import { formatNumber } from '../utils/formatNumber' +import StakingPanel from '../components/StakingPanel' +import PortfolioSection from '../components/PortfolioSection' +import EpochCountdown from '../components/EpochCountdown' + +export default function StakingPage() { + const exchangeRate = useStore((s) => s.exchangeRate) + const totalInjBacking = useStore((s) => s.totalInjBacking) + const isConnected = useStore((s) => s.isConnected) + + const tvl = parseFloat(totalInjBacking) / 1e18 + + return ( +
+ {/* Page hero */} +
+
+

Stake INJ

+

+ Deposit INJ, receive csINJ, and automatically enter prize draws with your staking rewards. +

+ +
+
+ +
+
+ {formatNumber(parseFloat(exchangeRate), 4)} +
+
csINJ Rate
+
+
+
+ +
+
+ {formatNumber(tvl, 1)} INJ +
+
Total Value Locked
+
+
+
+ +
+ +
+
+
+
+
+ + + {isConnected && } +
+ ) +} + +const styles: Record = { + page: { + paddingTop: 64, + }, + hero: { + padding: '48px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 1280, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 42, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 12, + }, + heroSubtitle: { + fontSize: 16, + color: '#8E8EA0', + maxWidth: 520, + margin: '0 auto 32px', + lineHeight: 1.6, + }, + statsRow: { + display: 'flex', + justifyContent: 'center', + gap: 16, + marginBottom: 40, + flexWrap: 'wrap' as const, + }, + statCard: { + display: 'flex', + alignItems: 'center', + gap: 10, + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 12, + padding: '12px 20px', + }, + statValue: { + fontSize: 15, + fontWeight: 700, + color: '#F0F0F5', + fontVariantNumeric: 'tabular-nums', + }, + statLabel: { + fontSize: 11, + color: '#8E8EA0', + marginTop: 1, + }, +} diff --git a/frontend/src/pages/TermsPage.tsx b/frontend/src/pages/TermsPage.tsx new file mode 100644 index 0000000..c92e606 --- /dev/null +++ b/frontend/src/pages/TermsPage.tsx @@ -0,0 +1,218 @@ +import React from 'react' + +export default function TermsPage() { + return ( +
+
+
+

Terms of Use

+

Last updated: February 2025

+
+
+ +
+
+

+ By accessing or using the Chance.Staking protocol ("Protocol"), including the + website, smart contracts, and any related services, you agree to be bound by these + Terms of Use. If you do not agree, do not use the Protocol. +

+
+ +
+

+ Chance.Staking is a prize-linked staking protocol deployed on the Injective blockchain. + Users stake INJ tokens, receive csINJ (a liquid staking token), and are entered into + periodic prize draws funded by staking rewards. The Protocol consists of three smart + contracts: Staking Hub, Reward Distributor, and drand Oracle. +

+
+ +
+

+ You must be of legal age in your jurisdiction to use the Protocol. You are solely + responsible for ensuring that your use of the Protocol complies with all applicable + laws and regulations in your jurisdiction. The Protocol is not available to persons + or entities subject to sanctions or located in jurisdictions where participation in + decentralized finance protocols or prize-linked savings products is prohibited. +

+
+ +
+

+ Nothing provided by the Protocol constitutes financial, investment, legal, or tax + advice. You should consult your own advisors before making any financial decisions. + Staking, unstaking, and participating in prize draws involve risks, including but not + limited to smart contract risk, validator slashing, and market volatility. +

+
+ +
+

+ The Protocol operates via smart contracts on the Injective blockchain. While the + contracts have been audited, no audit eliminates all risk. Smart contracts may + contain bugs, vulnerabilities, or behave unexpectedly. By using the Protocol, you + acknowledge and accept these risks. You are responsible for your own due diligence. +

+
+ +
+

+ When you stake INJ, you receive csINJ at the current exchange rate. Unstaking + requires a 21-day unbonding period (determined by the Injective network). During + the unbonding period, your INJ is locked and cannot be accessed. The exchange rate + between csINJ and INJ may fluctuate based on staking rewards and validator + performance. +

+
+ +
+

+ Prize draws are funded by staking rewards, not by user principal. Eligibility for + draws requires holding csINJ for a minimum number of epochs. Re-staking resets + your eligibility timer. The probability of winning is proportional to your csINJ + holdings. Draw outcomes are determined by verifiable randomness from the drand + network combined with an operator-committed secret. +

+

+ The operator may, under certain circumstances, allow a draw to expire rather than + revealing it. Expired draws return funds to the prize pool and do not result in + loss of user funds, but may affect the fairness of draw distribution. +

+
+ +
+

+ The Protocol charges a protocol fee on staking rewards as configured in the smart + contracts. This fee is deducted before rewards are distributed to prize pools and + base yield. Fee parameters are visible on-chain and may be updated by the protocol + administrator. +

+
+ +
+

+ The Protocol is provided "as is" and "as available" without warranties of any kind, + whether express or implied. We do not guarantee that the Protocol will be + uninterrupted, error-free, or secure. We make no warranties regarding the accuracy, + reliability, or completeness of any information provided through the Protocol. +

+
+ +
+

+ To the maximum extent permitted by law, the Protocol developers, operators, and + contributors shall not be liable for any indirect, incidental, special, + consequential, or punitive damages, or any loss of profits or revenues, whether + incurred directly or indirectly, or any loss of data, use, goodwill, or other + intangible losses resulting from your use of the Protocol. +

+
+ +
+

+ You agree to indemnify and hold harmless the Protocol developers, operators, and + contributors from any claims, damages, losses, liabilities, and expenses arising + out of or related to your use of the Protocol or your violation of these Terms. +

+
+ +
+

+ We reserve the right to modify these Terms at any time. Changes will be reflected + by updating the "Last updated" date. Continued use of the Protocol after changes + constitutes acceptance of the modified Terms. +

+
+ +
+

+ These Terms shall be governed by and construed in accordance with applicable law, + without regard to conflict of law principles. Any disputes arising from these Terms + or your use of the Protocol shall be resolved through binding arbitration. +

+
+ +
+

+ For questions about these Terms, please reach out via our community channels or + open an issue on our{' '} + + GitHub repository + . +

+
+
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ) +} + +function P({ children }: { children: React.ReactNode }) { + return

{children}

+} + +const styles: Record = { + page: { + paddingTop: 64, + }, + hero: { + padding: '56px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 720, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 46, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 16, + }, + heroSubtitle: { + fontSize: 14, + color: '#8E8EA0', + marginBottom: 0, + }, + content: { + maxWidth: 720, + margin: '0 auto', + padding: '40px 24px 80px', + }, + section: { + marginBottom: 32, + }, + sectionTitle: { + fontSize: 18, + fontWeight: 700, + color: '#F0F0F5', + marginBottom: 12, + }, + paragraph: { + fontSize: 14, + color: '#8E8EA0', + lineHeight: 1.8, + marginBottom: 12, + }, + link: { + color: '#8B6FFF', + textDecoration: 'underline', + }, +} diff --git a/frontend/src/pages/ValidatorsPage.tsx b/frontend/src/pages/ValidatorsPage.tsx new file mode 100644 index 0000000..1423700 --- /dev/null +++ b/frontend/src/pages/ValidatorsPage.tsx @@ -0,0 +1,438 @@ +import React, { useEffect, useState } from 'react' +import { Shield, ExternalLink, Percent, TrendingUp, Coins, PieChart } from 'lucide-react' +import { useStore } from '../store/useStore' +import { formatNumber } from '../utils/formatNumber' +import * as contracts from '../services/contracts' + +interface ValidatorInfo { + address: string + moniker: string + commission: number + delegationAmount: number + shareOfTotal: number + effectiveApr: number +} + +export default function ValidatorsPage() { + const validators = useStore((s) => s.validators) + const [validatorData, setValidatorData] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [nominalApr, setNominalApr] = useState(0) + + useEffect(() => { + if (validators.length === 0) return + let cancelled = false + + async function fetchData() { + setLoading(true) + setError('') + try { + const [provisions, pool, ...results] = await Promise.allSettled([ + contracts.fetchAnnualProvisions(), + contracts.fetchStakingPool(), + ...validators.map((addr) => + Promise.all([ + contracts.fetchValidatorDetails(addr).catch(() => null), + contracts.fetchProtocolDelegation(addr).catch(() => null), + ]) + ), + ]) + + if (cancelled) return + + // Calculate nominal APR + let apr = 0 + if (provisions.status === 'fulfilled' && pool.status === 'fulfilled') { + const annualProv = parseFloat(provisions.value.annualProvisions) / 1e18 + const bonded = parseFloat(pool.value.bondedTokens) + if (bonded > 0 && annualProv > 0) { + apr = (annualProv / bonded) * 100 + } + } + setNominalApr(apr) + + // Build validator info + const infos: ValidatorInfo[] = [] + let totalDelegated = 0 + + for (let i = 0; i < validators.length; i++) { + const result = results[i] + if (result.status !== 'fulfilled') continue + const [validator, delegation] = result.value + + const delegated = delegation ? parseFloat(delegation.balance.amount) / 1e18 : 0 + totalDelegated += delegated + + const commission = validator + ? parseFloat(validator.commission.commissionRates.rate) + : 0 + + infos.push({ + address: validators[i], + moniker: validator?.description?.moniker || validators[i].slice(0, 16) + '...', + commission, + delegationAmount: delegated, + shareOfTotal: 0, + effectiveApr: apr * (1 - commission), + }) + } + + // Calculate shares + for (const info of infos) { + info.shareOfTotal = totalDelegated > 0 ? (info.delegationAmount / totalDelegated) * 100 : 0 + } + + // Sort by delegation (largest first) + infos.sort((a, b) => b.delegationAmount - a.delegationAmount) + setValidatorData(infos) + } catch (err: any) { + if (!cancelled) setError(err?.message || 'Failed to load validator data') + } finally { + if (!cancelled) setLoading(false) + } + } + + fetchData() + return () => { cancelled = true } + }, [validators]) + + const totalDelegated = validatorData.reduce((sum, v) => sum + v.delegationAmount, 0) + const weightedApr = validatorData.length > 0 && totalDelegated > 0 + ? validatorData.reduce((sum, v) => sum + v.effectiveApr * v.delegationAmount, 0) / totalDelegated + : 0 + + return ( +
+
+
+

Protocol Validators

+

+ Chance.Staking delegates INJ across multiple validators for security and decentralization. + All staking rewards fund the prize pools and base yield. +

+ +
+
+ +
+
{formatNumber(totalDelegated, 1)} INJ
+
Total Delegated
+
+
+
+ +
+
{formatNumber(weightedApr, 2)}%
+
Weighted APR
+
+
+
+ +
+
{validatorData.length}
+
Active Validators
+
+
+
+
+
+ +
+
+ {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+ {validatorData.map((v) => ( +
+
+
+
+ +
+
+
{v.moniker}
+
+ {v.address.slice(0, 16)}...{v.address.slice(-8)} +
+
+
+ + + +
+ +
+
+
+ + Commission +
+ + {(v.commission * 100).toFixed(1)}% + +
+
+
+ + Effective APR +
+ + {formatNumber(v.effectiveApr, 2)}% + +
+
+
+ + Delegated +
+ + {formatNumber(v.delegationAmount, 1)} INJ + +
+
+
+ + Allocation +
+ + {formatNumber(v.shareOfTotal, 1)}% + +
+
+ + {/* Allocation bar */} +
+
+
+
+ ))} +
+ )} + + {/* Info note */} +
+ + + The protocol distributes delegations across validators to reduce centralization risk. + Validators can be updated by governance. Nominal network APR: {formatNumber(nominalApr, 2)}%. + +
+
+
+
+ ) +} + +const styles: Record = { + page: { + paddingTop: 64, + }, + hero: { + padding: '48px 0 0', + background: 'linear-gradient(180deg, rgba(139, 111, 255, 0.04) 0%, transparent 100%)', + }, + heroContainer: { + maxWidth: 1280, + margin: '0 auto', + padding: '0 24px', + textAlign: 'center', + }, + heroTitle: { + fontSize: 42, + fontWeight: 800, + color: '#F0F0F5', + letterSpacing: '-0.03em', + marginBottom: 12, + }, + heroSubtitle: { + fontSize: 16, + color: '#8E8EA0', + maxWidth: 560, + margin: '0 auto 32px', + lineHeight: 1.6, + }, + statsRow: { + display: 'flex', + justifyContent: 'center', + gap: 16, + marginBottom: 40, + flexWrap: 'wrap' as const, + }, + statCard: { + display: 'flex', + alignItems: 'center', + gap: 10, + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 12, + padding: '12px 20px', + }, + statValue: { + fontSize: 15, + fontWeight: 700, + color: '#F0F0F5', + fontVariantNumeric: 'tabular-nums', + }, + statLabel: { + fontSize: 11, + color: '#8E8EA0', + marginTop: 1, + }, + content: { + padding: '0 0 80px', + }, + container: { + maxWidth: 900, + margin: '0 auto', + padding: '0 24px', + }, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', + gap: 16, + }, + card: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 20, + transition: 'border-color 0.2s', + }, + cardHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 16, + }, + monikerRow: { + display: 'flex', + alignItems: 'center', + gap: 10, + }, + validatorIcon: { + width: 36, + height: 36, + borderRadius: 10, + background: 'rgba(139, 111, 255, 0.1)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + moniker: { + fontSize: 15, + fontWeight: 700, + color: '#F0F0F5', + }, + address: { + fontSize: 11, + color: '#8E8EA0', + fontFamily: "'JetBrains Mono', monospace", + marginTop: 2, + }, + explorerLink: { + color: '#8E8EA0', + padding: 6, + borderRadius: 8, + transition: 'color 0.2s', + }, + cardMetrics: { + display: 'flex', + flexDirection: 'column' as const, + gap: 8, + }, + metricRow: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + metricLeft: { + display: 'flex', + alignItems: 'center', + gap: 8, + }, + metricLabel: { + fontSize: 12, + color: '#8E8EA0', + }, + metricValue: { + fontSize: 13, + fontWeight: 700, + color: '#F0F0F5', + fontVariantNumeric: 'tabular-nums', + }, + allocBar: { + height: 4, + borderRadius: 2, + background: '#0F0F13', + marginTop: 14, + overflow: 'hidden', + }, + allocFill: { + height: '100%', + borderRadius: 2, + background: 'linear-gradient(90deg, #8B6FFF, #6B4FD6)', + transition: 'width 0.4s ease', + }, + skeletonCard: { + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 16, + padding: 24, + display: 'flex', + flexDirection: 'column' as const, + gap: 10, + }, + skeletonBar: { + height: 14, + borderRadius: 6, + background: 'linear-gradient(90deg, #2A2A38 25%, #1A1A22 50%, #2A2A38 75%)', + backgroundSize: '200% 100%', + animation: 'shimmer 1.5s ease infinite', + width: '100%', + }, + errorCard: { + background: '#1A1A22', + border: '1px solid rgba(239, 68, 68, 0.3)', + borderRadius: 16, + padding: 24, + textAlign: 'center' as const, + }, + infoNote: { + display: 'flex', + alignItems: 'flex-start', + gap: 10, + marginTop: 24, + padding: '14px 16px', + background: '#1A1A22', + border: '1px solid #2A2A38', + borderRadius: 12, + fontSize: 12, + color: '#8E8EA0', + lineHeight: 1.6, + }, +} diff --git a/frontend/src/services/contracts.ts b/frontend/src/services/contracts.ts index 43ccbc0..f6476a3 100644 --- a/frontend/src/services/contracts.ts +++ b/frontend/src/services/contracts.ts @@ -4,6 +4,7 @@ import { ChainGrpcStakingApi, ChainGrpcMintApi, MsgExecuteContractCompat, + IndexerGrpcExplorerApi, } from "@injectivelabs/sdk-ts"; import { CONTRACTS, ENDPOINTS, INJ_DENOM, getCsinjDenom } from "../config"; @@ -12,6 +13,7 @@ const wasmApi = new ChainGrpcWasmApi(ENDPOINTS.grpc); const bankApi = new ChainGrpcBankApi(ENDPOINTS.grpc); const stakingApi = new ChainGrpcStakingApi(ENDPOINTS.grpc); const mintApi = new ChainGrpcMintApi(ENDPOINTS.grpc); +const explorerApi = new IndexerGrpcExplorerApi(ENDPOINTS.indexer); // ---------- Types ---------- export interface ExchangeRateResponse { @@ -106,6 +108,42 @@ export interface PoolBalancesResponse { big_pool: string; } +export interface OracleConfig { + admin: string; + operators: string[]; + quicknet_pubkey: number[]; + chain_hash: string; + genesis_time: number; + period_seconds: number; +} + +export interface DistributorConfig { + admin: string; + operator: string; + staking_hub: string; + drand_oracle: string; + reveal_deadline_seconds: number; + epochs_between_regular: number; + epochs_between_big: number; +} + +export interface StoredBeacon { + round: number; + randomness: number[]; + signature: number[]; + verified: boolean; + submitted_at: string; + submitted_by: string; +} + +export interface SnapshotInfo { + epoch: number; + merkle_root: string; + total_weight: string; + num_holders: number; + submitted_at: string; +} + // ---------- Generic query helper ---------- async function queryContract( contractAddress: string, @@ -293,6 +331,69 @@ export async function fetchStakingApr( } } +// ---------- Validator detail queries ---------- +export async function fetchValidatorDetails(validatorAddress: string) { + return stakingApi.fetchValidator(validatorAddress); +} + +export async function fetchProtocolDelegation(validatorAddress: string) { + return stakingApi.fetchDelegation({ + injectiveAddress: CONTRACTS.stakingHub, + validatorAddress, + }); +} + +export async function fetchStakingPool() { + return stakingApi.fetchPool(); +} + +export async function fetchAnnualProvisions() { + return mintApi.fetchAnnualProvisions(); +} + +// ---------- Account transaction history ---------- +export async function fetchAccountTransactions( + address: string, + limit?: number, +) { + return explorerApi.fetchAccountTx({ + address, + limit: limit ?? 20, + }); +} + +// ---------- drand Oracle Queries ---------- +export async function fetchOracleConfig(): Promise { + return queryContract(CONTRACTS.drandOracle, { config: {} }); +} + +export async function fetchLatestRound(): Promise { + return queryContract(CONTRACTS.drandOracle, { latest_round: {} }); +} + +export async function fetchBeacon( + round: number, +): Promise { + return queryContract(CONTRACTS.drandOracle, { + beacon: { round }, + }); +} + +// ---------- Reward Distributor Config ---------- +export async function fetchDistributorConfig(): Promise { + return queryContract(CONTRACTS.rewardDistributor, { + config: {}, + }); +} + +export async function fetchSnapshot( + epoch: number, +): Promise { + return queryContract(CONTRACTS.rewardDistributor, { + snapshot: { epoch }, + }); +} + // ---------- 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 452f488..9921f48 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -47,6 +47,7 @@ interface ContractState { bigPoolBps: number; protocolFeeBps: number; stakingApr: number | null; + validators: string[]; } interface ConfettiState { @@ -173,6 +174,7 @@ export const useStore = create()( bigPoolBps: 2000, protocolFeeBps: 500, stakingApr: null, + validators: [], // Confetti state showConfetti: false, @@ -218,9 +220,9 @@ export const useStore = create()( selectDraw: (drawId) => { set({ selectedDrawId: drawId }); if (drawId !== null) { - window.location.hash = `draw/${drawId}`; + window.location.hash = `#/draws/${drawId}`; } else { - history.replaceState(null, "", window.location.pathname); + window.location.hash = '#/draws'; } }, @@ -311,6 +313,7 @@ export const useStore = create()( epochState.snapshot_total_weight || "0", snapshotNumHolders: epochState.snapshot_num_holders || 0, + validators: hubConfig.validators || [], }); // Fetch on-chain staking APR in background (non-blocking)