diff --git a/cmd/rpc/README.md b/cmd/rpc/README.md index aded080a0..ba83e0f93 100644 --- a/cmd/rpc/README.md +++ b/cmd/rpc/README.md @@ -2595,13 +2595,9 @@ $ curl -X POST localhost:50002/v1/query/order \ - **height**: `uint64` – the block height to read data from (optional: use 0 to read from the latest block) - **committee**: `uint64` – the unique identifier of the committee to filter by (optional: use 0 to get all committees) -- **sellersSendAddress**: `hex-string` – the seller address to filter orders by (optional: use "" to get all seller addresses) -- **buyerSendAddress**: `hex-string` – the buyer address to filter locked orders by (optional: use "" to get all buyer addresses) - **pageNumber**: `int` – the page number to retrieve (optional: starts at 1) - **perPage**: `int` – the number of orders per page (optional: defaults to system default) -**Note**: `sellersSendAddress` and `buyerSendAddress` are mutually exclusive filters. You cannot use both in the same request. - **Response**: - **pageNumber**: `int` - the current page number - **perPage**: `int` - the number of items per page @@ -2670,40 +2666,6 @@ $ curl -X POST localhost:50002/v1/query/orders \ }' ``` -**Example 3: Filter by sellersSendAddress with pagination (uses indexed lookup)** -``` -$ curl -X POST localhost:50002/v1/query/orders \ - -H "Content-Type: application/json" \ - -d '{ - "sellersSendAddress": "bb43c46244cef15f2451a446cea011fc1a2eddfe", - "pageNumber": 1, - "perPage": 10 - }' -``` - -**Example 4: Filter by both committee and sellersSendAddress with pagination (most efficient)** -``` -$ curl -X POST localhost:50002/v1/query/orders \ - -H "Content-Type: application/json" \ - -d '{ - "committee": 1, - "sellersSendAddress": "bb43c46244cef15f2451a446cea011fc1a2eddfe", - "pageNumber": 1, - "perPage": 10 - }' -``` - -**Example 5: Filter by buyerSendAddress with pagination (locked orders only)** -``` -$ curl -X POST localhost:50002/v1/query/orders \ - -H "Content-Type: application/json" \ - -d '{ - "buyerSendAddress": "aaac0b3d64c12c6f164545545b2ba2ab4d80deff", - "pageNumber": 1, - "perPage": 10 - }' -``` - ## Dex Batch **Route:** `/v1/query/dex-batch` **Description**: view the locked dex batch for a committee or all dex batches diff --git a/cmd/rpc/query.go b/cmd/rpc/query.go index 93f6c066e..650ea064c 100644 --- a/cmd/rpc/query.go +++ b/cmd/rpc/query.go @@ -273,34 +273,10 @@ func (s *Server) Order(w http.ResponseWriter, r *http.Request, _ httprouter.Para }) } -// Orders retrieves the order book for a committee with optional filters and pagination +// Orders retrieves the order book for a committee with pagination func (s *Server) Orders(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - // Invoke helper with the HTTP request, response writer and an inline callback s.ordersParams(w, r, func(s *fsm.StateMachine, req *ordersRequest) (any, lib.ErrorI) { - // validate mutual exclusion: cannot filter by both seller and buyer address - if req.SellersSendAddress != "" && req.BuyerSendAddress != "" { - return nil, lib.NewError(lib.CodeInvalidArgument, lib.RPCModule, "cannot filter by both sellersSendAddress and buyerSendAddress") - } - // convert seller address if provided - var sellerAddr []byte - if req.SellersSendAddress != "" { - var err lib.ErrorI - sellerAddr, err = lib.StringToBytes(req.SellersSendAddress) - if err != nil { - return nil, err - } - } - // convert buyer address if provided - var buyerAddr []byte - if req.BuyerSendAddress != "" { - var err lib.ErrorI - buyerAddr, err = lib.StringToBytes(req.BuyerSendAddress) - if err != nil { - return nil, err - } - } - // use paginated query - return s.GetOrdersPaginated(sellerAddr, buyerAddr, req.Committee, req.PageParams) + return s.GetOrdersPaginated(req.Committee, req.PageParams) }) } diff --git a/cmd/rpc/types.go b/cmd/rpc/types.go index 91dae4dc0..1d8df40dc 100644 --- a/cmd/rpc/types.go +++ b/cmd/rpc/types.go @@ -32,9 +32,7 @@ type orderRequest struct { } type ordersRequest struct { - Committee uint64 `json:"committee"` - SellersSendAddress string `json:"sellersSendAddress"` - BuyerSendAddress string `json:"buyerSendAddress"` + Committee uint64 `json:"committee"` heightRequest lib.PageParams } diff --git a/cmd/rpc/web/explorer/src/components/Home/OverviewCards.tsx b/cmd/rpc/web/explorer/src/components/Home/OverviewCards.tsx index 23c2556b2..bd88a8190 100644 --- a/cmd/rpc/web/explorer/src/components/Home/OverviewCards.tsx +++ b/cmd/rpc/web/explorer/src/components/Home/OverviewCards.tsx @@ -218,7 +218,7 @@ const OverviewCards: React.FC = () => { key={c.type} title={c.title} live - viewAllPath="/swaps" + viewAllPath="/token-swaps" columns={[{ label: 'Action' }, { label: 'Exchange Rate' }, { label: 'Hash' }]} rows={rows} /> diff --git a/cmd/rpc/web/explorer/src/components/Navbar.tsx b/cmd/rpc/web/explorer/src/components/Navbar.tsx index a6c8cce0c..d74f5a3c5 100644 --- a/cmd/rpc/web/explorer/src/components/Navbar.tsx +++ b/cmd/rpc/web/explorer/src/components/Navbar.tsx @@ -3,7 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion' import React from 'react' import menuConfig from '../data/navbar.json' import Logo from './Logo' -import { useAllBlocksCache } from '../hooks/useApi' +import { useLatestBlock } from '../hooks/useApi' import NetworkSelector from './NetworkSelector' const Navbar = () => { @@ -48,7 +48,7 @@ const Navbar = () => { // State for mobile dropdowns (accordion) const [mobileOpenIndex, setMobileOpenIndex] = React.useState(null) const toggleMobileIndex = (index: number) => setMobileOpenIndex(prev => prev === index ? null : index) - const blocks = useAllBlocksCache() + const latestBlock = useLatestBlock() // Check whether the current route is inside an item's child routes const isActiveRoute = (item: MenuItem): boolean => { @@ -93,7 +93,7 @@ const Navbar = () => {

Block:

-

#{blocks.data?.[0]?.blockHeader?.height?.toLocaleString() || '0'}

+

#{latestBlock.data?.totalCount?.toLocaleString() || '0'}

diff --git a/cmd/rpc/web/explorer/src/components/account/AccountsPage.tsx b/cmd/rpc/web/explorer/src/components/account/AccountsPage.tsx index b20903893..70821d1dc 100644 --- a/cmd/rpc/web/explorer/src/components/account/AccountsPage.tsx +++ b/cmd/rpc/web/explorer/src/components/account/AccountsPage.tsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect } from 'react' import { motion } from 'framer-motion' import AccountsTable from './AccountsTable' -import { useAccounts, useAllValidators } from '../../hooks/useApi' -import { getTotalAccountCount } from '../../lib/api' +import { useAccounts } from '../../hooks/useApi' +import { getTotalAccountCount, Account as AccountApi } from '../../lib/api' import accountsTexts from '../../data/accounts.json' import AnimatedNumber from '../AnimatedNumber' @@ -15,30 +15,8 @@ const AccountsPage: React.FC = () => { const [isLoadingStats, setIsLoadingStats] = useState(true) const { data: accountsData, isLoading, error } = useAccounts(currentPage) - const { data: validatorsData } = useAllValidators() - - // Create a map of addresses to staking type - const stakingTypeMap = useMemo(() => { - const map = new Map() - - if (validatorsData?.results && Array.isArray(validatorsData.results)) { - validatorsData.results.forEach((validator: any) => { - const address = validator.address - if (!address) return - - // Check if unstaking - if (validator.unstakingHeight && validator.unstakingHeight > 0) { - map.set(address.toLowerCase(), 'unstaked') - } else if (validator.delegate === true) { - map.set(address.toLowerCase(), 'delegator') - } else { - map.set(address.toLowerCase(), 'validator') - } - }) - } - - return map - }, [validatorsData]) + const [directSearchResult, setDirectSearchResult] = useState<{ address: string; amount: number } | null>(null) + const [isSearchingDirect, setIsSearchingDirect] = useState(false) // Fetch account statistics useEffect(() => { @@ -57,9 +35,32 @@ const AccountsPage: React.FC = () => { fetchStats() }, []) - // Reset to first page when search term changes + // Reset to first page when search term changes and do direct lookup for exact addresses useEffect(() => { setCurrentPage(1) + setDirectSearchResult(null) + + const trimmed = searchTerm.trim() + const isExactAddress = /^[a-fA-F0-9]{40}$/.test(trimmed) + + if (isExactAddress) { + setIsSearchingDirect(true) + AccountApi(0, trimmed) + .then((data) => { + if (data && data.address) { + setDirectSearchResult({ + address: data.address, + amount: Number(data.amount || 0) / 1_000_000, + }) + } + }) + .catch(() => { + setDirectSearchResult(null) + }) + .finally(() => { + setIsSearchingDirect(false) + }) + } }, [searchTerm]) const handlePageChange = (page: number) => { @@ -68,27 +69,30 @@ const AccountsPage: React.FC = () => { const handleEntriesPerPageChange = (value: number) => { setCurrentEntriesPerPage(value) - setCurrentPage(1) // Reset to first page when changing entries per page + setCurrentPage(1) } - // Filter accounts based on search term - const filteredAccounts = accountsData?.results?.filter(account => - account.address.toLowerCase().includes(searchTerm.toLowerCase()) - ) || [] - - // Calculate pagination for filtered results const isSearching = searchTerm.trim() !== '' + const isExactAddress = /^[a-fA-F0-9]{40}$/.test(searchTerm.trim()) + + // For exact address search, use direct API result; for partial, filter current page + const filteredAccounts = isSearching && !isExactAddress + ? (accountsData?.results?.filter(account => + account.address.toLowerCase().includes(searchTerm.toLowerCase()) + ) || []) + : [] + + const accountsToShow = isSearching + ? (isExactAddress && directSearchResult ? [directSearchResult] : filteredAccounts) + : (accountsData?.results || []) + const totalCount = isSearching + ? accountsToShow.length + : (accountsData?.totalCount || 0) - // For search results, implement local pagination - // For normal browsing, use server pagination - const accountsToShow = isSearching ? filteredAccounts : (accountsData?.results || []) - const totalCount = isSearching ? filteredAccounts.length : (accountsData?.totalCount || 0) - - // Local pagination for search results only const startIndex = (currentPage - 1) * currentEntriesPerPage const endIndex = startIndex + currentEntriesPerPage const paginatedAccounts = isSearching - ? filteredAccounts.slice(startIndex, endIndex) + ? accountsToShow.slice(startIndex, endIndex) : accountsToShow // Stage card component @@ -179,7 +183,7 @@ const AccountsPage: React.FC = () => {
setSearchTerm(e.target.value)} @@ -201,14 +205,13 @@ const AccountsPage: React.FC = () => { {/* Right Column - Accounts Table */}
diff --git a/cmd/rpc/web/explorer/src/components/account/AccountsTable.tsx b/cmd/rpc/web/explorer/src/components/account/AccountsTable.tsx index c1998690a..695867552 100644 --- a/cmd/rpc/web/explorer/src/components/account/AccountsTable.tsx +++ b/cmd/rpc/web/explorer/src/components/account/AccountsTable.tsx @@ -22,7 +22,6 @@ interface AccountsTableProps { onEntriesPerPageChange?: (value: number) => void showExportButton?: boolean onExportButtonClick?: () => void - stakingTypeMap?: Map } const AccountsTable: React.FC = ({ @@ -31,14 +30,12 @@ const AccountsTable: React.FC = ({ totalCount = 0, currentPage = 1, onPageChange, - // Destructure the new props showEntriesSelector = false, entriesPerPageOptions = [10, 25, 50, 100], currentEntriesPerPage = 10, onEntriesPerPageChange, showExportButton = false, onExportButtonClick, - stakingTypeMap }) => { const navigate = useNavigate() const truncateLong = (s: string, start: number = 10, end: number = 8) => { @@ -47,17 +44,8 @@ const AccountsTable: React.FC = ({ } - // Get staking type for an account - const getStakingType = (address: string): 'validator' | 'delegator' | 'unstaked' | null => { - if (!stakingTypeMap) return null - return stakingTypeMap.get(address.toLowerCase()) || null - } - const rows = accounts.length > 0 ? accounts.map((account) => { - const stakingType = getStakingType(account.address) - return [ - // Address navigate(`/account/${account.address}`)} @@ -66,26 +54,16 @@ const AccountsTable: React.FC = ({ {truncateLong(account.address, 16, 12)} , - // Amount CNPY , - - // Staking Type - - {stakingType === 'validator' && Validator} - {stakingType === 'delegator' && Delegator} - {stakingType === 'unstaked' && Unstaked} - {!stakingType && } - ] }) : [] const columns = [ - { label: accountsTexts.table.headers.address, width: 'w-[30%]' }, - { label: accountsTexts.table.headers.balance, width: 'w-[25%]' }, - { label: 'Staking', width: 'w-[20%]' } + { label: accountsTexts.table.headers.address, width: 'w-[40%]' }, + { label: accountsTexts.table.headers.balance, width: 'w-[35%]' }, ] // Show message when no data diff --git a/cmd/rpc/web/explorer/src/components/analytics/AnalyticsFilters.tsx b/cmd/rpc/web/explorer/src/components/analytics/AnalyticsFilters.tsx index 2de867526..78d69763d 100644 --- a/cmd/rpc/web/explorer/src/components/analytics/AnalyticsFilters.tsx +++ b/cmd/rpc/web/explorer/src/components/analytics/AnalyticsFilters.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect, useMemo, useRef } from 'react' interface AnalyticsFiltersProps { fromBlock: string @@ -9,15 +9,25 @@ interface AnalyticsFiltersProps { isLoading?: boolean errorMessage?: string blocksData?: any + blockTime?: number // seconds per block } -const blockRangeFilters = [ - { key: '10', oldLabel: '10 Blocks', recentLabel: 'Last 1 minute' }, - { key: '25', oldLabel: '25 Blocks', recentLabel: 'Last 5 minutes' }, - { key: '50', oldLabel: '50 Blocks', recentLabel: 'Last 15 minutes' }, - { key: '100', oldLabel: '100 Blocks', recentLabel: 'Last 30 minutes' } +// target time ranges in seconds +const timeTargets = [ + { seconds: 60, label: '1 min' }, + { seconds: 300, label: '5 min' }, + { seconds: 900, label: '15 min' }, + { seconds: 1800, label: '30 min' }, ] +// snap to a round number so small blockTime fluctuations don't change labels +const snapToRound = (n: number): number => { + if (n <= 5) return n + if (n <= 20) return Math.round(n / 5) * 5 + if (n <= 50) return Math.round(n / 10) * 10 + return Math.round(n / 25) * 25 +} + const AnalyticsFilters: React.FC = ({ fromBlock, toBlock, @@ -26,64 +36,46 @@ const AnalyticsFilters: React.FC = ({ onSearch, isLoading = false, errorMessage = '', - blocksData + blocksData, + blockTime = 20, }) => { const [selectedRange, setSelectedRange] = useState('') - - // Determine if blocks are recent (less than 2 months old) - const areBlocksRecent = useMemo(() => { - if (!blocksData?.results || !Array.isArray(blocksData.results) || blocksData.results.length === 0) { - return false - } - - // Get the most recent block - const sortedBlocks = [...blocksData.results].sort((a: any, b: any) => { - const heightA = a.blockHeader?.height || a.height || 0 - const heightB = b.blockHeader?.height || b.height || 0 - return heightB - heightA + // lock filters once computed so blockTime jitter doesn't change them + const lockedFilters = useRef<{ key: string; label: string }[] | null>(null) + + // compute block counts once from block time, then lock them + const blockRangeFilters = useMemo(() => { + if (lockedFilters.current) return lockedFilters.current + + const filters = timeTargets.map(target => { + const raw = Math.max(1, Math.round(target.seconds / blockTime)) + const snapped = snapToRound(raw) + const capped = Math.min(snapped, 100) + return { + key: capped.toString(), + label: `Last ${capped} blocks (~${target.label})`, + } }) + // deduplicate if multiple targets resolve to the same block count + .filter((f, i, arr) => arr.findIndex(x => x.key === f.key) === i) - if (sortedBlocks.length === 0) { - return false - } - - const mostRecentBlock = sortedBlocks[0] - const mostRecentTime = mostRecentBlock.blockHeader?.time || mostRecentBlock.time || 0 - - if (!mostRecentTime) { - return false - } - - // Convert timestamp (may be in microseconds) - const mostRecentTimeMs = mostRecentTime > 1e12 ? mostRecentTime / 1000 : mostRecentTime - const now = Date.now() - - // Calculate age of most recent block from now - const ageOfMostRecentMs = now - mostRecentTimeMs - const ageOfMostRecentDays = ageOfMostRecentMs / (24 * 60 * 60 * 1000) - - // If blocks are old (2 months or more), return false - return ageOfMostRecentDays < 60 // 2 months = ~60 days - }, [blocksData]) + lockedFilters.current = filters + return filters + }, [blockTime]) - // Detect when custom range is being used + // when toBlock changes and a range is selected, auto-update fromBlock to maintain the window useEffect(() => { - if (fromBlock && toBlock) { - const from = parseInt(fromBlock) - const to = parseInt(toBlock) - const range = to - from + 1 - - // Check if it matches any predefined range - const predefinedRanges = ['10', '25', '50', '100'] - const matchingRange = predefinedRanges.find(r => parseInt(r) === range) - - if (matchingRange) { - setSelectedRange(matchingRange) - } else { - setSelectedRange('custom') + if (selectedRange && selectedRange !== 'custom' && toBlock) { + const blockCount = parseInt(selectedRange) + const currentToBlock = parseInt(toBlock) || 0 + const expectedFrom = Math.max(0, currentToBlock - blockCount + 1) + const currentFrom = parseInt(fromBlock) || 0 + + if (currentFrom !== expectedFrom) { + onFromBlockChange(expectedFrom.toString()) } } - }, [fromBlock, toBlock]) + }, [toBlock, selectedRange]) const handleBlockRangeSelect = (range: string) => { setSelectedRange(range) @@ -102,9 +94,6 @@ const AnalyticsFilters: React.FC = ({
{blockRangeFilters.map((filter) => { const isSelected = selectedRange === filter.key - const isCustom = filter.key === 'custom' - // Use recentLabel if blocks are recent, otherwise use oldLabel - const displayText = areBlocksRecent ? filter.recentLabel : filter.oldLabel return ( ) diff --git a/cmd/rpc/web/explorer/src/components/analytics/BlockProductionRate.tsx b/cmd/rpc/web/explorer/src/components/analytics/BlockProductionRate.tsx index 21ccd4444..a5708883b 100644 --- a/cmd/rpc/web/explorer/src/components/analytics/BlockProductionRate.tsx +++ b/cmd/rpc/web/explorer/src/components/analytics/BlockProductionRate.tsx @@ -38,16 +38,21 @@ const BlockProductionRate: React.FC = ({ fromBlock, to return timeA - timeB }) - // Always create 4 data points by dividing blocks into 4 equal groups - const numPoints = 4 - const blocksPerGroup = Math.max(1, Math.ceil(filteredBlocks.length / numPoints)) + // always create 4 data points by dividing blocks into 4 equal groups + const numPoints = Math.min(4, filteredBlocks.length) + const base = Math.floor(filteredBlocks.length / numPoints) + const remainder = filteredBlocks.length % numPoints const blockData: number[] = [] const timeLabels: string[] = [] - const groupTimeRanges: number[] = [] // Store time range for each group in minutes + const groupTimeRanges: number[] = [] // store time range for each group in minutes + let offset = 0 for (let i = 0; i < numPoints; i++) { - const startIdx = i * blocksPerGroup - const endIdx = Math.min(startIdx + blocksPerGroup, filteredBlocks.length) + // distribute remainder across first groups so sizes differ by at most 1 + const groupSize = base + (i < remainder ? 1 : 0) + const startIdx = offset + const endIdx = offset + groupSize + offset = endIdx const groupBlocks = filteredBlocks.slice(startIdx, endIdx) if (groupBlocks.length === 0) { @@ -57,23 +62,26 @@ const BlockProductionRate: React.FC = ({ fromBlock, to continue } - // Count blocks in this group - blockData.push(groupBlocks.length) - - // Get time label from first and last block in group + // get time label from first and last block in group const firstBlock = groupBlocks[0] const lastBlock = groupBlocks[groupBlocks.length - 1] - + const firstTime = firstBlock.blockHeader?.time || firstBlock.time || 0 const lastTime = lastBlock.blockHeader?.time || lastBlock.time || 0 - + const firstTimeMs = firstTime > 1e12 ? firstTime / 1000 : firstTime const lastTimeMs = lastTime > 1e12 ? lastTime / 1000 : lastTime - - // Calculate time range for this group in minutes + + // calculate time range for this group in minutes const groupTimeRangeMs = lastTimeMs - firstTimeMs const groupTimeRangeMins = groupTimeRangeMs / (60 * 1000) groupTimeRanges.push(groupTimeRangeMins) + + // normalize to blocks per minute: N blocks have N-1 intervals over the duration + const blocksPerMin = groupTimeRangeMins > 0 && groupBlocks.length > 1 + ? (groupBlocks.length - 1) / groupTimeRangeMins + : groupBlocks.length + blockData.push(Math.round(blocksPerMin * 100) / 100) const firstDate = new Date(firstTimeMs) const lastDate = new Date(lastTimeMs) @@ -150,7 +158,7 @@ const BlockProductionRate: React.FC = ({ fromBlock, to Block Production Rate

- Blocks per time interval + Blocks per minute

@@ -172,7 +180,7 @@ const BlockProductionRate: React.FC = ({ fromBlock, to Block Production Rate

- Blocks per {timeInterval} interval + Blocks per minute

diff --git a/cmd/rpc/web/explorer/src/components/analytics/ChainStatus.tsx b/cmd/rpc/web/explorer/src/components/analytics/ChainStatus.tsx index 79b159763..fe7acaf92 100644 --- a/cmd/rpc/web/explorer/src/components/analytics/ChainStatus.tsx +++ b/cmd/rpc/web/explorer/src/components/analytics/ChainStatus.tsx @@ -15,9 +15,28 @@ interface NetworkMetrics { interface ChainStatusProps { metrics: NetworkMetrics loading: boolean + paramsData?: any } -const ChainStatus: React.FC = ({ metrics, loading }) => { +const ChainStatus: React.FC = ({ metrics, loading, paramsData }) => { + // compute average of all fee params + const getAvgFee = () => { + if (!paramsData?.fee) return 0 + const feeKeys = [ + 'sendFee', 'stakeFee', 'editStakeFee', 'unstakeFee', + 'pauseFee', 'unpauseFee', 'changeParameterFee', + 'daoTransferFee', 'certificateResultsFee', 'subsidyFee', + 'createOrderFee', 'editOrderFee', 'deleteOrderFee', + ] + const fees = feeKeys + .map(k => paramsData.fee[k]) + .filter((v: any) => typeof v === 'number' && v > 0) + if (fees.length === 0) return 0 + const avg = fees.reduce((sum: number, f: number) => sum + f, 0) / fees.length + return avg / 1000000 // convert to CNPY + } + + const avgFee = getAvgFee() if (loading) { return (
@@ -79,6 +98,13 @@ const ChainStatus: React.FC = ({ metrics, loading }) => { {metrics.networkVersion}
+ +
+ Avg. Transaction Fee + + {parseFloat(avgFee.toFixed(4))} CNPY + +
) diff --git a/cmd/rpc/web/explorer/src/components/analytics/KeyMetrics.tsx b/cmd/rpc/web/explorer/src/components/analytics/KeyMetrics.tsx index 4eb16c070..b84a85aae 100644 --- a/cmd/rpc/web/explorer/src/components/analytics/KeyMetrics.tsx +++ b/cmd/rpc/web/explorer/src/components/analytics/KeyMetrics.tsx @@ -33,13 +33,7 @@ const KeyMetrics: React.FC = ({ metrics, loading, supplyData, v realMetrics.totalValueLocked = stakedAmount / 1000000000000 // Convert to M CNPY } - // 2. Average Transaction Fee - Real data from params - if (paramsData?.fee?.sendFee) { - const sendFee = paramsData.fee.sendFee || 0 - realMetrics.avgTransactionFee = sendFee / 1000000 // Convert to CNPY - } - - // 3. Validator Count - Real ACTIVE validators based on API fields + // 2. Validator Count - Real ACTIVE validators based on API fields // Active = not paused, not unstaking, and not delegate if (validatorsData?.results || validatorsData?.validators) { const validatorsList = validatorsData.results || validatorsData.validators || [] @@ -137,27 +131,6 @@ const KeyMetrics: React.FC = ({ metrics, loading, supplyData, v */} - {/* Average Transaction Fee */} -
-
- Avg. Transaction Fee - - - -
-
-
-
-
- {/* Total Value Locked */}
diff --git a/cmd/rpc/web/explorer/src/components/analytics/NetworkActivity.tsx b/cmd/rpc/web/explorer/src/components/analytics/NetworkActivity.tsx index 6419ea46b..04c0bb21d 100644 --- a/cmd/rpc/web/explorer/src/components/analytics/NetworkActivity.tsx +++ b/cmd/rpc/web/explorer/src/components/analytics/NetworkActivity.tsx @@ -44,16 +44,21 @@ const NetworkActivity: React.FC = ({ fromBlock, toBlock, l return timeA - timeB }) - // Always create 4 data points by dividing blocks into 4 equal groups - const numPoints = 4 - const blocksPerGroup = Math.max(1, Math.ceil(filteredBlocks.length / numPoints)) + // always create 4 data points by dividing blocks into 4 equal groups + const numPoints = Math.min(4, filteredBlocks.length) + const base = Math.floor(filteredBlocks.length / numPoints) + const remainder = filteredBlocks.length % numPoints const txCounts: number[] = [] const timeLabels: string[] = [] - const groupTimeRanges: number[] = [] // Store time range for each group in minutes + const groupTimeRanges: number[] = [] // store time range for each group in minutes + let offset = 0 for (let i = 0; i < numPoints; i++) { - const startIdx = i * blocksPerGroup - const endIdx = Math.min(startIdx + blocksPerGroup, filteredBlocks.length) + // distribute remainder across first groups so sizes differ by at most 1 + const groupSize = base + (i < remainder ? 1 : 0) + const startIdx = offset + const endIdx = offset + groupSize + offset = endIdx const groupBlocks = filteredBlocks.slice(startIdx, endIdx) if (groupBlocks.length === 0) { @@ -63,11 +68,12 @@ const NetworkActivity: React.FC = ({ fromBlock, toBlock, l continue } - // Count total transactions in this group - const groupTxCount = groupBlocks.reduce((sum: number, block: any) => { - return sum + (block.blockHeader?.numTxs || 0) + // average per-block transactions in this group so ±1 block difference doesn't skew the chart + const groupTxTotal = groupBlocks.reduce((sum: number, block: any) => { + return sum + parseInt(block.blockHeader?.numTxs || '0', 10) }, 0) - txCounts.push(groupTxCount) + const avgTxPerBlock = groupBlocks.length > 0 ? groupTxTotal / groupBlocks.length : 0 + txCounts.push(Math.round(avgTxPerBlock * 100) / 100) // Get time label from first and last block in group const firstBlock = groupBlocks[0] @@ -158,7 +164,7 @@ const NetworkActivity: React.FC = ({ fromBlock, toBlock, l Network Activity

- Transactions per {timeInterval} + Avg transactions per block over time

diff --git a/cmd/rpc/web/explorer/src/components/analytics/NetworkAnalyticsPage.tsx b/cmd/rpc/web/explorer/src/components/analytics/NetworkAnalyticsPage.tsx index 590e97d64..72691e990 100644 --- a/cmd/rpc/web/explorer/src/components/analytics/NetworkAnalyticsPage.tsx +++ b/cmd/rpc/web/explorer/src/components/analytics/NetworkAnalyticsPage.tsx @@ -63,6 +63,17 @@ const NetworkAnalyticsPage: React.FC = () => { } }, [blockRange]); + // auto-sync searchParams when fromBlock/toBlock change so data re-fetches without manual search + useEffect(() => { + if (fromBlock && toBlock) { + const from = parseInt(fromBlock) + const to = parseInt(toBlock) + if (!isNaN(from) && !isNaN(to) && to >= from && to - from + 1 <= 100) { + setSearchParams({ from: fromBlock, to: toBlock }) + } + } + }, [fromBlock, toBlock]); + const blocksToFetch = blockRange > 0 ? Math.min(blockRange, 100) : 10; // Default 10 blocks, maximum 100 // Only make the request if searchParams.from and searchParams.to are valid @@ -444,6 +455,7 @@ const NetworkAnalyticsPage: React.FC = () => { isLoading={filteredBlocksLoading} errorMessage={errorMessage} blocksData={filteredBlocksData || analyticsBlocksData || blocksData} + blockTime={metrics.blockTime || undefined} /> {/* Analytics Grid - 3 columns layout */} @@ -454,7 +466,7 @@ const NetworkAnalyticsPage: React.FC = () => { {/* Chain Status */} - +
{/* Second Column - 3 cards */} diff --git a/cmd/rpc/web/explorer/src/components/analytics/StakingTrends.tsx b/cmd/rpc/web/explorer/src/components/analytics/StakingTrends.tsx index 2cae4cba8..2c499b356 100644 --- a/cmd/rpc/web/explorer/src/components/analytics/StakingTrends.tsx +++ b/cmd/rpc/web/explorer/src/components/analytics/StakingTrends.tsx @@ -28,107 +28,72 @@ const StakingTrends: React.FC = ({ fromBlock, toBlock, loadi return value.toFixed(2) } - // Get time labels from blocks data - const getTimeLabels = () => { - if (!blocksData?.results || !Array.isArray(blocksData.results) || !blockGroups || blockGroups.length === 0) { - return blockGroups?.map(group => `${group.start}-${group.end}`) || [] + // generate staking data from block reward events, using evenly distributed groups from actual data + const generateStakingData = () => { + if (!blocksData?.results || !Array.isArray(blocksData.results)) { + return { rewards: [], timeLabels: [] } } const realBlocks = blocksData.results const fromBlockNum = parseInt(fromBlock) || 0 const toBlockNum = parseInt(toBlock) || 0 - // Filter blocks by the specified range + // filter blocks by the specified range const filteredBlocks = realBlocks.filter((block: any) => { const blockHeight = block.blockHeader?.height || block.height || 0 return blockHeight >= fromBlockNum && blockHeight <= toBlockNum }) if (filteredBlocks.length === 0) { - return blockGroups.map(group => `${group.start}-${group.end}`) + return { rewards: [], timeLabels: [] } } - // Sort blocks by timestamp (oldest first) + // sort by timestamp filteredBlocks.sort((a: any, b: any) => { const timeA = a.blockHeader?.time || a.time || 0 const timeB = b.blockHeader?.time || b.time || 0 return timeA - timeB }) - // Determine time interval based on number of filtered blocks - const use10MinuteIntervals = filteredBlocks.length >= 20 - - // Create time labels for each block group - const timeLabels = blockGroups.map((group, index) => { - // Find the time key for this group - const groupBlocks = filteredBlocks.filter((block: any) => { - const blockHeight = block.blockHeader?.height || block.height || 0 - return blockHeight >= group.start && blockHeight <= group.end - }) - - if (groupBlocks.length === 0) { - return `${group.start}-${group.end}` - } - - // Get the first block's time for this group + // create evenly distributed groups from actual data + const numPoints = Math.min(6, filteredBlocks.length) + const base = Math.floor(filteredBlocks.length / numPoints) + const remainder = filteredBlocks.length % numPoints + const rewards: number[] = [] + const timeLabels: string[] = [] + + let offset = 0 + for (let i = 0; i < numPoints; i++) { + const groupSize = base + (i < remainder ? 1 : 0) + const groupBlocks = filteredBlocks.slice(offset, offset + groupSize) + offset += groupSize + + // sum all reward events in this group's blocks + const groupReward = groupBlocks.reduce((sum: number, block: any) => { + const events = block.events || [] + const rewardSum = events + .filter((e: any) => e.eventType === 'reward') + .reduce((s: number, e: any) => s + (e.msg?.amount || 0), 0) + return sum + rewardSum + }, 0) + + // normalize to per-block average so groups with ±1 block difference are comparable + const avgReward = groupBlocks.length > 0 ? groupReward / groupBlocks.length : 0 + rewards.push(avgReward / 1000000) + + // build time label from first and last block in group const firstBlock = groupBlocks[0] - const blockTime = firstBlock.blockHeader?.time || firstBlock.time || 0 - const blockTimeMs = blockTime > 1e12 ? blockTime / 1000 : blockTime - const blockDate = new Date(blockTimeMs) - - const minute = use10MinuteIntervals ? - Math.floor(blockDate.getMinutes() / 10) * 10 : - blockDate.getMinutes() - - const timeKey = `${blockDate.getHours().toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}` - - if (!use10MinuteIntervals) { - return timeKey - } - - // Create 10-minute range - const [hour, min] = timeKey.split(':').map(Number) - const endMinute = (min + 10) % 60 - const endHour = endMinute < min ? (hour + 1) % 24 : hour - - return `${timeKey}-${endHour.toString().padStart(2, '0')}:${endMinute.toString().padStart(2, '0')}` - }) - - return timeLabels - } - - // Generate real staking data based on validators and block groups - const generateStakingData = () => { - if (!validatorsData?.results || !Array.isArray(validatorsData.results) || !blockGroups || blockGroups.length === 0) { - return { rewards: [], timeLabels: [] } + const lastBlock = groupBlocks[groupBlocks.length - 1] + const firstTime = firstBlock.blockHeader?.time || firstBlock.time || 0 + const lastTime = lastBlock.blockHeader?.time || lastBlock.time || 0 + const firstMs = firstTime > 1e12 ? firstTime / 1000 : firstTime + const lastMs = lastTime > 1e12 ? lastTime / 1000 : lastTime + const fmt = (d: Date) => `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}` + const startLabel = fmt(new Date(firstMs)) + const endLabel = fmt(new Date(lastMs)) + timeLabels.push(startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`) } - const validators = validatorsData.results - - // Calculate total staked amount from validators - const totalStaked = validators.reduce((sum: number, validator: any) => { - return sum + (validator.stakedAmount || 0) - }, 0) - - // Calculate average staking rewards per validator - const avgRewardPerValidator = totalStaked > 0 ? totalStaked / validators.length : 0 - const baseReward = avgRewardPerValidator / 1000000 // Convert from micro to CNPY - - // Use blockGroups to generate realistic reward data - // Each block group will have a reward based on the number of blocks - const rewards = blockGroups.map((group, index) => { - // Calculate reward based on the number of blocks in this group - // and add a small variation to make it look more natural - const blockFactor = group.blockCount / 10 // Normalize by every 10 blocks - const timeFactor = Math.sin((index / blockGroups.length) * Math.PI) * 0.2 + 0.9 // Variation from 0.7 to 1.1 - - // Base reward * block factor * time factor - return Math.max(0, baseReward * blockFactor * timeFactor) - }) - - // Get time labels from blocks data - const timeLabels = getTimeLabels() - return { rewards, timeLabels } } @@ -161,7 +126,7 @@ const StakingTrends: React.FC = ({ fromBlock, toBlock, loadi Staking Trends

- Average rewards over time + Block rewards over time

@@ -183,7 +148,7 @@ const StakingTrends: React.FC = ({ fromBlock, toBlock, loadi Staking Trends

- Average rewards over time + Average reward per block over time

diff --git a/cmd/rpc/web/explorer/src/components/analytics/TransactionTypes.tsx b/cmd/rpc/web/explorer/src/components/analytics/TransactionTypes.tsx index 93c35d4a9..ce02dba94 100644 --- a/cmd/rpc/web/explorer/src/components/analytics/TransactionTypes.tsx +++ b/cmd/rpc/web/explorer/src/components/analytics/TransactionTypes.tsx @@ -1,6 +1,12 @@ import React from 'react' import { motion } from 'framer-motion' +// color palette for dynamic message types +const TYPE_COLORS = [ + '#4ADE80', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', + '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#06b6d4', +] + interface TransactionTypesProps { fromBlock: string toBlock: string @@ -16,183 +22,133 @@ interface TransactionTypesProps { } const TransactionTypes: React.FC = ({ fromBlock, toBlock, loading, transactionsData, blocksData, blockGroups }) => { - // Get time labels from blocks data - const getTimeLabels = () => { - if (!blocksData?.results || !Array.isArray(blocksData.results) || !blockGroups || blockGroups.length === 0) { - return blockGroups?.map(group => `${group.start}-${group.end}`) || [] + // collect all distinct messageTypes and count per time group + const getTransactionTypeData = () => { + if (!transactionsData?.results || !Array.isArray(transactionsData.results) || transactionsData.results.length === 0) { + return { data: [], labels: [], allTypes: [] } } - const realBlocks = blocksData.results - const fromBlockNum = parseInt(fromBlock) || 0 - const toBlockNum = parseInt(toBlock) || 0 - - // Filter blocks by the specified range - const filteredBlocks = realBlocks.filter((block: any) => { - const blockHeight = block.blockHeader?.height || block.height || 0 - return blockHeight >= fromBlockNum && blockHeight <= toBlockNum - }) - - if (filteredBlocks.length === 0) { - return blockGroups.map(group => `${group.start}-${group.end}`) + if (!blocksData?.results || !Array.isArray(blocksData.results) || blocksData.results.length === 0) { + return { data: [], labels: [], allTypes: [] } } - // Sort blocks by timestamp (oldest first) - filteredBlocks.sort((a: any, b: any) => { - const timeA = a.blockHeader?.time || a.time || 0 - const timeB = b.blockHeader?.time || b.time || 0 - return timeA - timeB - }) - - // Determine time interval based on number of filtered blocks - // Use 10-minute intervals only for very large datasets (100+ blocks) - const use10MinuteIntervals = filteredBlocks.length >= 100 + const realTransactions = transactionsData.results - // Create time labels for each block group - const timeLabels = blockGroups.map((group, index) => { - // Find the time key for this group - const groupBlocks = filteredBlocks.filter((block: any) => { - const blockHeight = block.blockHeader?.height || block.height || 0 - return blockHeight >= group.start && blockHeight <= group.end + // find the block height range that actually contains transactions + const txHeights = realTransactions.map((tx: any) => tx.blockHeight || tx.height || 0).filter((h: number) => h > 0) + if (txHeights.length === 0) { + return { data: [], labels: [], allTypes: [] } + } + const minTxHeight = Math.min(...txHeights) + const maxTxHeight = Math.max(...txHeights) + + // only use blocks in the range where transactions exist, sorted by time + const filteredBlocks = blocksData.results + .filter((block: any) => { + const h = block.blockHeader?.height || block.height || 0 + return h >= minTxHeight && h <= maxTxHeight + }) + .sort((a: any, b: any) => { + const tA = a.blockHeader?.time || a.time || 0 + const tB = b.blockHeader?.time || b.time || 0 + return tA - tB }) - if (groupBlocks.length === 0) { - return `${group.start}-${group.end}` - } - - // Get the first block's time for this group - const firstBlock = groupBlocks[0] - const blockTime = firstBlock.blockHeader?.time || firstBlock.time || 0 - const blockTimeMs = blockTime > 1e12 ? blockTime / 1000 : blockTime - const blockDate = new Date(blockTimeMs) - - const minute = use10MinuteIntervals ? - Math.floor(blockDate.getMinutes() / 10) * 10 : - blockDate.getMinutes() - - const timeKey = `${blockDate.getHours().toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}` - - if (!use10MinuteIntervals) { - return timeKey - } - - // Create 10-minute range - const [hour, min] = timeKey.split(':').map(Number) - const endMinute = (min + 10) % 60 - const endHour = endMinute < min ? (hour + 1) % 24 : hour - - return `${timeKey}-${endHour.toString().padStart(2, '0')}:${endMinute.toString().padStart(2, '0')}` - }) - - return timeLabels - } - // Use real transaction data to categorize by type - const getTransactionTypeData = () => { - if (!transactionsData?.results || !Array.isArray(transactionsData.results)) { - // Return empty array if no real data - return [] + if (filteredBlocks.length === 0) { + return { data: [], labels: [], allTypes: [] } } - const realTransactions = transactionsData.results - const blockRange = parseInt(toBlock) - parseInt(fromBlock) + 1 - const periods = Math.min(blockRange, 30) // Maximum 30 periods for visualization - const categorizedByPeriod: { [key: string]: { transfers: number, staking: number, governance: number, other: number } } = {} - - - // Initialize all categories to 0 for each period - for (let i = 0; i < periods; i++) { - categorizedByPeriod[i] = { transfers: 0, staking: 0, governance: 0, other: 0 } + // create exactly equal groups + const numGroups = Math.min(6, filteredBlocks.length) + const groupSize = Math.floor(filteredBlocks.length / numGroups) + const usableBlocks = filteredBlocks.slice(filteredBlocks.length - groupSize * numGroups) + + const groups: { minHeight: number, maxHeight: number, label: string }[] = [] + let offset = 0 + for (let i = 0; i < numGroups; i++) { + const groupBlocks = usableBlocks.slice(offset, offset + groupSize) + offset += groupSize + + const minH = groupBlocks[0].blockHeader?.height || groupBlocks[0].height || 0 + const maxH = groupBlocks[groupBlocks.length - 1].blockHeader?.height || groupBlocks[groupBlocks.length - 1].height || 0 + + const firstTime = groupBlocks[0].blockHeader?.time || groupBlocks[0].time || 0 + const lastTime = groupBlocks[groupBlocks.length - 1].blockHeader?.time || groupBlocks[groupBlocks.length - 1].time || 0 + const firstMs = firstTime > 1e12 ? firstTime / 1000 : firstTime + const lastMs = lastTime > 1e12 ? lastTime / 1000 : lastTime + const fmt = (d: Date) => `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}` + const startLabel = fmt(new Date(firstMs)) + const endLabel = fmt(new Date(lastMs)) + + groups.push({ + minHeight: minH, + maxHeight: maxH, + label: startLabel === endLabel ? startLabel : `${startLabel}-${endLabel}`, + }) } - // Count transactions by type - const typeCounts = { transfers: 0, staking: 0, governance: 0, other: 0 } + // count distinct messageTypes per group + // each group entry is a map of messageType -> 1 (present) or 0 (absent) + const allTypesSet = new Set() + const groupTypeSets: Set[] = groups.map(() => new Set()) realTransactions.forEach((tx: any) => { - // Categorize transactions by message type - const messageType = tx.messageType || 'other' - let category = 'other' - - // Map real message types to categories - if (messageType === 'certificateResults' || messageType.includes('send') || messageType.includes('transfer')) { - category = 'transfers' - } else if (messageType.includes('staking') || messageType.includes('delegate') || messageType.includes('undelegate')) { - category = 'staking' - } else if (messageType.includes('governance') || messageType.includes('proposal') || messageType.includes('vote')) { - category = 'governance' - } else { - category = 'other' - } + const msgType = tx.messageType || 'unknown' + allTypesSet.add(msgType) - typeCounts[category as keyof typeof typeCounts]++ + const txHeight = tx.blockHeight || tx.height || 0 + const groupIndex = groups.findIndex(g => txHeight >= g.minHeight && txHeight <= g.maxHeight) + if (groupIndex >= 0) { + groupTypeSets[groupIndex].add(msgType) + } }) - // Distribute counts by type across periods - const totalTransactions = realTransactions.length - if (totalTransactions > 0) { - for (let i = 0; i < periods; i++) { - // Distribute proportionally based on block range - const periodWeight = 1 / periods - categorizedByPeriod[i] = { - transfers: Math.floor(typeCounts.transfers * periodWeight), - staking: Math.floor(typeCounts.staking * periodWeight), - governance: Math.floor(typeCounts.governance * periodWeight), - other: Math.floor(typeCounts.other * periodWeight) - } - } - } + const allTypes = Array.from(allTypesSet).sort() - return Array.from({ length: periods }, (_, i) => { - const periodData = categorizedByPeriod[i] - return { - day: i + 1, - transfers: periodData.transfers, - staking: periodData.staking, - governance: periodData.governance, - other: periodData.other, - total: periodData.transfers + periodData.staking + periodData.governance + periodData.other, - } + // build data: each entry has a count per type (1 if present, 0 if not) and total + const data = groups.map((_, i) => { + const typeCounts: { [key: string]: number } = {} + let total = 0 + allTypes.forEach(t => { + const present = groupTypeSets[i].has(t) ? 1 : 0 + typeCounts[t] = present + total += present + }) + return { day: i + 1, types: typeCounts, total } }) - } - const transactionData = getTransactionTypeData() - const maxTotal = Math.max(...transactionData.map(d => d.total), 0) // Ensure maxTotal is not negative if all are 0 + const labels = groups.map(g => g.label) + return { data, labels, allTypes } + } - // Get available transaction types from real data - const getAvailableTypes = () => { + // get global type counts for the legend + const getTypeCounts = () => { if (!transactionsData?.results || !Array.isArray(transactionsData.results)) { return [] } - const typeCounts = { transfers: 0, staking: 0, governance: 0, other: 0 } - + const counts: { [key: string]: number } = {} transactionsData.results.forEach((tx: any) => { - const messageType = tx.messageType || 'other' - let category = 'other' - - if (messageType === 'certificateResults' || messageType.includes('send') || messageType.includes('transfer')) { - category = 'transfers' - } else if (messageType.includes('staking') || messageType.includes('delegate') || messageType.includes('undelegate')) { - category = 'staking' - } else if (messageType.includes('governance') || messageType.includes('proposal') || messageType.includes('vote')) { - category = 'governance' - } else { - category = 'other' - } - - typeCounts[category as keyof typeof typeCounts]++ + const msgType = tx.messageType || 'unknown' + counts[msgType] = (counts[msgType] || 0) + 1 }) - // Return only types that have transactions - const availableTypes = [] - if (typeCounts.transfers > 0) availableTypes.push({ name: 'Transfers', count: typeCounts.transfers, color: '#4ADE80' }) - if (typeCounts.staking > 0) availableTypes.push({ name: 'Staking', count: typeCounts.staking, color: '#3b82f6' }) - if (typeCounts.governance > 0) availableTypes.push({ name: 'Governance', count: typeCounts.governance, color: '#f59e0b' }) - if (typeCounts.other > 0) availableTypes.push({ name: 'Other', count: typeCounts.other, color: '#6b7280' }) - - return availableTypes + return Object.entries(counts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, count], i) => ({ + name, + count, + color: TYPE_COLORS[i % TYPE_COLORS.length], + })) } - const availableTypes = getAvailableTypes() - const timeLabels = getTimeLabels() + const { data: transactionData, labels: txTimeLabels, allTypes } = getTransactionTypeData() + const maxTotal = transactionData.length > 0 ? Math.max(...transactionData.map(d => d.total), 0) : 0 + const typeCounts = getTypeCounts() + + // map type name -> color + const typeColorMap: { [key: string]: string } = {} + typeCounts.forEach(t => { typeColorMap[t.name] = t.color }) if (loading) { return ( @@ -205,7 +161,6 @@ const TransactionTypes: React.FC = ({ fromBlock, toBlock, ) } - // If no real data, show empty state if (transactionData.length === 0 || maxTotal === 0) { return ( = ({ fromBlock, toBlock,
- {/* Grid lines */} @@ -255,82 +209,57 @@ const TransactionTypes: React.FC = ({ fromBlock, toBlock, - {/* Stacked bars */} + {/* stacked bars - each segment is a distinct messageType */} {transactionData.map((day, index) => { const barWidth = 280 / transactionData.length const x = (index * barWidth) + 10 const barHeight = maxTotal > 0 ? (day.total / maxTotal) * 100 : 0 let currentY = 110 - - return ( - - {/* Other (grey) */} - {day.total > 0 && ( - <> - - {currentY -= (day.other / day.total) * barHeight} - - {/* Governance (orange) */} - - {currentY -= (day.governance / day.total) * barHeight} - - {/* Staking (blue) */} - - {currentY -= (day.staking / day.total) * barHeight} - - {/* Transfers (green) */} - - - )} - - ) + // render a rect for each type that is present in this group + const rects = allTypes.map((typeName) => { + const value = day.types[typeName] || 0 + if (value === 0 || day.total === 0) return null + const segmentHeight = (value / day.total) * barHeight + const y = currentY - segmentHeight + currentY = y + return ( + + ) + }) + + return {rects} })} {/* Y-axis labels */}
{maxTotal} - {Math.round(maxTotal / 2)} + {maxTotal > 1 && ( + {Math.floor(maxTotal / 2)} + )} 0
- {timeLabels.slice(0, 6).map((label, index) => ( + {txTimeLabels.map((label, index) => ( {label} ))}
- {/* Legend - Only show types that exist */} + {/* legend - each distinct messageType with its tx count */}
- {availableTypes.map((type, index) => ( + {typeCounts.map((type, index) => (
{type.name} ({type.count}) @@ -341,4 +270,4 @@ const TransactionTypes: React.FC = ({ fromBlock, toBlock, ) } -export default TransactionTypes \ No newline at end of file +export default TransactionTypes diff --git a/cmd/rpc/web/explorer/src/components/block/BlockDetailInfo.tsx b/cmd/rpc/web/explorer/src/components/block/BlockDetailInfo.tsx index 551a14e8a..a15fbba26 100644 --- a/cmd/rpc/web/explorer/src/components/block/BlockDetailInfo.tsx +++ b/cmd/rpc/web/explorer/src/components/block/BlockDetailInfo.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState } from 'react' import { motion } from 'framer-motion' import toast from 'react-hot-toast' import blockDetailTexts from '../../data/blockDetail.json' @@ -16,9 +16,11 @@ interface BlockDetailInfoProps { blockHash: string parentHash: string } + blockData?: Record } -const BlockDetailInfo: React.FC = ({ block }) => { +const BlockDetailInfo: React.FC = ({ block, blockData }) => { + const [viewMode, setViewMode] = useState<'decoded' | 'raw'>('decoded') const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text) toast.success('Copied to clipboard!', { @@ -54,10 +56,56 @@ const BlockDetailInfo: React.FC = ({ block }) => { transition={{ duration: 0.3 }} className="bg-card rounded-xl border border-gray-800/60 p-6" > -

- {blockDetailTexts.blockDetails.title} -

+
+

+ {blockDetailTexts.blockDetails.title} +

+
+ + +
+
+ {viewMode === 'raw' && blockData ? ( +
+
+                        
+                            {JSON.stringify(blockData, null, 2)
+                                .split('\n')
+                                .map((line, index) => (
+                                    
+ + {String(index + 1).padStart(2, '0')} + + $1:') + .replace(/:\s*(".*?")/g, ': $1') + .replace(/:\s*(\d+)/g, ': $1') + .replace(/:\s*(true|false|null)/g, ': $1') + .replace(/({|}|\[|\])/g, '$1') + || ' ' + }} + /> +
+ )) + } +
+
+
+ ) : (
{/* Left Column */}
@@ -143,6 +191,7 @@ const BlockDetailInfo: React.FC = ({ block }) => {
+ )} ) } diff --git a/cmd/rpc/web/explorer/src/components/block/BlockDetailPage.tsx b/cmd/rpc/web/explorer/src/components/block/BlockDetailPage.tsx index d8ab16086..522d468f3 100644 --- a/cmd/rpc/web/explorer/src/components/block/BlockDetailPage.tsx +++ b/cmd/rpc/web/explorer/src/components/block/BlockDetailPage.tsx @@ -267,7 +267,7 @@ const BlockDetailPage: React.FC = () => { const networkInfo = { nonce: blockData?.blockHeader?.hash?.slice(0, 16) || '0x0000000000000000', - extraData: `Canopy Network ID: ${blockData?.blockHeader?.networkID || 1}` + extraData: `Total VDF Iterations: ${blockData?.blockHeader?.totalVDFIterations?.toLocaleString() ?? 'N/A'}` } // Get validator name or use address as fallback @@ -302,7 +302,7 @@ const BlockDetailPage: React.FC = () => {
{/* Main Content */}
- + = ({ )}
-
Proposer Address
diff --git a/cmd/rpc/web/explorer/src/components/block/BlocksFilters.tsx b/cmd/rpc/web/explorer/src/components/block/BlocksFilters.tsx index 5891b09c1..a5722ee18 100644 --- a/cmd/rpc/web/explorer/src/components/block/BlocksFilters.tsx +++ b/cmd/rpc/web/explorer/src/components/block/BlocksFilters.tsx @@ -10,8 +10,6 @@ interface BlocksFiltersProps { activeFilter: string onFilterChange: (filter: string) => void totalBlocks: number - sortBy: string - onSortChange: (sort: string) => void dynamicFilters: DynamicFilter[] } @@ -19,19 +17,10 @@ const BlocksFilters: React.FC = ({ activeFilter, onFilterChange, totalBlocks, - sortBy, - onSortChange, dynamicFilters }) => { const filters = dynamicFilters - const sortOptions = [ - { key: 'height', label: 'Sort by Height' }, - { key: 'timestamp', label: 'Sort by Time' }, - { key: 'transactions', label: 'Sort by Transactions' }, - { key: 'producer', label: 'Sort by Producer' } - ] - return (
{/* Header */} @@ -45,25 +34,15 @@ const BlocksFilters: React.FC = ({

- {/* Live Updates and Total */}
-
-
-
- - {blocksTexts.filters.liveUpdates} - -
-
{blocksTexts.page.totalBlocks} {totalBlocks.toLocaleString()} {blocksTexts.page.blocksUnit}
- {/* Filters and Controls */} + {/* Filters */}
- {/* Filter Tabs */}
{filters.map((filter) => (
- - {/* Sort and Filter Controls */} -
-
- -
- -
) } -export default BlocksFilters \ No newline at end of file +export default BlocksFilters diff --git a/cmd/rpc/web/explorer/src/components/block/BlocksPage.tsx b/cmd/rpc/web/explorer/src/components/block/BlocksPage.tsx index 91f267f1e..d703bc8f5 100644 --- a/cmd/rpc/web/explorer/src/components/block/BlocksPage.tsx +++ b/cmd/rpc/web/explorer/src/components/block/BlocksPage.tsx @@ -23,7 +23,6 @@ interface DynamicFilter { const BlocksPage: React.FC = () => { const [activeFilter, setActiveFilter] = useState('all') - const [sortBy, setSortBy] = useState('height') const [currentPage, setCurrentPage] = useState(1) const [allBlocks, setAllBlocks] = useState([]) const [filteredBlocks, setFilteredBlocks] = useState([]) @@ -42,33 +41,44 @@ const BlocksPage: React.FC = () => { const isLoading = activeFilter === 'all' ? isLoadingBlocks : isLoadingCache // Normalize blocks data - const normalizeBlocks = (payload: any): Block[] => { + const normalizeBlocks = (payload: unknown): Block[] => { if (!payload) return [] - // Real structure is: { results: [...], totalCount: number } - const blocksList = payload.results || payload.blocks || payload.list || payload.data || payload + const p = payload as Record + const blocksList = p.results || p.blocks || p.list || p.data || payload if (!Array.isArray(blocksList)) return [] - return blocksList.map((block: any) => { - // Extract blockHeader data - const blockHeader = block.blockHeader || block - const height = blockHeader.height || 0 + return blocksList.map((block: Record) => { + const blockHeader = (block.blockHeader || block) as Record + const height = (blockHeader.height as number) || 0 const timestamp = blockHeader.time || blockHeader.timestamp - const hash = blockHeader.hash || 'N/A' - const producer = blockHeader.proposerAddress || blockHeader.proposer || 'N/A' - const transactions = blockHeader.numTxs || blockHeader.totalTxs || block.transactions?.length || 0 - const networkID = blockHeader.networkID - const size = block.meta?.size + const hash = (blockHeader.hash as string) || 'N/A' + const producer = (blockHeader.proposerAddress as string) || (blockHeader.proposer as string) || 'N/A' + const transactions = parseInt(blockHeader.numTxs as string, 10) || parseInt(blockHeader.totalTxs as string, 10) || (block.transactions as unknown[])?.length || 0 + const networkID = blockHeader.networkID as number | undefined + const size = (block.meta as Record)?.size as number | undefined - // Calculate age - let age = 'N/A' + let blockTimeMs = 0 if (timestamp) { - const now = Date.now() - // Timestamp comes in microseconds, convert to milliseconds - const blockTimeMs = typeof timestamp === 'number' ? - (timestamp > 1e12 ? timestamp / 1000 : timestamp) : - new Date(timestamp).getTime() + const ts = typeof timestamp === 'string' && /^\d+$/.test(timestamp) + ? Number(timestamp) + : timestamp + if (typeof ts === 'number') { + if (ts > 1e15) { + blockTimeMs = ts / 1_000 + } else if (ts > 1e12) { + blockTimeMs = ts + } else { + blockTimeMs = ts * 1_000 + } + } else { + blockTimeMs = new Date(ts as string).getTime() + } + } + let age = 'N/A' + if (blockTimeMs > 0) { + const now = Date.now() const diffMs = now - blockTimeMs const diffSecs = Math.floor(diffMs / 1000) const diffMins = Math.floor(diffSecs / 60) @@ -88,7 +98,7 @@ const BlocksPage: React.FC = () => { return { height, - timestamp: timestamp ? new Date(timestamp / 1000).toISOString() : 'N/A', + timestamp: blockTimeMs > 0 ? new Date(blockTimeMs).toISOString() : 'N/A', age, hash, producer, @@ -109,12 +119,11 @@ const BlocksPage: React.FC = () => { return filters } - // Get timestamps and calculate time range const now = Date.now() const blockTimestamps = blocks .map(block => new Date(block.timestamp).getTime()) .filter(ts => !isNaN(ts)) - .sort((a, b) => b - a) // Most recent first + .sort((a, b) => b - a) if (blockTimestamps.length === 0) { return filters @@ -123,110 +132,61 @@ const BlocksPage: React.FC = () => { const mostRecent = blockTimestamps[0] const oldest = blockTimestamps[blockTimestamps.length - 1] - // Calculate age of most recent block from now const ageOfMostRecentMs = now - mostRecent const ageOfMostRecentHours = ageOfMostRecentMs / (60 * 60 * 1000) const ageOfMostRecentDays = ageOfMostRecentMs / (24 * 60 * 60 * 1000) - // Calculate total time range covered by cached blocks const totalRangeMs = mostRecent - oldest const totalRangeHours = totalRangeMs / (60 * 60 * 1000) const totalRangeDays = totalRangeMs / (24 * 60 * 60 * 1000) - // Only show time filters if the most recent block is recent enough - // If blocks are from months ago, don't show short-term filters if (ageOfMostRecentDays >= 30) { - // Blocks are very old (months), show only longer-term filters - if (totalRangeDays >= 14) { - filters.push({ key: '2w', label: 'Last 2 weeks' }) - } - if (totalRangeDays >= 7) { - filters.push({ key: 'week', label: 'Last week' }) - } - if (totalRangeDays >= 3) { - filters.push({ key: '3d', label: 'Last 3 days' }) - } + if (totalRangeDays >= 14) filters.push({ key: '2w', label: 'Last 2 weeks' }) + if (totalRangeDays >= 7) filters.push({ key: 'week', label: 'Last week' }) + if (totalRangeDays >= 3) filters.push({ key: '3d', label: 'Last 3 days' }) } else if (ageOfMostRecentDays >= 7) { - // Blocks are weeks old - if (totalRangeDays >= 7) { - filters.push({ key: 'week', label: 'Last week' }) - } - if (totalRangeDays >= 3) { - filters.push({ key: '3d', label: 'Last 3 days' }) - } - if (totalRangeDays >= 1) { - filters.push({ key: '24h', label: 'Last 24h' }) - } + if (totalRangeDays >= 7) filters.push({ key: 'week', label: 'Last week' }) + if (totalRangeDays >= 3) filters.push({ key: '3d', label: 'Last 3 days' }) + if (totalRangeDays >= 1) filters.push({ key: '24h', label: 'Last 24h' }) } else if (ageOfMostRecentDays >= 1) { - // Blocks are days old - if (totalRangeDays >= 3) { - filters.push({ key: '3d', label: 'Last 3 days' }) - } - if (totalRangeDays >= 1) { - filters.push({ key: '24h', label: 'Last 24h' }) - } - if (totalRangeHours >= 12) { - filters.push({ key: '12h', label: 'Last 12h' }) - } - if (totalRangeHours >= 6) { - filters.push({ key: '6h', label: 'Last 6h' }) - } + if (totalRangeDays >= 3) filters.push({ key: '3d', label: 'Last 3 days' }) + if (totalRangeDays >= 1) filters.push({ key: '24h', label: 'Last 24h' }) + if (totalRangeHours >= 12) filters.push({ key: '12h', label: 'Last 12h' }) + if (totalRangeHours >= 6) filters.push({ key: '6h', label: 'Last 6h' }) } else if (ageOfMostRecentHours >= 6) { - // Blocks are hours old - if (totalRangeHours >= 6) { - filters.push({ key: '6h', label: 'Last 6h' }) - } - if (totalRangeHours >= 3) { - filters.push({ key: '3h', label: 'Last 3h' }) - } - if (totalRangeHours >= 1) { - filters.push({ key: '1h', label: 'Last 1h' }) - } + if (totalRangeHours >= 6) filters.push({ key: '6h', label: 'Last 6h' }) + if (totalRangeHours >= 3) filters.push({ key: '3h', label: 'Last 3h' }) + if (totalRangeHours >= 1) filters.push({ key: '1h', label: 'Last 1h' }) } else if (ageOfMostRecentHours >= 1) { - // Blocks are less than 6 hours old - if (totalRangeHours >= 2) { - filters.push({ key: '2h', label: 'Last 2h' }) - } - if (totalRangeHours >= 1) { - filters.push({ key: '1h', label: 'Last 1h' }) - } - if (totalRangeMs >= 30 * 60 * 1000) { - filters.push({ key: '30m', label: 'Last 30min' }) - } + if (totalRangeHours >= 2) filters.push({ key: '2h', label: 'Last 2h' }) + if (totalRangeHours >= 1) filters.push({ key: '1h', label: 'Last 1h' }) + if (totalRangeMs >= 30 * 60 * 1000) filters.push({ key: '30m', label: 'Last 30min' }) } else { - // Blocks are very recent (less than 1 hour old) - if (totalRangeMs >= 30 * 60 * 1000) { - filters.push({ key: '30m', label: 'Last 30min' }) - } - if (totalRangeMs >= 15 * 60 * 1000) { - filters.push({ key: '15m', label: 'Last 15min' }) - } + if (totalRangeMs >= 30 * 60 * 1000) filters.push({ key: '30m', label: 'Last 30min' }) + if (totalRangeMs >= 15 * 60 * 1000) filters.push({ key: '15m', label: 'Last 15min' }) } return filters } - // Filter blocks based on time filter (supports dynamic filters) + // Filter blocks based on time filter const filterBlocksByTime = (blocks: Block[], filter: string): Block[] => { const now = Date.now() - // If there are no blocks or few blocks, don't filter if (!blocks || blocks.length < 3) { return blocks; } - // Sort first by timestamp to ensure correct filtering const sortedBlocks = [...blocks].sort((a, b) => { const timeA = new Date(a.timestamp).getTime(); const timeB = new Date(b.timestamp).getTime(); - return timeB - timeA; // Descending order (most recent first) + return timeB - timeA; }); if (filter === 'all') { return sortedBlocks } - // Parse dynamic filter keys let timeMs = 0 if (filter === '15m') timeMs = 15 * 60 * 1000 else if (filter === '30m') timeMs = 30 * 60 * 1000 @@ -239,7 +199,6 @@ const BlocksPage: React.FC = () => { else if (filter === '3d') timeMs = 3 * 24 * 60 * 60 * 1000 else if (filter === 'week') timeMs = 7 * 24 * 60 * 60 * 1000 else if (filter === '2w') timeMs = 14 * 24 * 60 * 60 * 1000 - // Legacy support else if (filter === 'hour') timeMs = 60 * 60 * 1000 if (timeMs === 0) { @@ -252,24 +211,6 @@ const BlocksPage: React.FC = () => { }) } - // Sort blocks based on sort criteria - const sortBlocks = (blocks: Block[], sortCriteria: string): Block[] => { - const sortedBlocks = [...blocks] - - switch (sortCriteria) { - case 'height': - return sortedBlocks.sort((a, b) => b.height - a.height) // Descending - case 'timestamp': - return sortedBlocks.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - case 'transactions': - return sortedBlocks.sort((a, b) => b.transactions - a.transactions) - case 'producer': - return sortedBlocks.sort((a, b) => a.producer.localeCompare(b.producer)) - default: - return sortedBlocks - } - } - // Normalize cached blocks const cachedBlocks = useMemo(() => { if (!cachedBlocksRaw || !Array.isArray(cachedBlocksRaw)) { @@ -290,27 +231,19 @@ const BlocksPage: React.FC = () => { } }, [dynamicFilters, activeFilter]) - // Apply filters and sorting - const applyFiltersAndSort = React.useCallback(() => { + // Apply filters + const applyFilters = React.useCallback(() => { if (activeFilter === 'all') { - // For "all" filter, use blocks from useBlocks API - if (allBlocks.length === 0) { - setFilteredBlocks([]) - return - } - const sorted = sortBlocks(allBlocks, sortBy) - setFilteredBlocks(sorted) + setFilteredBlocks(allBlocks) } else { - // For time-based filters, filter and sort cached blocks if (cachedBlocks.length === 0) { setFilteredBlocks([]) return } - let filtered = filterBlocksByTime(cachedBlocks, activeFilter) - filtered = sortBlocks(filtered, sortBy) + const filtered = filterBlocksByTime(cachedBlocks, activeFilter) setFilteredBlocks(filtered) } - }, [allBlocks, cachedBlocks, activeFilter, sortBy]) + }, [allBlocks, cachedBlocks, activeFilter]) // Effect to update blocks when data changes (for "all" filter) useEffect(() => { @@ -328,16 +261,15 @@ const BlocksPage: React.FC = () => { } }, [isLoadingCache, activeFilter]) - // Effect to apply filters and sorting when they change + // Effect to apply filters when they change useEffect(() => { - applyFiltersAndSort() - // When activeFilter changes, reset to first page to prevent showing empty results + applyFilters() if (activeFilter !== 'all') { setCurrentPage(1) } - }, [allBlocks, cachedBlocks, activeFilter, sortBy, applyFiltersAndSort]) + }, [allBlocks, cachedBlocks, activeFilter, applyFilters]) - // Effect to simulate real-time updates for age + // Effect to update age display in real-time useEffect(() => { const updateBlockAge = (blocks: Block[]): Block[] => { return blocks.map(block => { @@ -371,25 +303,19 @@ const BlocksPage: React.FC = () => { return () => clearInterval(interval) }, []) - // Get total blocks count from API const totalBlocks = blocksData?.totalCount || 0 - // Calculate total filtered blocks for pagination const totalFilteredBlocks = React.useMemo(() => { if (activeFilter === 'all') { - return totalBlocks // Use total from API when showing all blocks + return totalBlocks } - // For time-based filters, use actual filtered count return filteredBlocks.length }, [activeFilter, totalBlocks, filteredBlocks.length]) - // Apply local pagination for non-"all" filters (always show 10 per page) const paginatedBlocks = React.useMemo(() => { if (activeFilter === 'all') { - // For "all" filter, blocks are already paginated by API return filteredBlocks } - // For time-based filters, paginate locally (10 per page) const startIndex = (currentPage - 1) * 10 const endIndex = startIndex + 10 return filteredBlocks.slice(startIndex, endIndex) @@ -401,11 +327,6 @@ const BlocksPage: React.FC = () => { const handleFilterChange = (filter: string) => { setActiveFilter(filter) - // Pagination resets automatically in the useEffect when the filter changes - } - - const handleSortChange = (sortCriteria: string) => { - setSortBy(sortCriteria) } return ( @@ -420,8 +341,6 @@ const BlocksPage: React.FC = () => { activeFilter={activeFilter} onFilterChange={handleFilterChange} totalBlocks={totalBlocks} - sortBy={sortBy} - onSortChange={handleSortChange} dynamicFilters={dynamicFilters} /> @@ -436,4 +355,4 @@ const BlocksPage: React.FC = () => { ) } -export default BlocksPage \ No newline at end of file +export default BlocksPage diff --git a/cmd/rpc/web/explorer/src/components/staking/SupplyView.tsx b/cmd/rpc/web/explorer/src/components/staking/SupplyView.tsx index c39a59813..49f26c2fc 100644 --- a/cmd/rpc/web/explorer/src/components/staking/SupplyView.tsx +++ b/cmd/rpc/web/explorer/src/components/staking/SupplyView.tsx @@ -43,44 +43,28 @@ const SupplyView: React.FC = () => { value: stakedSupplyCNPY, suffix: ' CNPY', icon: 'fa-solid fa-coins', - color: 'text-white', - bgColor: 'bg-card', - description: 'delta', - delta: '+2.09M', - deltaColor: 'text-primary' + iconColor: 'text-primary', }, { title: 'Total Supply', value: totalSupplyCNPY, suffix: ' CNPY', icon: 'fa-solid fa-coins', - color: 'text-white', - bgColor: 'bg-card', - description: 'circulating', - delta: '+1.2M', - deltaColor: 'text-blue-400' + iconColor: 'text-blue-400', }, { title: 'Liquid Supply', value: liquidSupplyCNPY, suffix: ' CNPY', icon: 'fa-solid fa-water', - color: 'text-white', - bgColor: 'bg-card', - description: 'available', - delta: '-0.5M', - deltaColor: 'text-red-400' + iconColor: 'text-red-400', }, { title: 'Staking Ratio', value: stakingRatio, suffix: '%', icon: 'fa-solid fa-percentage', - color: 'text-white', - bgColor: 'bg-card', - description: 'ratio', - delta: '+5.2%', - deltaColor: 'text-primary' + iconColor: 'text-primary', } ] @@ -112,7 +96,7 @@ const SupplyView: React.FC = () => { > {/* Icon in top-right */}
- +
{/* Title */} @@ -121,7 +105,7 @@ const SupplyView: React.FC = () => { {/* Main Value */} -
+
{ {metric.suffix}
- - {/* Delta and Description */} -
- - {metric.delta} - - - {metric.description} - -
))}
@@ -197,7 +171,6 @@ const SupplyView: React.FC = () => { {/* Supply Statistics */} { - -
-

Staking Information

-
-
- Staking Ratio - - % - -
-
- Staking Status - - {stakingRatio > 50 ? 'High' : stakingRatio > 25 ? 'Medium' : 'Low'} - -
-
- Network Health - - {stakingRatio > 60 ? 'Excellent' : stakingRatio > 40 ? 'Good' : 'Fair'} - -
-
-
) diff --git a/cmd/rpc/web/explorer/src/components/token-swaps/RecentSwapsTable.tsx b/cmd/rpc/web/explorer/src/components/token-swaps/RecentSwapsTable.tsx index 3ef51230f..5a9331193 100644 --- a/cmd/rpc/web/explorer/src/components/token-swaps/RecentSwapsTable.tsx +++ b/cmd/rpc/web/explorer/src/components/token-swaps/RecentSwapsTable.tsx @@ -1,35 +1,17 @@ import React from 'react'; -import AnimatedNumber from '../AnimatedNumber'; import TableCard from '../Home/TableCard'; - -interface Swap { - hash: string; - assetPair: string; - action: 'Buy CNPY' | 'Sell CNPY'; - block: number; - age: string; - fromAddress: string; - toAddress: string; - exchangeRate: string; - amount: string; - orderId: string; - committee: number; - status: 'Active' | 'Locked' | 'Completed'; -} +import type { SwapData } from './TokenSwapsPage'; interface RecentSwapsTableProps { - swaps: Swap[]; + swaps: SwapData[]; loading: boolean; + onRowClick?: (swap: SwapData) => void; } -const RecentSwapsTable: React.FC = ({ swaps, loading }) => { - // Define table columns +const RecentSwapsTable: React.FC = ({ swaps, loading, onRowClick }) => { const columns = [ - { label: 'Hash', key: 'hash' }, - { label: 'Asset Pair', key: 'assetPair' }, - { label: 'Action', key: 'action' }, - { label: 'Block', key: 'block' }, - { label: 'Age', key: 'age' }, + { label: 'Order ID', key: 'orderId' }, + { label: 'Committee', key: 'committee' }, { label: 'From Address', key: 'fromAddress' }, { label: 'To Address', key: 'toAddress' }, { label: 'Exchange Rate', key: 'exchangeRate' }, @@ -37,49 +19,29 @@ const RecentSwapsTable: React.FC = ({ swaps, loading }) = { label: 'Status', key: 'status' } ]; - // Transform swaps data to table rows const rows = swaps.map((swap) => [ - // Hash - {swap.hash}, - - // Asset Pair - {swap.assetPair}, - - // Action - - {swap.action} + onRowClick?.(swap)} + > + {swap.orderId.length > 16 + ? swap.orderId.slice(0, 8) + '...' + swap.orderId.slice(-4) + : swap.orderId} , - - // Block - , - - // Age - {swap.age}, - - // From Address + + {swap.committee}, + {swap.fromAddress}, - - // To Address + {swap.toAddress}, - - // Exchange Rate + {swap.exchangeRate}, - - // Amount - - {swap.amount} - , - - // Status + + {swap.amount}, + {swap.status} diff --git a/cmd/rpc/web/explorer/src/components/token-swaps/SwapFilters.tsx b/cmd/rpc/web/explorer/src/components/token-swaps/SwapFilters.tsx index 0503c75e6..40fbef4b0 100644 --- a/cmd/rpc/web/explorer/src/components/token-swaps/SwapFilters.tsx +++ b/cmd/rpc/web/explorer/src/components/token-swaps/SwapFilters.tsx @@ -1,15 +1,12 @@ import React, { useState, useEffect } from 'react'; interface SwapFiltersProps { - onApplyFilters: (filters: any) => void; + onApplyFilters: (filters: { minAmount: string }) => void; onResetFilters: () => void; filters: { - assetPair: string; - actionType: string; - timeRange: string; minAmount: string; }; - onFiltersChange: (filters: any) => void; + onFiltersChange: (filters: { minAmount: string }) => void; } const SwapFilters: React.FC = ({ onApplyFilters, onResetFilters, filters, onFiltersChange }) => { @@ -30,12 +27,7 @@ const SwapFilters: React.FC = ({ onApplyFilters, onResetFilter }; const handleReset = () => { - const resetFilters = { - assetPair: 'All Pairs', - actionType: 'All Actions', - timeRange: 'Last 24 Hours', - minAmount: '' - }; + const resetFilters = { minAmount: '' }; setLocalFilters(resetFilters); onFiltersChange(resetFilters); onResetFilters(); @@ -44,57 +36,8 @@ const SwapFilters: React.FC = ({ onApplyFilters, onResetFilter return (
- {/* Asset Pair */}
- - -
- - {/* Action Type */} -
- - -
- - {/* Time Range */} -
- - -
- - {/* Min Amount */} -
- + { + const navigate = useNavigate(); const [selectedChainId] = useState(1); const [filters, setFilters] = useState({ - assetPair: 'All Pairs', - actionType: 'All Actions', - timeRange: 'Last 24 Hours', minAmount: '' }); - // Fetch orders data const { data: ordersData, isLoading } = useOrders(selectedChainId); - // Transform orders data to swaps format const swaps = useMemo(() => { - const ordersList = Array.isArray((ordersData as any)?.orders) - ? (ordersData as any).orders - : Array.isArray((ordersData as any)?.results) - ? (ordersData as any).results + const ordersList = Array.isArray((ordersData as Record)?.orders) + ? (ordersData as Record).orders + : Array.isArray((ordersData as Record)?.results) + ? (ordersData as Record).results : []; if (ordersList.length === 0) return []; - return ordersList.map((order: Order) => { - // Determine asset pair based on committee (this is a simplified mapping) - const assetPairs = ['CNPY/ETH', 'CNPY/BTC', 'CNPY/SOL', 'CNPY/USDC', 'CNPY/AVAX']; - const assetPair = assetPairs[order.committee % assetPairs.length] || 'CNPY/UNKNOWN'; - - // Calculate exchange rate (CNPY per unit of counter asset) + return ordersList.map((rawOrder) => { + const order = rawOrder as Order; const exchangeRate = order.requestedAmount > 0 - ? `1 Asset = ${(order.amountForSale / order.requestedAmount).toFixed(6)} CNPY` + ? `1 : ${(order.amountForSale / order.requestedAmount).toFixed(6)}` : 'N/A'; - // Determine action (all orders are sell orders in the API) - const action = 'Sell CNPY'; + const status: 'Active' | 'Locked' = order.buyerSendAddress ? 'Locked' : 'Active'; - // Determine status - const status = order.buyerSendAddress ? 'Locked' : 'Active'; - - // Format amounts (convert from micro denomination to CNPY) const cnpyAmount = (order.amountForSale / 1000000).toFixed(6); - const amount = `-${cnpyAmount} CNPY`; + const amount = `${cnpyAmount} CNPY`; - // Format addresses const truncateAddress = (addr: string) => { if (!addr || addr.length < 10) return addr; return addr.slice(0, 6) + '...' + addr.slice(-4); }; return { - hash: order.id.slice(0, 8) + '...' + order.id.slice(-4), - assetPair, - action, - block: Math.floor(Math.random() * 1000000) + 6000000, // Simulated block number - age: 'Unknown', // We don't have timestamp in the API + orderId: order.id, + committee: order.committee, fromAddress: truncateAddress(order.sellersSendAddress), toAddress: truncateAddress(order.sellerReceiveAddress), exchangeRate, amount, - orderId: order.id, - committee: order.committee, status - }; + } satisfies SwapData; }); }, [ordersData]); - // Apply filters const filteredSwaps = useMemo(() => { - return swaps.filter((swap: SwapData) => { - if (filters.assetPair !== 'All Pairs' && swap.assetPair !== filters.assetPair) { - return false; - } - if (filters.actionType !== 'All Actions' && swap.action !== filters.actionType) { - return false; - } + return swaps.filter((swap) => { if (filters.minAmount && parseFloat(swap.amount.replace(/[^\d.-]/g, '')) < parseFloat(filters.minAmount)) { return false; } @@ -112,28 +82,20 @@ const TokenSwapsPage: React.FC = () => { }); }, [swaps, filters]); - const handleApplyFilters = (newFilters: any) => { + const handleApplyFilters = (newFilters: { minAmount: string }) => { setFilters(newFilters); }; const handleResetFilters = () => { - setFilters({ - assetPair: 'All Pairs', - actionType: 'All Actions', - timeRange: 'Last 24 Hours', - minAmount: '' - }); + setFilters({ minAmount: '' }); }; const handleExportData = () => { const csvContent = [ - ['Hash', 'Asset Pair', 'Action', 'Block', 'Age', 'From Address', 'To Address', 'Exchange Rate', 'Amount', 'Status'], - ...filteredSwaps.map((swap: SwapData) => [ - swap.hash, - swap.assetPair, - swap.action, - swap.block.toString(), - swap.age, + ['Order ID', 'Committee', 'From Address', 'To Address', 'Exchange Rate', 'Amount', 'Status'], + ...filteredSwaps.map((swap) => [ + swap.orderId, + swap.committee.toString(), swap.fromAddress, swap.toAddress, swap.exchangeRate, @@ -151,6 +113,10 @@ const TokenSwapsPage: React.FC = () => { window.URL.revokeObjectURL(url); }; + const handleRowClick = (swap: SwapData) => { + navigate(`/transaction/${swap.orderId}`); + }; + return ( {

Token Swaps

-

Real-time atomic swaps between Canopy (CNPY) and other cryptocurrencies

+

Atomic swap orders on the Canopy network

-
- Nonce - {nonce} -
-
@@ -537,7 +520,7 @@ const TransactionDetailPage: React.FC = () => {
- {/* Gas Information */} + {/* Fee Information */} { className="bg-card rounded-xl border border-gray-800/60 p-6" >

- Gas Information + Fee Information

-
-
- Gas Used - {transactionFeeMicro.toLocaleString()} -
-
-
-
-
- 0 - {transactionFeeMicro.toLocaleString()} (Gas Limit) -
-
-
- Transaction Fee + Fee Paid {formatFee(transactionFeeMicro)}
+
+ Fee (uCNPY) + {transactionFeeMicro.toLocaleString()} +
{minimumFeeForTxType > 0 && (
Minimum Fee ({getFeeParamKey(txType)}) @@ -633,12 +603,6 @@ const TransactionDetailPage: React.FC = () => { {memo}
)} - {confirmations !== null && ( -
- Confirmations - {confirmations.toLocaleString()} -
- )}
diff --git a/cmd/rpc/web/explorer/src/components/transaction/TransactionsPage.tsx b/cmd/rpc/web/explorer/src/components/transaction/TransactionsPage.tsx index a6d7f58a5..2358ee5cd 100644 --- a/cmd/rpc/web/explorer/src/components/transaction/TransactionsPage.tsx +++ b/cmd/rpc/web/explorer/src/components/transaction/TransactionsPage.tsx @@ -1,67 +1,11 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { motion } from 'framer-motion' import TransactionsTable from './TransactionsTable' -import { useTransactionsWithRealPagination, useTransactions, useAllBlocksCache, useTxByHash } from '../../hooks/useApi' +import { useBlockByHeight, useLatestBlock, usePending } from '../../hooks/useApi' import { getTotalTransactionCount } from '../../lib/api' import transactionsTexts from '../../data/transactions.json' import { formatDistanceToNow, parseISO, isValid } from 'date-fns' -interface OverviewCardProps { - title: string - value: string | number - subValue?: string - icon?: string - progressBar?: number - valueColor?: string - subValueColor?: string -} - -interface SelectFilter { - type: 'select' - label: string - options: string[] - value: string - onChange: (value: string) => void -} - -interface BlockRangeFilter { - type: 'blockRange' - label: string - fromBlock: string - toBlock: string - onFromBlockChange: (block: string) => void - onToBlockChange: (block: string) => void -} - -interface StatusFilter { - type: 'statusButtons' - label: string - options: Array<{ label: string; status: 'success' | 'failed' | 'pending' }> - selectedStatus: 'success' | 'failed' | 'pending' | 'all' - onStatusChange: (status: 'success' | 'failed' | 'pending' | 'all') => void -} - -interface AmountRangeFilter { - type: 'amountRangeSlider' // Changed to slider - label: string - value: number // Selected value on the slider - onChange: (value: number) => void - min: number - max: number - step: number - displayLabels: { value: number; label: string }[] -} - -interface SearchFilter { - type: 'search' - label: string - placeholder: string - value: string - onChange: (value: string) => void -} - -type FilterProps = SelectFilter | BlockRangeFilter | StatusFilter | AmountRangeFilter | SearchFilter - interface Transaction { hash: string type: string @@ -72,214 +16,220 @@ interface Transaction { status: 'success' | 'failed' | 'pending' age: string blockHeight?: number - date?: number // Timestamp in milliseconds for calculations + date?: number } -interface ApiFilters { - type?: string - fromBlock?: string - toBlock?: string - status?: 'success' | 'failed' | 'pending' - address?: string - minAmount?: number - maxAmount?: number -} +const TX_TYPES = [ + 'All Types', + 'send', + 'stake', + 'editStake', + 'unstake', + 'pause', + 'unpause', + 'changeParameter', + 'daoTransfer', + 'certificateResults', + 'subsidy', + 'createOrder', + 'editOrder', + 'deleteOrder', +] as const + +type ViewMode = 'confirmed' | 'pending' const TransactionsPage: React.FC = () => { - const [transactions, setTransactions] = useState([]) - const [loading, setLoading] = useState(true) - const [currentPage, setCurrentPage] = useState(1) - - // Filter states + const [viewMode, setViewMode] = useState('confirmed') + const [heightInput, setHeightInput] = useState('') + const [queryHeight, setQueryHeight] = useState(0) const [transactionType, setTransactionType] = useState('All Types') - const [fromBlock, setFromBlock] = useState('') - const [toBlock, setToBlock] = useState('') - const [statusFilter, setStatusFilter] = useState<'success' | 'failed' | 'pending' | 'all'>('all') - const [amountRangeValue, setAmountRangeValue] = useState(0) - const [addressSearch, setAddressSearch] = useState('') + const [currentPage, setCurrentPage] = useState(1) const [entriesPerPage, setEntriesPerPage] = useState(10) + const [pendingPage, setPendingPage] = useState(1) - // Applied filters used by API queries (separate from draft UI filter state). - const [appliedFilters, setAppliedFilters] = useState({}) + const { data: latestBlockData } = useLatestBlock() + const { data: blockData, isLoading: isBlockLoading } = useBlockByHeight( + viewMode === 'confirmed' ? queryHeight : 0 + ) + const { data: pendingData, isLoading: isPendingLoading } = usePending( + viewMode === 'pending' ? pendingPage : 0 + ) - // Create filter object for API from applied state only - const apiFilters = appliedFilters + const latestHeight = useMemo(() => { + if (!latestBlockData) return 0 + const results = latestBlockData.results || latestBlockData + if (Array.isArray(results) && results.length > 0) { + return Number(results[0]?.blockHeader?.height ?? results[0]?.height ?? 0) + } + return Number(latestBlockData.totalCount ?? 0) + }, [latestBlockData]) - // Detect if search is a transaction hash - const appliedSearchTerm = appliedFilters.address || '' - const isHashSearch = appliedSearchTerm && appliedSearchTerm.length >= 32 && /^[a-fA-F0-9]+$/.test(appliedSearchTerm) + useEffect(() => { + if (latestHeight > 0 && queryHeight === 0) { + setQueryHeight(latestHeight) + setHeightInput(String(latestHeight)) + } + }, [latestHeight, queryHeight]) - // Hook for direct hash search - const { data: hashSearchData, isLoading: isHashLoading } = useTxByHash(isHashSearch ? appliedSearchTerm : '') + const normalizeBlockTransactions = (block: Record): Transaction[] => { + if (!block) return [] - // Hook to get all transactions data with real pagination - const { data: transactionsData, isLoading } = useTransactionsWithRealPagination(currentPage, entriesPerPage, apiFilters) + const txList = (block as Record).blockTxs + ?? (block as Record).transactions + ?? (block as Record).txs + if (!Array.isArray(txList)) return [] - // Hook to get blocks data to determine default block range - const { data: blocksData } = useAllBlocksCache() // Get first page of blocks + const blockHeight = Number( + (block as Record).height + ?? ((block as Record).blockHeader as Record)?.height + ?? 0 + ) + const blockHeader = (block as Record).blockHeader as Record | undefined + const blockTime = blockHeader?.time ?? blockHeader?.timestamp ?? (block as Record).time - // Normalize transaction data - const normalizeTransactions = (payload: any): Transaction[] => { - if (!payload) return [] + return txList.map((tx: Record) => normalizeSingleTx(tx, blockHeight, blockTime)) + } - // Real structure is: { results: [...], totalCount: number } - const transactionsList = payload.results || payload.transactions || payload.list || payload.data || payload - if (!Array.isArray(transactionsList)) return [] + const normalizePendingTransactions = (data: unknown): Transaction[] => { + if (!data) return [] + const payload = data as Record + const txList = payload.results ?? payload.transactions ?? payload.txs ?? payload + if (!Array.isArray(txList)) return [] - return transactionsList.map((tx: any) => { - // Extract transaction data - const hash = tx.txHash || tx.hash || 'N/A' - const type = tx.messageType || tx.type || 'send' - const from = tx.sender || tx.from || 'N/A' - // Handle different transaction types for "To" field - let to = tx.recipient || tx.to || 'N/A' + return txList.map((tx: Record) => normalizeSingleTx(tx, undefined, undefined, 'pending')) + } - // For certificateResults, extract from reward recipients - if (type === 'certificateResults' && tx.transaction?.msg?.qc?.results?.rewardRecipients?.paymentPercents) { - const recipients = tx.transaction.msg.qc.results.rewardRecipients.paymentPercents - if (recipients.length > 0) { - to = recipients[0].address || 'N/A' - } + const normalizeSingleTx = ( + tx: Record, + blockHeight?: number, + blockTime?: unknown, + forceStatus?: 'pending' + ): Transaction => { + const hash = String(tx.txHash ?? tx.hash ?? 'N/A') + const type = String(tx.messageType ?? tx.type ?? 'send') + const from = String(tx.sender ?? tx.from ?? 'N/A') + + let to = String(tx.recipient ?? tx.to ?? 'N/A') + if ( + type === 'certificateResults' && + tx.transaction && + typeof tx.transaction === 'object' + ) { + const msg = (tx.transaction as Record).msg as Record | undefined + const qc = msg?.qc as Record | undefined + const results = qc?.results as Record | undefined + const rr = results?.rewardRecipients as Record | undefined + const pp = rr?.paymentPercents as Array> | undefined + if (pp && pp.length > 0) { + to = String(pp[0].address ?? 'N/A') } - const amount = tx.amount || tx.value || 0 - // Extract fee from transaction - it comes in micro denomination from endpoint - const fee = tx.transaction?.fee || tx.fee || 0 // Fee is in micro denomination (uCNPY) according to README - const status = tx.status || 'success' - const blockHeight = tx.blockHeight || tx.height || 0 - - let age = 'N/A' - let transactionDate: number | undefined - - // Use blockTime if available, otherwise timestamp or time - const timeSource = tx.blockTime || tx.timestamp || tx.time - if (timeSource) { - try { - // Handle different timestamp formats - let date: Date - if (typeof timeSource === 'number') { - // If timestamp is in microseconds (Canopy format) - if (timeSource > 1e12) { - date = new Date(timeSource / 1000) - } else { - date = new Date(timeSource * 1000) - } - } else if (typeof timeSource === 'string') { - date = parseISO(timeSource) - } else { + } + + const amount = Number(tx.amount ?? tx.value ?? 0) + const fee = Number( + (tx.transaction && typeof tx.transaction === 'object' + ? (tx.transaction as Record).fee + : tx.fee) ?? 0 + ) + const status = forceStatus ?? ((tx.status as 'success' | 'failed' | 'pending') || 'success') + + let age = 'N/A' + let transactionDate: number | undefined + const timeSource = blockTime ?? tx.blockTime ?? tx.timestamp ?? tx.time + if (timeSource) { + try { + let date: Date + if (typeof timeSource === 'number') { + if (timeSource > 1e15) { + date = new Date(timeSource / 1000) + } else if (timeSource > 1e12) { date = new Date(timeSource) + } else { + date = new Date(timeSource * 1000) } - - if (isValid(date)) { - transactionDate = date.getTime() - age = formatDistanceToNow(date, { addSuffix: true }) + } else if (typeof timeSource === 'string') { + if (/^\d+$/.test(timeSource)) { + const n = Number(timeSource) + if (n > 1e15) date = new Date(n / 1000) + else if (n > 1e12) date = new Date(n) + else date = new Date(n * 1000) + } else { + date = parseISO(timeSource) } - } catch (error) { - console.error('Error calculating age:', error) - age = 'N/A' + } else { + date = new Date(timeSource as string | number) } + if (isValid(date!)) { + transactionDate = date!.getTime() + age = formatDistanceToNow(date!, { addSuffix: true }) + } + } catch { + age = 'N/A' } - - return { - hash, - type, - from, - to, - amount, - fee, - status, - age, - blockHeight, - date: transactionDate, - } - }) - } - - // Effect to update transactions when data changes - useEffect(() => { - if (isHashSearch && hashSearchData) { - // If it's hash search, convert single result to array - const singleTransaction = normalizeTransactions({ results: [hashSearchData] }) - setTransactions(singleTransaction) - setLoading(false) - } else if (!isHashSearch && transactionsData) { - // If it's normal search, use pagination data - const normalizedTransactions = normalizeTransactions(transactionsData) - setTransactions(normalizedTransactions) - setLoading(false) } - }, [transactionsData, hashSearchData, isHashSearch]) - // Effect to set default block values - useEffect(() => { - if (blocksData && Array.isArray(blocksData)) { - const blocks = blocksData - const latestBlock = blocks[0] // First block is the most recent - const oldestBlock = blocks[blocks.length - 1] // Last block is the oldest - - const latestHeight = latestBlock.blockHeader?.height || latestBlock.height || 0 - const oldestHeight = oldestBlock.blockHeader?.height || oldestBlock.height || 0 - - // Set default values if not already set - if (!fromBlock && !toBlock) { - setToBlock(latestHeight.toString()) - setFromBlock(oldestHeight.toString()) - } - } - }, [blocksData, fromBlock, toBlock]) + const height = blockHeight ?? (Number(tx.blockHeight ?? tx.height ?? 0) || undefined) - // Get transaction stats directly + return { hash, type, from, to, amount, fee, status, age, blockHeight: height, date: transactionDate } + } + + const allTransactions = useMemo(() => { + if (viewMode === 'pending') return normalizePendingTransactions(pendingData) + return normalizeBlockTransactions(blockData) + }, [blockData, pendingData, viewMode]) + + const filteredTransactions = useMemo(() => { + if (transactionType === 'All Types') return allTransactions + return allTransactions.filter( + (tx) => tx.type.toLowerCase() === transactionType.toLowerCase() + ) + }, [allTransactions, transactionType]) + + const paginatedTransactions = useMemo(() => { + if (viewMode === 'pending') return filteredTransactions + const start = (currentPage - 1) * entriesPerPage + return filteredTransactions.slice(start, start + entriesPerPage) + }, [filteredTransactions, currentPage, entriesPerPage, viewMode]) + + // Overview stats const [transactionsToday, setTransactionsToday] = useState(0) const [tpmLast24h, setTpmLast24h] = useState(0) - const [totalTransactions, setTotalTransactions] = useState(0) useEffect(() => { - const fetchStats = async () => { - try { - const stats = await getTotalTransactionCount() + getTotalTransactionCount() + .then((stats) => { setTransactionsToday(stats.last24h) setTpmLast24h(stats.tpm) - setTotalTransactions(stats.total) - } catch (error) { - console.error('Error fetching transaction stats:', error) - } - } - fetchStats() + }) + .catch(() => {}) }, []) - const isLoadingData = isHashSearch ? isHashLoading : isLoading - const displayTotalTransactions = isHashSearch - ? (hashSearchData ? 1 : 0) - : (transactionsData?.totalCount ?? transactions.length) - - // Helper function to format fee - shows in CNPY (converted from micro denomination) const formatFeeDisplay = (micro: number): string => { if (micro === 0) return '0 CNPY' const cnpy = micro / 1000000 return `${cnpy.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 })} CNPY` } - const averageFee = React.useMemo(() => { - if (transactions.length === 0) return '0' - const totalFees = transactions.reduce((sum, tx) => sum + (tx.fee || 0), 0) - const avgFeeMicro = totalFees / transactions.length - return formatFeeDisplay(avgFeeMicro) - }, [transactions]) - - - - - // Calculate success rate - const successRate = React.useMemo(() => { - if (transactions.length === 0) return 0 - const successfulTxs = transactions.filter(tx => tx.status === 'success').length - return Math.round((successfulTxs / transactions.length) * 100) - }, [transactions]) - - const overviewCards: OverviewCardProps[] = [ + const averageFee = useMemo(() => { + if (filteredTransactions.length === 0) return '0' + const total = filteredTransactions.reduce((s, tx) => s + (tx.fee || 0), 0) + return formatFeeDisplay(total / filteredTransactions.length) + }, [filteredTransactions]) + + const successRate = useMemo(() => { + if (filteredTransactions.length === 0) return 0 + return Math.round( + (filteredTransactions.filter((tx) => tx.status === 'success').length / + filteredTransactions.length) * + 100 + ) + }, [filteredTransactions]) + + const overviewCards = [ { - title: 'Transactions Today', + title: 'Transactions (estimated)', value: transactionsToday.toLocaleString(), - subValue: `Last 24 hours`, + subValue: 'From recent blocks', icon: 'fa-solid fa-arrow-right-arrow-left text-primary', valueColor: 'text-white', subValueColor: 'text-primary', @@ -287,7 +237,7 @@ const TransactionsPage: React.FC = () => { { title: 'Average Fee', value: averageFee, - subValue: 'CNPY', + subValue: viewMode === 'pending' ? 'CNPY (pending)' : 'CNPY (current block)', icon: 'fa-solid fa-coins text-primary', valueColor: 'text-white', subValueColor: 'text-gray-400', @@ -300,159 +250,78 @@ const TransactionsPage: React.FC = () => { valueColor: 'text-white', }, { - title: 'Average TPM (24h)', + title: 'TPM (estimated)', value: tpmLast24h.toFixed(2).toLocaleString(), - subValue: 'Transactions Per Minute', + subValue: 'Transactions per minute from recent blocks', icon: 'fa-solid fa-chart-line text-primary', valueColor: 'text-white', subValueColor: 'text-gray-400', }, ] - const handlePageChange = (page: number) => { - setCurrentPage(page) + const handleQuery = () => { + if (viewMode === 'confirmed') { + const h = Number(heightInput) + if (h > 0) { + setQueryHeight(h) + setCurrentPage(1) + } + } } - const handleResetFilters = () => { + const handleViewModeChange = (mode: ViewMode) => { + setViewMode(mode) setTransactionType('All Types') - setFromBlock('') - setToBlock('') - setStatusFilter('all') - setAmountRangeValue(0) - setAddressSearch('') - setAppliedFilters({}) setCurrentPage(1) + setPendingPage(1) } - const handleApplyFilters = () => { - const nextFilters: ApiFilters = { - type: transactionType !== 'All Types' ? transactionType : undefined, - fromBlock: fromBlock || undefined, - toBlock: toBlock || undefined, - status: statusFilter !== 'all' ? statusFilter : undefined, - address: addressSearch || undefined, - minAmount: amountRangeValue > 0 ? amountRangeValue : undefined, - maxAmount: amountRangeValue >= 1000 ? undefined : amountRangeValue - } - setAppliedFilters(nextFilters) - setCurrentPage(1) - } - - // Function to change entries per page - const handleEntriesPerPageChange = (value: number) => { - setEntriesPerPage(value) - setCurrentPage(1) // Reset to first page when entries per page changes - } - - const handleAmountRangeInputChange = (event: React.ChangeEvent) => { - const rawValue = event.target.value.replace(/,/g, '') - const parsedValue = Number(rawValue) - if (Number.isNaN(parsedValue)) return - const clampedValue = Math.min(Math.max(parsedValue, 0), 1000) - setAmountRangeValue(clampedValue) - } - - // Function to handle export const handleExportTransactions = () => { - // create CSV with the filtered transactions const csvContent = [ ['Hash', 'Type', 'From', 'To', 'Amount', 'Fee', 'Status', 'Age', 'Block Height'].join(','), - ...transactions.map(tx => [ - tx.hash, - tx.type, - tx.from, - tx.to, - tx.amount, - tx.fee, - tx.status, - tx.age, - tx.blockHeight - ].join(',')) + ...filteredTransactions.map((tx) => + [tx.hash, tx.type, tx.from, tx.to, tx.amount, tx.fee, tx.status, tx.age, tx.blockHeight ?? ''].join(',') + ), ].join('\n') const blob = new Blob([csvContent], { type: 'text/csv' }) const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `transactions_${new Date().toISOString().split('T')[0]}.csv` + const suffix = viewMode === 'pending' ? 'pending' : `block_${queryHeight}` + a.download = `transactions_${suffix}_${new Date().toISOString().split('T')[0]}.csv` document.body.appendChild(a) a.click() document.body.removeChild(a) window.URL.revokeObjectURL(url) } - const filterConfigs: FilterProps[] = [ - { - type: 'select', - label: 'Transaction Type', - options: ['All Types', 'send', 'stake', 'edit-stake', 'unstake', 'pause', 'unpause', 'changeParameter', 'daoTransfer', 'certificateResults', 'subsidy', 'createOrder', 'editOrder', 'deleteOrder'], - value: transactionType, - onChange: setTransactionType, - }, - { - type: 'blockRange', - label: 'Block Range', - fromBlock: fromBlock, - toBlock: toBlock, - onFromBlockChange: setFromBlock, - onToBlockChange: setToBlock, - }, - { - type: 'statusButtons', - label: 'Status', - options: [ - { label: 'Success', status: 'success' }, - { label: 'Failed', status: 'failed' }, - { label: 'Pending', status: 'pending' }, - ], - selectedStatus: statusFilter, - onStatusChange: setStatusFilter, - }, - { - type: 'amountRangeSlider', - label: 'Amount Range', - value: amountRangeValue, - onChange: setAmountRangeValue, - min: 0, - max: 1000, // Adjusted for a more manageable range and then 1000+ will be handled visually - step: 1, - displayLabels: [ - { value: 0, label: '0 CNPY' }, - { value: 500, label: '500 CNPY' }, - { value: 1000, label: '1000+ CNPY' }, - ], - }, - { - type: 'search', - label: 'Address Search', - placeholder: 'Search by address or hash...', - value: addressSearch, - onChange: setAddressSearch, - }, - ] + const isLoadingData = viewMode === 'pending' ? isPendingLoading : isBlockLoading + const totalCount = viewMode === 'pending' + ? filteredTransactions.length + : filteredTransactions.length return ( - {/* Header with transaction information */} + {/* Header */}
-

- {transactionsTexts.page.title} -

-

- {transactionsTexts.page.description} -

+

{transactionsTexts.page.title}

+

{transactionsTexts.page.description}

{/* Overview Cards */}
{overviewCards.map((card, index) => ( -
+
{card.title} @@ -460,7 +329,9 @@ const TransactionsPage: React.FC = () => {

{card.value}

- {card.subValue && {card.subValue}} + {card.subValue && ( + {card.subValue} + )} {card.progressBar !== undefined && (
{ ))}
- {/* Transaction filters */} -
-
- {/* Transaction Type Filter */} -
- - -
- - {/* Block Range Filter */} -
- -
- (filterConfigs[1] as BlockRangeFilter).onFromBlockChange(e.target.value)} - /> - (filterConfigs[1] as BlockRangeFilter).onToBlockChange(e.target.value)} - /> -
-
+ {/* View Mode Tabs + Filters */} +
+ {/* Tabs */} +
+ + +
- {/* Status Filter */} -
- -
-
- {(filterConfigs[2] as StatusFilter).options.map((option, idx) => ( - - ))} + {/* Filter Controls */} +
+ {viewMode === 'confirmed' ? ( + <> + {/* Labels row */} +
+ + +
- -
-
- - {/* Amount Range Filter */} -
- -
-
-
- (filterConfigs[3] as AmountRangeFilter).onChange(Number(e.target.value))} - className="w-full h-2 bg-input rounded-lg appearance-none cursor-pointer accent-primary" - style={{ background: `linear-gradient(to right, #4ADE80 0%, #4ADE80 ${(((filterConfigs[3] as AmountRangeFilter).value - (filterConfigs[3] as AmountRangeFilter).min) / ((filterConfigs[3] as AmountRangeFilter).max - (filterConfigs[3] as AmountRangeFilter).min)) * 100}%, #4B5563 ${(((filterConfigs[3] as AmountRangeFilter).value - (filterConfigs[3] as AmountRangeFilter).min) / ((filterConfigs[3] as AmountRangeFilter).max - (filterConfigs[3] as AmountRangeFilter).min)) * 100}%, #4B5563 100%)` }} - /> -
- {/* Current value tag - fixed on the right */} -
- {(filterConfigs[3] as AmountRangeFilter).value >= 1000 ? "1000+" : (filterConfigs[3] as AmountRangeFilter).value} CNPY -
+ {/* Inputs row */} +
setHeightInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleQuery() + }} + min={1} + max={latestHeight || undefined} /> + + +
+ {latestHeight > 0 && ( + + Latest block: {latestHeight.toLocaleString()} + + )} + {queryHeight > 0 && ( +
+ Showing transactions for block{' '} + #{queryHeight.toLocaleString()} + {transactionType !== 'All Types' && ( + <> + {' '}filtered by type{' '} + {transactionType} + + )} + {' '}—{' '} + {filteredTransactions.length}{' '} + transaction{filteredTransactions.length !== 1 ? 's' : ''} found +
+ )} + + ) : ( + <> + {/* Pending mode: only transaction type filter */} +
+ +
-
- {(filterConfigs[3] as AmountRangeFilter).displayLabels.map((label, idx) => ( - - {label.label} +
+ +
+ + + {filteredTransactions.length} pending transaction{filteredTransactions.length !== 1 ? 's' : ''} - ))} +
-
-
- - {/* Address Search Filter */} -
- -
- (filterConfigs[4] as SearchFilter).onChange(e.target.value)} - /> - -
-
- - -
-
+ + )}
{ + if (viewMode === 'pending') { + setPendingPage(page) + } else { + setCurrentPage(page) + } + }} + showEntriesSelector={viewMode === 'confirmed'} currentEntriesPerPage={entriesPerPage} - onEntriesPerPageChange={handleEntriesPerPageChange} + onEntriesPerPageChange={(value) => { + setEntriesPerPage(value) + setCurrentPage(1) + }} showExportButton={true} onExportButtonClick={handleExportTransactions} /> diff --git a/cmd/rpc/web/explorer/src/components/validator/ValidatorDetailHeader.tsx b/cmd/rpc/web/explorer/src/components/validator/ValidatorDetailHeader.tsx index 2585e135c..44a427301 100644 --- a/cmd/rpc/web/explorer/src/components/validator/ValidatorDetailHeader.tsx +++ b/cmd/rpc/web/explorer/src/components/validator/ValidatorDetailHeader.tsx @@ -4,7 +4,7 @@ import toast from 'react-hot-toast' interface ValidatorDetail { address: string - status: 'active' | 'paused' | 'unstaking' | 'inactive' + status: 'active' | 'paused' | 'unstaking' | 'delegate' stakedAmount: number committees: number[] delegate: boolean @@ -65,8 +65,8 @@ const ValidatorDetailHeader: React.FC = ({ validator return 'bg-yellow-500' case 'unstaking': return 'bg-orange-500' - case 'inactive': - return 'bg-gray-500' + case 'delegate': + return 'bg-blue-500' default: return 'bg-gray-500' } @@ -80,8 +80,8 @@ const ValidatorDetailHeader: React.FC = ({ validator return 'Paused' case 'unstaking': return 'Unstaking' - case 'inactive': - return validatorDetailTexts.header.status.inactive + case 'delegate': + return 'Delegator' default: return 'Unknown' } @@ -179,7 +179,7 @@ const ValidatorDetailHeader: React.FC = ({ validator {/* Committees */}
-
Committees:
+
Committee IDs:
{validator.committees.length > 0 ? validator.committees.join(', ') : 'None'}
@@ -207,26 +207,16 @@ const ValidatorDetailHeader: React.FC = ({ validator
- {/* Status and actions */} + {/* Type badge */}
- - {/* Action buttons */} - {/*
- - -
*/} + +
diff --git a/cmd/rpc/web/explorer/src/components/validator/ValidatorDetailPage.tsx b/cmd/rpc/web/explorer/src/components/validator/ValidatorDetailPage.tsx index 649839f86..291bf8f96 100644 --- a/cmd/rpc/web/explorer/src/components/validator/ValidatorDetailPage.tsx +++ b/cmd/rpc/web/explorer/src/components/validator/ValidatorDetailPage.tsx @@ -3,24 +3,23 @@ import { useParams, useNavigate, useLocation } from 'react-router-dom' import { motion } from 'framer-motion' import ValidatorDetailHeader from './ValidatorDetailHeader' import ValidatorStakeChains from './ValidatorStakeChains' -import ValidatorRewards from './ValidatorRewards' -import { useValidator, useAllValidators } from '../../hooks/useApi' +import { useValidator } from '../../hooks/useApi' +import { Committee } from '../../lib/api' import validatorDetailTexts from '../../data/validatorDetail.json' import ValidatorMetrics from './ValidatorMetrics' interface ValidatorDetail { address: string publicKey: string - stakedAmount: number // in micro denomination - committees: number[] // list of chain ids + stakedAmount: number + committees: number[] netAddress: string - maxPausedHeight: number // 0 if not paused - unstakingHeight: number // 0 if not unstaking - output: string // address where rewards are distributed + maxPausedHeight: number + unstakingHeight: number + output: string delegate: boolean compound: boolean - // Calculated from real data - status: 'active' | 'paused' | 'unstaking' | 'inactive' + status: 'active' | 'paused' | 'unstaking' | 'delegate' rank: number // From query param when navigating from table nestedChains: Array<{ name: string @@ -47,61 +46,70 @@ const ValidatorDetailPage: React.FC = () => { // Hook to get specific validator data const { data: validatorData, isLoading } = useValidator(0, validatorAddress || '') - // Hook to get all validators to calculate total stake - const { data: allValidatorsData } = useAllValidators() + // Per-committee total stake data + const [committeeTotals, setCommitteeTotals] = useState>(new Map()) - // Helper function to convert micro denomination to CNPY - const toCNPY = (micro: number): number => { - return micro / 1000000 - } - - // Calculate total stake from all validators - const totalNetworkStake = React.useMemo(() => { - const allValidators = allValidatorsData?.results || [] - return allValidators.reduce((sum: number, v: any) => sum + Number(v.stakedAmount || 0), 0) - }, [allValidatorsData]) - - // Generate nested chains from real committees data - // Restakes aren't split amongst chains, but instead the full amount is applied against each - const generateNestedChains = (committees: number[], validatorStake: number, totalStake: number) => { + // Fetch per-committee total stake when validator committees are known + useEffect(() => { + const committees = validatorData?.committees || [] + if (committees.length === 0) return + + const fetchCommitteeData = async () => { + const totals = new Map() + for (const committeeId of committees) { + try { + const data = await Committee(1, committeeId) + const validators = data?.results || [] + const total = validators.reduce((sum: number, v: Record) => sum + Number(v.stakedAmount || 0), 0) + totals.set(committeeId, total) + } catch { + totals.set(committeeId, 0) + } + } + setCommitteeTotals(totals) + } + fetchCommitteeData() + }, [validatorData?.committees]) + + const icons = [ + 'fa-solid fa-leaf', + 'fa-brands fa-ethereum', + 'fa-brands fa-bitcoin', + 'fa-solid fa-circle-nodes', + 'fa-solid fa-link', + 'fa-solid fa-network-wired' + ] + const chainColors = [ + 'bg-green-300/10 text-primary text-lg', + 'bg-blue-300/10 text-blue-500 text-lg', + 'bg-yellow-600/10 text-yellow-400 text-lg', + 'bg-purple-300/10 text-purple-500 text-lg', + 'bg-red-300/10 text-red-500 text-lg', + 'bg-cyan-300/10 text-cyan-500 text-lg' + ] + + const generateNestedChains = (committees: number[], validatorStake: number) => { if (!committees || committees.length === 0) { return [] } - // Staking power = Your stake / total stake - const stakingPower = totalStake > 0 ? (validatorStake / totalStake) * 100 : 0 - return committees.map((committeeId, index) => { - const icons = [ - 'fa-solid fa-leaf', - 'fa-brands fa-ethereum', - 'fa-brands fa-bitcoin', - 'fa-solid fa-circle-nodes', - 'fa-solid fa-link', - 'fa-solid fa-network-wired' - ] - const colors = [ - 'bg-green-300/10 text-primary text-lg', - 'bg-blue-300/10 text-blue-500 text-lg', - 'bg-yellow-600/10 text-yellow-400 text-lg', - 'bg-purple-300/10 text-purple-500 text-lg', - 'bg-red-300/10 text-red-500 text-lg', - 'bg-cyan-300/10 text-cyan-500 text-lg' - ] + const committeeTotal = committeeTotals.get(committeeId) || 0 + const stakingPower = committeeTotal > 0 ? (validatorStake / committeeTotal) * 100 : 0 return { name: `Committee ${committeeId}`, committeeId: committeeId, - stakedAmount: validatorStake, // Full amount applied to each committee - percentage: stakingPower, // Staking power percentage + stakedAmount: validatorStake, + percentage: stakingPower, icon: icons[index % icons.length], - color: colors[index % colors.length] + color: chainColors[index % chainColors.length] } }) } // Calculate validator status from real data - const calculateStatus = (maxPausedHeight: number, unstakingHeight: number, delegate: boolean): 'active' | 'paused' | 'unstaking' | 'inactive' => { + const calculateStatus = (maxPausedHeight: number, unstakingHeight: number, delegate: boolean): 'active' | 'paused' | 'unstaking' | 'delegate' => { if (unstakingHeight > 0) { return 'unstaking' } @@ -109,7 +117,7 @@ const ValidatorDetailPage: React.FC = () => { return 'paused' } if (delegate) { - return 'inactive' // Delegates are not active validators + return 'delegate' } return 'active' } @@ -145,13 +153,13 @@ const ValidatorDetailPage: React.FC = () => { compound, status, rank: rank || 0, // Use rank from query param, 0 if not provided - nestedChains: generateNestedChains(committees, stakedAmount, totalNetworkStake) + nestedChains: generateNestedChains(committees, stakedAmount) } setValidator(validatorDetail) setLoading(false) } - }, [validatorData, validatorAddress, rank, totalNetworkStake]) + }, [validatorData, validatorAddress, rank, committeeTotals]) if (loading || isLoading) { return ( diff --git a/cmd/rpc/web/explorer/src/components/validator/ValidatorMetrics.tsx b/cmd/rpc/web/explorer/src/components/validator/ValidatorMetrics.tsx index 666e1e7cd..0b97854f8 100644 --- a/cmd/rpc/web/explorer/src/components/validator/ValidatorMetrics.tsx +++ b/cmd/rpc/web/explorer/src/components/validator/ValidatorMetrics.tsx @@ -38,11 +38,11 @@ const ValidatorMetrics: React.FC = ({ validator }) => { subtitle: null }, { - title: 'Committees', + title: 'Committees Staked', value: validator.committees.length, suffix: '', icon: 'fa-solid fa-network-wired', - subtitle: validator.committees.length > 0 ? `${validator.committees.join(', ')}` : 'None' + subtitle: validator.committees.length > 0 ? `IDs: ${validator.committees.join(', ')}` : 'None' }, { title: 'Max Paused Height', diff --git a/cmd/rpc/web/explorer/src/components/validator/ValidatorStakeChains.tsx b/cmd/rpc/web/explorer/src/components/validator/ValidatorStakeChains.tsx index 0ca83975c..c0d04b6b3 100644 --- a/cmd/rpc/web/explorer/src/components/validator/ValidatorStakeChains.tsx +++ b/cmd/rpc/web/explorer/src/components/validator/ValidatorStakeChains.tsx @@ -29,8 +29,28 @@ const ValidatorStakeChains: React.FC = ({ validator } return num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 }) } + const subscriptDigits = ['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉'] + const formatPercentage = (num: number) => { - return `${num.toFixed(2)}%` + if (num === 0) return '0.00%' + if (num >= 0.01) return `${num.toFixed(2)}%` + + // For very small numbers, use subscript notation: 0.0₅34% + const str = num.toFixed(10) + const [, decimal] = str.split('.') + if (!decimal) return `${num.toFixed(2)}%` + + let leadingZeros = 0 + for (const c of decimal) { + if (c === '0') leadingZeros++ + else break + } + + if (leadingZeros < 2) return `${num.toFixed(4)}%` + + const significantDigits = decimal.slice(leadingZeros, leadingZeros + 3) + const subscript = subscriptDigits[leadingZeros] + return `0.0${subscript}${significantDigits}%` } const getProgressBarColor = (color: string) => { diff --git a/cmd/rpc/web/explorer/src/components/validator/ValidatorsFilters.tsx b/cmd/rpc/web/explorer/src/components/validator/ValidatorsFilters.tsx index ba028682b..92a72883e 100644 --- a/cmd/rpc/web/explorer/src/components/validator/ValidatorsFilters.tsx +++ b/cmd/rpc/web/explorer/src/components/validator/ValidatorsFilters.tsx @@ -70,8 +70,6 @@ const ValidatorsFilters: React.FC = ({ return validator.activityScore === 'Unstaking' case 'delegate': return validator.activityScore === 'Delegate' - case 'inactive': - return validator.activityScore === 'Inactive' default: return true } @@ -125,8 +123,6 @@ const ValidatorsFilters: React.FC = ({ return validator.activityScore === 'Unstaking' case 'delegate': return validator.activityScore === 'Delegate' - case 'inactive': - return validator.activityScore === 'Inactive' default: return true } @@ -261,7 +257,6 @@ const ValidatorsFilters: React.FC = ({ -
@@ -276,7 +271,6 @@ const ValidatorsFilters: React.FC = ({ -
{/* Middle - Min Stake Slider */} diff --git a/cmd/rpc/web/explorer/src/components/validator/ValidatorsPage.tsx b/cmd/rpc/web/explorer/src/components/validator/ValidatorsPage.tsx index 9661e064c..d448958ea 100644 --- a/cmd/rpc/web/explorer/src/components/validator/ValidatorsPage.tsx +++ b/cmd/rpc/web/explorer/src/components/validator/ValidatorsPage.tsx @@ -71,18 +71,9 @@ const ValidatorsPage: React.FC = () => { const normalizedValidators = React.useMemo(() => { if (!validatorsData) return [] - // Real structure: { results: [...], totalCount: number } let validatorsList = validatorsData.results || [] if (!Array.isArray(validatorsList)) return [] - // Filter out delegators when on validators page (only show non-delegators) - if (!isDelegatorsPage) { - validatorsList = validatorsList.filter((validator: any) => { - // Exclude delegators (those with delegate: true) - return !validator.delegate || validator.delegate === false - }) - } - // Calculate total stake for percentages const totalStake = validatorsList.reduce((sum: number, validator: any) => sum + (validator.stakedAmount || 0), 0) diff --git a/cmd/rpc/web/explorer/src/data/validators.json b/cmd/rpc/web/explorer/src/data/validators.json index adfc45806..ab86abd20 100644 --- a/cmd/rpc/web/explorer/src/data/validators.json +++ b/cmd/rpc/web/explorer/src/data/validators.json @@ -18,7 +18,7 @@ "Rank", "Validator Name/Address", "Reward % (24h)", - "Reward Change", + "Status", "Chains Restaked", "Stake Weight", "Total Stake (CNPY)", diff --git a/cmd/rpc/web/explorer/src/hooks/useApi.ts b/cmd/rpc/web/explorer/src/hooks/useApi.ts index ada87cdba..5b0bff409 100644 --- a/cmd/rpc/web/explorer/src/hooks/useApi.ts +++ b/cmd/rpc/web/explorer/src/hooks/useApi.ts @@ -65,18 +65,31 @@ export const queryKeys = { tableData: (page: number, category: number, committee?: number) => ['tableData', page, category, committee], }; +const BLOCKS_POLL_MS = 3000; + +// Lightweight hook for the latest block height (polls every 3s) +export const useLatestBlock = () => { + return useQuery({ + queryKey: ['latestBlock'], + queryFn: () => Blocks(1, 0), + staleTime: 0, + refetchInterval: BLOCKS_POLL_MS, + refetchOnWindowFocus: true, + refetchOnMount: 'always', + }); +}; + // Hooks for Blocks export const useBlocks = (page: number, perPage: number = 10, filter: string = 'all') => { - // Load more blocks if the filter is week or 24h to have enough data to filter const blockCount = filter === 'week' ? 50 : filter === '24h' ? 30 : perPage; return useQuery({ queryKey: queryKeys.blocks(page, blockCount, filter), queryFn: () => Blocks(page, blockCount), - staleTime: 300000, // Cache for 5 minutes (increased from 30 seconds) - refetchInterval: REFRESH_INTERVAL_MS, - refetchOnWindowFocus: false, // Don't refetch when window regains focus - gcTime: 600000 // Keep in cache for 10 minutes + staleTime: 0, + refetchInterval: BLOCKS_POLL_MS, + refetchOnWindowFocus: true, + refetchOnMount: 'always', }); }; @@ -97,6 +110,8 @@ export const useAllTransactions = (page: number, perPage: number = 10, filters?: type?: string; fromDate?: string; toDate?: string; + fromBlock?: string; + toBlock?: string; status?: string; address?: string; minAmount?: number; @@ -115,6 +130,8 @@ export const useTransactionsWithRealPagination = (page: number, perPage: number type?: string; fromDate?: string; toDate?: string; + fromBlock?: string; + toBlock?: string; status?: string; address?: string; minAmount?: number; @@ -390,10 +407,12 @@ export const useModalData = (query: string | number, page: number) => { // Hooks for Card Data export const useCardData = () => { return useQuery({ - queryKey: [...queryKeys.cardData(), rpcURL], // Include RPC URL to invalidate on network change + queryKey: [...queryKeys.cardData(), rpcURL], queryFn: () => getCardData(), - staleTime: 5000, // Reduced stale time for more frequent updates - refetchOnWindowFocus: true, // Refetch when window regains focus + staleTime: 0, + refetchInterval: BLOCKS_POLL_MS, + refetchOnWindowFocus: true, + refetchOnMount: 'always', }); }; @@ -475,10 +494,10 @@ export const useAllBlocksCache = () => { throw new Error(`Error fetching blocks: ${error.message}`); } }, - staleTime: 300000, // Cache for 5 minutes - // Keep this in sync with live widgets (Navbar + Home Blocks table) - refetchInterval: REFRESH_INTERVAL_MS, - gcTime: 600000, // Keep in cache for 10 minutes + staleTime: 5000, + refetchInterval: 10000, + refetchOnWindowFocus: true, + refetchOnMount: 'always', }); }; @@ -573,8 +592,8 @@ export const useTransactionsInRange = (fromBlock: number, toBlock: number, maxBl }); } - // Limit to a maximum of 50 blocks to avoid too many requests - const limitedBlocks = Math.min(maxBlocksToFetch, 50); + // limit blocks to the requested max (capped at 100 to stay reasonable) + const limitedBlocks = Math.min(maxBlocksToFetch, 100); const finalBlocks = filteredBlocks.slice(0, limitedBlocks); const allTransactions: any[] = []; diff --git a/cmd/rpc/web/explorer/src/lib/api.ts b/cmd/rpc/web/explorer/src/lib/api.ts index f816e3f50..050f5e3cd 100644 --- a/cmd/rpc/web/explorer/src/lib/api.ts +++ b/cmd/rpc/web/explorer/src/lib/api.ts @@ -190,6 +190,8 @@ export async function getTransactionsWithRealPagination(page: number, perPage: n type?: string; fromDate?: string; toDate?: string; + fromBlock?: string; + toBlock?: string; status?: string; address?: string; minAmount?: number; @@ -407,9 +409,9 @@ export async function getTotalTransactionCount(cachedBlocks?: any[]): Promise<{ } return { - total: 795963, - last24h: 79596, - tpm: 55.27 + total: 0, + last24h: 0, + tpm: 0 }; } catch (error) { console.error('Error getting total transaction count:', error); @@ -421,8 +423,28 @@ export async function getTotalTransactionCount(cachedBlocks?: any[]): Promise<{ } } -// new function to get transactions from multiple blocks -export async function AllTransactions(page: number, perPage: number = 10, filters?: { +// Extract transactions from a list of blocks, attaching block metadata to each tx +function extractTransactionsFromBlocks(blocks: any[]): any[] { + const txs: any[] = []; + for (const block of blocks) { + if (block.transactions && Array.isArray(block.transactions)) { + const blockHeight = block.blockHeader?.height || block.height || 0; + for (const tx of block.transactions) { + txs.push({ + ...tx, + blockHeight, + blockHash: block.blockHeader?.hash || block.hash, + blockTime: block.blockHeader?.time || block.time, + blockNumber: blockHeight, + }); + } + } + } + return txs; +} + +// Apply non-block-range filters to a list of transactions +function applyTxFilters(txs: any[], filters: { type?: string; fromDate?: string; toDate?: string; @@ -430,135 +452,177 @@ export async function AllTransactions(page: number, perPage: number = 10, filter address?: string; minAmount?: number; maxAmount?: number; -}) { - try { - // Get total transaction count - const totalTransactionCount = await getTotalTransactionCount(); - - // Calculate how many blocks we need to fetch to cover the pagination - // We assume an average transactions per block to optimize - const estimatedTxsPerBlock = 1; // Adjust according to your blockchain reality - const blocksNeeded = Math.ceil((page * perPage) / estimatedTxsPerBlock) + 5; // Extra buffer - - // Fetch multiple pages of blocks to ensure enough transactions - let allTransactions: any[] = []; - let currentBlockPage = 1; - const maxBlockPages = Math.min(blocksNeeded, 20); // Limit for performance - - while (currentBlockPage <= maxBlockPages && allTransactions.length < (page * perPage)) { - const blocksResponse = await Blocks(currentBlockPage, 0); - const blocks = blocksResponse?.results || blocksResponse?.blocks || blocksResponse?.list || []; - - if (!Array.isArray(blocks) || blocks.length === 0) break; - - for (const block of blocks) { - if (block.transactions && Array.isArray(block.transactions)) { - // add block information to each transaction - const blockTransactions = block.transactions.map((tx: any) => ({ - ...tx, - blockHeight: block.blockHeader?.height || block.height, - blockHash: block.blockHeader?.hash || block.hash, - blockTime: block.blockHeader?.time || block.time, - blockNumber: block.blockHeader?.height || block.height - })); - allTransactions = allTransactions.concat(blockTransactions); +}): any[] { + return txs.filter(tx => { + if (filters.type && filters.type !== 'All Types') { + const txType = tx.messageType || tx.type || 'send'; + if (txType.toLowerCase() !== filters.type.toLowerCase()) return false; + } + if (filters.address) { + const addr = filters.address.toLowerCase(); + const sender = (tx.sender || tx.from || '').toLowerCase(); + const recipient = (tx.recipient || tx.to || '').toLowerCase(); + const hash = (tx.txHash || tx.hash || '').toLowerCase(); + if (!sender.includes(addr) && !recipient.includes(addr) && !hash.includes(addr)) return false; + } + if (filters.fromDate || filters.toDate) { + const txTime = tx.blockTime || tx.time || tx.timestamp; + if (txTime) { + const txDate = new Date(txTime > 1e12 ? txTime / 1000 : txTime); + if (filters.fromDate && txDate < new Date(filters.fromDate)) return false; + if (filters.toDate) { + const toDate = new Date(filters.toDate); + toDate.setHours(23, 59, 59, 999); + if (txDate > toDate) return false; } } - - currentBlockPage++; } + if (filters.minAmount !== undefined || filters.maxAmount !== undefined) { + const amount = tx.amount || tx.value || 0; + if (filters.minAmount !== undefined && amount < filters.minAmount) return false; + if (filters.maxAmount !== undefined && amount > filters.maxAmount) return false; + } + if (filters.status && filters.status !== 'all') { + if ((tx.status || 'success') !== filters.status) return false; + } + return true; + }); +} - // apply filters if provided - if (filters) { - allTransactions = allTransactions.filter(tx => { - // Filter by type - if (filters.type && filters.type !== 'All Types') { - const txType = tx.messageType || tx.type || 'send'; - if (txType.toLowerCase() !== filters.type.toLowerCase()) { - return false; - } - } - - // filter by address (sender or recipient) - if (filters.address) { - const address = filters.address.toLowerCase(); - const sender = (tx.sender || tx.from || '').toLowerCase(); - const recipient = (tx.recipient || tx.to || '').toLowerCase(); - const hash = (tx.txHash || tx.hash || '').toLowerCase(); - - if (!sender.includes(address) && !recipient.includes(address) && !hash.includes(address)) { - return false; - } - } - - // filter by date range - if (filters.fromDate || filters.toDate) { - const txTime = tx.blockTime || tx.time || tx.timestamp; - if (txTime) { - const txDate = new Date(txTime > 1e12 ? txTime / 1000 : txTime); - - if (filters.fromDate) { - const fromDate = new Date(filters.fromDate); - if (txDate < fromDate) return false; - } - - if (filters.toDate) { - const toDate = new Date(filters.toDate); - toDate.setHours(23, 59, 59, 999); // Include the whole day - if (txDate > toDate) return false; - } - } - } +// Fetch transactions from blocks within a specific height range by +// querying each block height directly via the BlockByHeight RPC endpoint. +async function fetchTransactionsForBlockRange( + fromHeight: number, + toHeight: number, + filters: { type?: string; fromDate?: string; toDate?: string; status?: string; address?: string; minAmount?: number; maxAmount?: number }, +): Promise<{ transactions: any[]; totalFiltered: number }> { + const metaResponse = await Blocks(1, 0); + const latestHeight: number = metaResponse?.totalCount || 0; + + const effectiveFrom = Math.max(fromHeight || 1, 1); + const effectiveTo = latestHeight > 0 + ? Math.min(toHeight || latestHeight, latestHeight) + : (toHeight || effectiveFrom); + + if (effectiveFrom > effectiveTo) return { transactions: [], totalFiltered: 0 }; + + // Cap the number of heights to query (newest first within the range) + const maxHeights = 500; + const rangeSize = effectiveTo - effectiveFrom + 1; + const cappedFrom = rangeSize > maxHeights ? effectiveTo - maxHeights + 1 : effectiveFrom; + + let allTransactions: any[] = []; + const batchSize = 10; + + // Fetch blocks by height in parallel batches, newest first + for (let batchTop = effectiveTo; batchTop >= cappedFrom; batchTop -= batchSize) { + const batchBottom = Math.max(batchTop - batchSize + 1, cappedFrom); + const promises: Promise[] = []; + + for (let h = batchTop; h >= batchBottom; h--) { + promises.push( + BlockByHeight(h).catch(() => null) + ); + } - // filter by amount range - if (filters.minAmount !== undefined || filters.maxAmount !== undefined) { - const amount = tx.amount || tx.value || 0; + const results = await Promise.all(promises); - if (filters.minAmount !== undefined && amount < filters.minAmount) { - return false; - } + for (const block of results) { + if (!block) continue; + const txs = extractTransactionsFromBlocks([block]); + allTransactions = allTransactions.concat(txs); + } + } - if (filters.maxAmount !== undefined && amount > filters.maxAmount) { - return false; - } - } + const filtered = applyTxFilters(allTransactions, filters); + return { transactions: filtered, totalFiltered: filtered.length }; +} + +// Fetch transactions from recent blocks (no block-range constraint) +async function fetchRecentTransactions( + targetCount: number, + filters: { type?: string; fromDate?: string; toDate?: string; status?: string; address?: string; minAmount?: number; maxAmount?: number }, + hasFilters: boolean, +): Promise<{ transactions: any[]; totalFiltered: number; totalRaw: number }> { + let allTransactions: any[] = []; + let currentBlockPage = 1; + const maxBlockPages = 50; + const collectAll = hasFilters; + + while (currentBlockPage <= maxBlockPages && (collectAll || allTransactions.length < targetCount)) { + const blocksResponse = await Blocks(currentBlockPage, 0); + const blocks = blocksResponse?.results || blocksResponse?.blocks || blocksResponse?.list || []; + if (!Array.isArray(blocks) || blocks.length === 0) break; + allTransactions = allTransactions.concat(extractTransactionsFromBlocks(blocks)); + currentBlockPage++; + } - // filter by status - if (filters.status && filters.status !== 'all') { - const txStatus = tx.status || 'success'; - if (txStatus !== filters.status) { - return false; - } - } + const totalRaw = allTransactions.length; + const filtered = hasFilters ? applyTxFilters(allTransactions, filters) : allTransactions; + return { transactions: filtered, totalFiltered: filtered.length, totalRaw }; +} - return true; - }); +// Main entry point: get transactions from multiple blocks with filters and pagination +export async function AllTransactions(page: number, perPage: number = 10, filters?: { + type?: string; + fromDate?: string; + toDate?: string; + fromBlock?: string; + toBlock?: string; + status?: string; + address?: string; + minAmount?: number; + maxAmount?: number; +}) { + try { + const fromHeight = filters?.fromBlock ? Number(filters.fromBlock) : 0; + const toHeight = filters?.toBlock ? Number(filters.toBlock) : 0; + const hasBlockRange = (fromHeight > 0 || toHeight > 0); + + const nonBlockFilters = { + type: filters?.type, + fromDate: filters?.fromDate, + toDate: filters?.toDate, + status: filters?.status, + address: filters?.address, + minAmount: filters?.minAmount, + maxAmount: filters?.maxAmount, + }; + const hasNonBlockFilters = Object.values(nonBlockFilters).some(v => v !== undefined && v !== ''); + + let allFiltered: any[]; + let totalCount: number; + + if (hasBlockRange) { + const result = await fetchTransactionsForBlockRange(fromHeight, toHeight, nonBlockFilters); + allFiltered = result.transactions; + totalCount = result.totalFiltered; + } else { + const totalTransactionCount = await getTotalTransactionCount(); + const result = await fetchRecentTransactions(page * perPage, nonBlockFilters, hasNonBlockFilters); + allFiltered = result.transactions; + totalCount = hasNonBlockFilters ? result.totalFiltered : totalTransactionCount.total; } - // Sort by time (most recent first) - allTransactions.sort((a, b) => { - const timeA = a.blockTime || a.time || a.timestamp || 0; - const timeB = b.blockTime || b.time || b.timestamp || 0; - return timeB - timeA; + // Sort by block height descending (most recent first) + allFiltered.sort((a, b) => { + const hA = a.blockHeight || a.height || 0; + const hB = b.blockHeight || b.height || 0; + return hB - hA; }); - // Apply pagination const startIndex = (page - 1) * perPage; const endIndex = startIndex + perPage; - const paginatedTransactions = allTransactions.slice(startIndex, endIndex); - - // Use real total count when there are no filters, otherwise use filtered count - const finalTotalCount = filters ? allTransactions.length : totalTransactionCount.total; + const paginatedTransactions = allFiltered.slice(startIndex, endIndex); return { results: paginatedTransactions, - totalCount: finalTotalCount, + totalCount: totalCount, pageNumber: page, perPage: perPage, - totalPages: Math.ceil(finalTotalCount / perPage), - hasMore: endIndex < finalTotalCount + totalPages: Math.ceil(totalCount / perPage), + hasMore: endIndex < totalCount, }; - } catch (error) { console.error('Error fetching all transactions:', error); return { results: [], totalCount: 0, pageNumber: page, perPage, totalPages: 0, hasMore: false }; diff --git a/cmd/rpc/web/explorer/src/lib/utils.ts b/cmd/rpc/web/explorer/src/lib/utils.ts index 8d36ae213..3382bfa71 100644 --- a/cmd/rpc/web/explorer/src/lib/utils.ts +++ b/cmd/rpc/web/explorer/src/lib/utils.ts @@ -11,9 +11,11 @@ export function toUCNPY(cnpy: number): number { return cnpy * cnpyConversionRate; } -// convertNumberWCommas() formats a number with commas +// convertNumberWCommas() formats a number with commas (integer part only) export function convertNumberWCommas(x: number): string { - return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + const parts = x.toString().split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return parts.join('.'); } // convertNumber() formats a number with commas or in compact notation diff --git a/cmd/rpc/web/wallet/public/plugin/canopy/chain.json b/cmd/rpc/web/wallet/public/plugin/canopy/chain.json index f9480502e..158acf6a5 100644 --- a/cmd/rpc/web/wallet/public/plugin/canopy/chain.json +++ b/cmd/rpc/web/wallet/public/plugin/canopy/chain.json @@ -9,8 +9,7 @@ }, "rpc": { "base": "http://localhost:50002", - "admin": "http://localhost:50003", - "root": "http://localhost:50002" + "admin": "http://localhost:50003" }, "explorer": { "tx": "http://localhost:50001/transaction" @@ -273,186 +272,6 @@ "startPage": 1 } } - }, - "root": { - "sent": { - "source": { - "base": "root", - "path": "/v1/query/txs-by-sender", - "method": "POST" - }, - "body": { - "pageNumber": "{{page}}", - "perPage": "{{perPage}}", - "address": "{{account.address}}" - }, - "selector": "", - "page": { - "strategy": "page", - "param": { - "page": "pageNumber", - "perPage": "perPage" - }, - "response": { - "items": "results", - "totalPages": "paging.totalPages" - }, - "defaults": { - "perPage": 20, - "startPage": 1 - } - } - }, - "received": { - "source": { - "base": "root", - "path": "/v1/query/txs-by-rec", - "method": "POST" - }, - "body": { - "pageNumber": "{{page}}", - "perPage": "{{perPage}}", - "address": "{{account.address}}" - }, - "selector": "", - "page": { - "strategy": "page", - "param": { - "page": "pageNumber", - "perPage": "perPage" - }, - "response": { - "items": "results" - }, - "defaults": { - "perPage": 20, - "startPage": 1 - } - } - }, - "failed": { - "source": { - "base": "root", - "path": "/v1/query/failed-txs", - "method": "POST" - }, - "body": { - "pageNumber": "{{page}}", - "perPage": "{{perPage}}", - "address": "{{account.address}}" - }, - "selector": "", - "page": { - "strategy": "page", - "param": { - "page": "pageNumber", - "perPage": "perPage" - }, - "response": { - "items": "results" - }, - "defaults": { - "perPage": 20, - "startPage": 1 - } - } - } - } - }, - "orders": { - "bySeller": { - "source": { - "base": "rpc", - "path": "/v1/query/orders", - "method": "POST" - }, - "body": { - "height": 0, - "sellersSendAddress": "{{account.address}}", - "pageNumber": "{{page}}", - "perPage": "{{perPage}}" - }, - "coerce": { - "body": { - "height": "int", - "pageNumber": "int", - "perPage": "int" - } - }, - "selector": "" - }, - "byCommittee": { - "source": { - "base": "root", - "path": "/v1/query/orders", - "method": "POST" - }, - "body": { - "height": 0, - "committee": "{{committee}}", - "pageNumber": "{{page}}", - "perPage": "{{perPage}}" - }, - "coerce": { - "ctx": { - "committee": "int" - }, - "body": { - "height": "int", - "committee": "int", - "pageNumber": "int", - "perPage": "int" - } - }, - "selector": "" - }, - "byBuyer": { - "source": { - "base": "root", - "path": "/v1/query/orders", - "method": "POST" - }, - "body": { - "height": 0, - "buyerSendAddress": "{{account.address}}", - "committee": "{{committee}}", - "pageNumber": "{{page}}", - "perPage": "{{perPage}}" - }, - "coerce": { - "ctx": { - "committee": "int" - }, - "body": { - "height": "int", - "committee": "int", - "pageNumber": "int", - "perPage": "int" - } - }, - "selector": "" - }, - "single": { - "source": { - "base": "root", - "path": "/v1/query/order", - "method": "POST" - }, - "body": { - "height": 0, - "orderId": "{{orderId}}", - "committee": "{{committee}}" - }, - "coerce": { - "ctx": { - "committee": "int" - }, - "body": { - "height": "int", - "committee": "int" - } - }, - "selector": "" } }, "activity": { diff --git a/cmd/rpc/web/wallet/public/plugin/canopy/chain.json.template b/cmd/rpc/web/wallet/public/plugin/canopy/chain.json.template index 6d19652dc..acb4f9f0d 100644 --- a/cmd/rpc/web/wallet/public/plugin/canopy/chain.json.template +++ b/cmd/rpc/web/wallet/public/plugin/canopy/chain.json.template @@ -9,8 +9,7 @@ }, "rpc": { "base": "/rpc", - "admin": "/adminrpc", - "root": "/rootrpc" + "admin": "/adminrpc" }, "explorer": { "tx": "http://localhost:50001/transaction" @@ -117,118 +116,6 @@ "response": { "items": "results" }, "defaults": { "perPage": 20, "startPage": 1 } } - }, - "root": { - "sent": { - "source": { "base": "root", "path": "/v1/query/txs-by-sender", "method": "POST" }, - "body": { "pageNumber": "{{page}}", "perPage": "{{perPage}}", "address": "{{account.address}}" }, - "selector": "", - "page": { - "strategy": "page", - "param": { "page": "pageNumber", "perPage": "perPage" }, - "response": { - "items": "results", - "totalPages": "paging.totalPages" - }, - "defaults": { "perPage": 20, "startPage": 1 } - } - }, - "received": { - "source": { "base": "root", "path": "/v1/query/txs-by-rec", "method": "POST" }, - "body": { "pageNumber": "{{page}}", "perPage": "{{perPage}}", "address": "{{account.address}}" }, - "selector": "", - "page": { - "strategy": "page", - "param": { "page": "pageNumber", "perPage": "perPage" }, - "response": { "items": "results" }, - "defaults": { "perPage": 20, "startPage": 1 } - } - }, - "failed": { - "source": { "base": "root", "path": "/v1/query/failed-txs", "method": "POST" }, - "body": { "pageNumber": "{{page}}", "perPage": "{{perPage}}", "address": "{{account.address}}" }, - "selector": "", - "page": { - "strategy": "page", - "param": { "page": "pageNumber", "perPage": "perPage" }, - "response": { "items": "results" }, - "defaults": { "perPage": 20, "startPage": 1 } - } - } - } - }, - "orders": { - "bySeller": { - "source": { "base": "rpc", "path": "/v1/query/orders", "method": "POST" }, - "body": { - "height": 0, - "sellersSendAddress": "{{account.address}}", - "pageNumber": "{{page}}", - "perPage": "{{perPage}}" - }, - "coerce": { - "body": { - "height": "int", - "pageNumber": "int", - "perPage": "int" - } - }, - "selector": "" - }, - "byCommittee": { - "source": { "base": "root", "path": "/v1/query/orders", "method": "POST" }, - "body": { - "height": 0, - "committee": "{{committee}}", - "pageNumber": "{{page}}", - "perPage": "{{perPage}}" - }, - "coerce": { - "ctx": { "committee": "int" }, - "body": { - "height": "int", - "committee": "int", - "pageNumber": "int", - "perPage": "int" - } - }, - "selector": "" - }, - "byBuyer": { - "source": { "base": "root", "path": "/v1/query/orders", "method": "POST" }, - "body": { - "height": 0, - "buyerSendAddress": "{{account.address}}", - "committee": "{{committee}}", - "pageNumber": "{{page}}", - "perPage": "{{perPage}}" - }, - "coerce": { - "ctx": { "committee": "int" }, - "body": { - "height": "int", - "committee": "int", - "pageNumber": "int", - "perPage": "int" - } - }, - "selector": "" - }, - "single": { - "source": { "base": "root", "path": "/v1/query/order", "method": "POST" }, - "body": { - "height": 0, - "orderId": "{{orderId}}", - "committee": "{{committee}}" - }, - "coerce": { - "ctx": { "committee": "int" }, - "body": { - "height": "int", - "committee": "int" - } - }, - "selector": "" } }, diff --git a/cmd/rpc/web/wallet/public/plugin/canopy/manifest.json b/cmd/rpc/web/wallet/public/plugin/canopy/manifest.json index 47ec08af8..06cc40952 100644 --- a/cmd/rpc/web/wallet/public/plugin/canopy/manifest.json +++ b/cmd/rpc/web/wallet/public/plugin/canopy/manifest.json @@ -90,12 +90,17 @@ } }, "keystore": {}, + "fees": {}, "__options": { "staleTimeMs": 0, "refetchOnMount": "always", "refetchOnWindowFocus": false, - "watch": ["form.address"], - "critical": ["keystore"] + "watch": [ + "form.address" + ], + "critical": [ + "keystore" + ] } }, "ui": { @@ -121,7 +126,9 @@ "value": "{{account.address}}", "autoPopulate": "once", "map": "{{ Object.keys(ds.keystore?.addressMap || {})?.map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname +') ' , value: k }))}}", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "output", @@ -142,23 +149,9 @@ "op": "paste" } ], - "span": { "base": 12 } - }, - { - "type": "divider", - "variant": "gradient", - "spacing": "md", - "span": { "base": 12 } - }, - { - "id": "asset", - "name": "asset", - "type": "text", - "label": "Asset", - "value": "{{chain.displayName}} (Balance: {{formatToCoin<{{ds.account.amount}}>}})", - "autoPopulate": "always", - "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "amount", @@ -166,7 +159,7 @@ "type": "amount", "label": "Amount", "required": true, - "min": 0.000001, + "min": 1e-06, "max": "{{ fromMicroDenom<{{ds.account?.amount ?? 0}}> }}", "validation": { "messages": { @@ -185,7 +178,9 @@ "value": "{{ fromMicroDenom<{{(ds.account?.amount ?? 0) - fees.raw.sendFee}}> }}" } ], - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "info": { @@ -197,7 +192,7 @@ }, { "label": "Estimation time", - "value": "≈ 20", + "value": "≈ {{chain.params.avgBlockTimeSec}} seconds", "icon": "Timer" } ] @@ -208,10 +203,6 @@ { "label": "Receiving Address", "value": "{{form.address}}" - }, - { - "label": "Asset", - "value": "{{chain.denom.symbol}} (Balance: {{formatToCoin<{{ds.account.amount}}>}})" } ] }, @@ -334,7 +325,7 @@ "hideSubmit": true, "slots": { "modal": { - "className": "sm:max-w-[20rem] md:max-w-[24rem]" + "className": "sm:max-w-[22rem] md:max-w-[26rem]" } } }, @@ -365,13 +356,19 @@ ] }, { - "id": "asset", - "name": "asset", + "id": "publicKey", + "name": "publicKey", "type": "text", - "label": "Asset", - "autoPopulate": "always", - "value": "{{chain.denom.symbol}} (Balance: {{formatToCoin<{{ds.account.amount}}>}})", - "readOnly": true + "label": "Public Key", + "value": "{{account.pubKey}}", + "readOnly": true, + "features": [ + { + "id": "copyPubKeyBtn", + "op": "copy", + "from": "{{account.pubKey}}" + } + ] } ], "info": { @@ -411,6 +408,7 @@ } }, "keystore": {}, + "fees": {}, "__options": { "staleTimeMs": 30000, "refetchOnMount": true, @@ -420,7 +418,9 @@ "form.output", "form.signerResponsible" ], - "critical": ["keystore"] + "critical": [ + "keystore" + ] } }, "ui": { @@ -452,7 +452,9 @@ "text": "Addresses", "level": 3, "color": "secondary", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "setup" }, { @@ -492,7 +494,9 @@ { "type": "spacer", "height": "sm", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "setup" }, { @@ -558,7 +562,9 @@ "label": "Stake Amount", "variant": "gradient", "spacing": "lg", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "setup" }, { @@ -609,7 +615,9 @@ { "type": "spacer", "height": "sm", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "setup", "showIf": "{{ ds.validator }}" }, @@ -653,66 +661,36 @@ "description": "Select the committees you want to delegate to. You can select up to 15 committees per validator. Your stake will be distributed across all selected committees.", "icon": "Info", "variant": "info", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "committees" }, { "type": "spacer", "height": "md", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "committees" }, { "id": "selectCommittees", "name": "selectCommittees", - "type": "tableSelect", - "label": "Select Committees", + "type": "text", + "label": "Committees", "required": true, - "help": "Maximum 15 committees per validator.", + "placeholder": "e.g. 1,2,3", + "help": "Enter committee IDs separated by commas. Maximum 15 committees per validator.", + "value": "{{ (params.selectCommittees || ds.validator?.committees || []).join ? (params.selectCommittees || ds.validator?.committees || []).join(',') : (params.selectCommittees || '') }}", + "autoPopulate": "once", "validation": { - "max": 15, "messages": { - "required": "Please select at least one committee", - "max": "Maximum 15 committees allowed per validator" + "required": "Please enter at least one committee ID" } }, - "multiple": true, - "rowKey": "id", - "selectMode": "action", - "value": "{{ params.selectCommittees || ds.validator?.committees || [] }}", - "autoPopulate": "once", - "rows": [ - { - "id": 1, - "name": "Canopy", - "minStake": "1 CNPY" - }, - { - "id": 2, - "name": "Canary", - "minStake": "1 CNPY" - } - ], - "columns": [ - { - "title": "Committee", - "type": "committee", - "align": "left" - }, - { - "title": "Staked Amount", - "type": "html", - "html": "{{ (() => { const isStaked = ds.validator?.committees?.includes(row.id); const isSelected = Array.isArray(form.selectCommittees) && (form.selectCommittees.includes(row.id) || form.selectCommittees.includes(String(row.id))); const amt = Number(form.amount) || 0; if (isStaked) { const current = ds.validator.stakedAmount / 1000000; const diff = amt - current; return '' + current.toLocaleString('en-US', {maximumFractionDigits: 3}) + ' CNPY + ' + (diff > 0 ? diff : 0).toLocaleString('en-US', {maximumFractionDigits: 3}) + ' CNPY'; } else if (isSelected) { return '' + amt.toLocaleString('en-US', {maximumFractionDigits: 3}) + ' CNPY'; } else { return '0 CNPY'; } })() }}", - "align": "left" - } - ], - "rowAction": { - "title": "Action", - "label": "{{ ds.validator?.committees?.includes(row.id) ? 'Staked' : 'Stake' }}", - "disabledIf": "{{ ds.validator?.committees?.includes(row.id) }}", - "emit": { - "op": "select" - } + "span": { + "base": 12 }, "step": "committees" }, @@ -726,7 +704,9 @@ "variant": "default", "defaultExpanded": false, "step": "committees", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "manualCommittees", @@ -758,7 +738,9 @@ "label": "Transaction Fee", "variant": "gradient", "spacing": "lg", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "committees" }, { @@ -766,11 +748,13 @@ "name": "txFee", "type": "amount", "label": "Transaction Fee", - "value": "{{ fromMicroDenom<{{ds.validator ? (fees.raw.editStakeFee ?? fees.raw.stakeFee ?? 0) : (fees.raw.stakeFee ?? 0)}}> }}", + "value": "{{fees.raw ? fromMicroDenom(ds.validator ? (fees.raw.editStakeFee ?? fees.raw.stakeFee ?? 0) : (fees.raw.stakeFee ?? 0)) : ''}}", "autoPopulate": "always", "required": true, "min": "{{ fromMicroDenom<{{ds.validator ? (fees.raw.editStakeFee ?? fees.raw.stakeFee ?? 0) : (fees.raw.stakeFee ?? 0)}}> }}", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Transaction fee is required", @@ -835,7 +819,7 @@ "coerce": "string" }, "committees": { - "value": "{{form?.selectCommittees?.filter(c => c !== '').map(c => c).join(',')}}", + "value": "{{form.selectCommittees}}", "coerce": "string" }, "netAddress": { @@ -931,12 +915,16 @@ "description": "Your vote will be recorded on-chain and cannot be changed after submission. Ensure you review the proposal details before voting.", "icon": "Info", "variant": "info", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "proposalId", @@ -944,7 +932,9 @@ "type": "text", "label": "Proposal ID", "required": true, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Proposal ID is required" @@ -956,7 +946,9 @@ "label": "Your Decision", "variant": "gradient", "spacing": "lg", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "vote", @@ -964,7 +956,9 @@ "type": "option", "label": "Your Vote", "required": true, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "options": [ { "label": "Yes", @@ -986,7 +980,9 @@ { "type": "spacer", "height": "sm", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "voterAddress", @@ -995,7 +991,9 @@ "label": "Voter Address", "value": "{{account.address}}", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -1083,19 +1081,25 @@ "description": "Proposals require a minimum deposit and will be open for community voting. Deposits are returned if the proposal passes or is rejected normally.", "icon": "Info", "variant": "info", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "heading", "text": "Proposal Details", "level": 3, "color": "secondary", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "title", @@ -1103,7 +1107,9 @@ "type": "text", "label": "Proposal Title", "required": true, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Title is required" @@ -1117,7 +1123,9 @@ "label": "Description", "required": true, "rows": 5, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Description is required" @@ -1129,7 +1137,9 @@ "label": "Submission Details", "variant": "gradient", "spacing": "lg", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "proposerAddress", @@ -1138,7 +1148,9 @@ "label": "Proposer Address", "value": "{{account.address}}", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "deposit", @@ -1147,7 +1159,9 @@ "label": "Deposit Amount", "required": true, "min": 0, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Deposit is required", @@ -1241,8 +1255,12 @@ "__options": { "staleTimeMs": 0, "refetchOnMount": true, - "watch": ["form.validatorAddress"], - "critical": ["keystore"] + "watch": [ + "form.validatorAddress" + ], + "critical": [ + "keystore" + ] } }, "form": { @@ -1253,12 +1271,16 @@ "description": "Maximum pause duration: 4,380 blocks (~24.3 hours). If not unpaused within this period, the validator will be automatically unstaked.", "icon": "AlertTriangle", "variant": "warning", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "validatorAddress", @@ -1273,7 +1295,9 @@ "autoPopulate": "once", "map": "{{ Object.keys(ds.keystore?.addressMap || {})?.map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname +') ' , value: k }))}}", "help": "The address of the validator to pause", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "signerAddress", @@ -1288,12 +1312,16 @@ "required": true, "map": "{{ Object.keys(ds.keystore?.addressMap || {})?.map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname +') ' , value: k }))}}", "help": "The address that will sign this transaction", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "divider", "spacing": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "memo", @@ -1302,18 +1330,22 @@ "label": "Memo (Optional)", "required": false, "placeholder": "Add a note about this pause action", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "txFee", "name": "txFee", "type": "amount", "label": "Transaction Fee", - "value": "{{ fromMicroDenom<{{fees.raw.pauseFee}}> }}", + "value": "{{fees.raw?.pauseFee != null ? fromMicroDenom(fees.raw.pauseFee) : ''}}", "autoPopulate": "always", "required": true, "min": "{{ fromMicroDenom<{{fees.raw.pauseFee}}> }}", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Transaction fee is required", @@ -1431,8 +1463,12 @@ "__options": { "staleTimeMs": 0, "refetchOnMount": true, - "watch": ["form.validatorAddress"], - "critical": ["keystore"] + "watch": [ + "form.validatorAddress" + ], + "critical": [ + "keystore" + ] } }, "form": { @@ -1443,12 +1479,16 @@ "description": "This action will resume your validator's participation in consensus and block production. Make sure your node is fully synced before unpausing.", "icon": "Info", "variant": "success", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "validatorAddress", @@ -1463,7 +1503,9 @@ "autoPopulate": "once", "map": "{{ Object.keys(ds.keystore?.addressMap || {})?.map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname +') ' , value: k }))}}", "help": "The address of the validator to unpause", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "signerAddress", @@ -1478,12 +1520,16 @@ "required": true, "map": "{{ Object.keys(ds.keystore?.addressMap || {})?.map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname +') ' , value: k }))}}", "help": "The address that will sign this transaction", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "divider", "spacing": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "memo", @@ -1492,18 +1538,22 @@ "label": "Memo (Optional)", "required": false, "placeholder": "Add a note about this unpause action", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "txFee", "name": "txFee", "type": "amount", "label": "Transaction Fee", - "value": "{{ fromMicroDenom<{{fees.raw.unpauseFee}}> }}", + "value": "{{fees.raw?.unpauseFee != null ? fromMicroDenom(fees.raw.unpauseFee) : ''}}", "autoPopulate": "always", "required": true, "min": "{{ fromMicroDenom<{{fees.raw.unpauseFee}}> }}", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Transaction fee is required", @@ -1630,19 +1680,25 @@ "description": "Community polls allow validators and delegators to gauge sentiment on non-binding proposals. Results are publicly visible but do not execute on-chain actions.", "icon": "Info", "variant": "info", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "heading", "text": "Poll Question", "level": 3, "color": "secondary", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "question", @@ -1651,7 +1707,9 @@ "label": "Poll Question", "required": true, "placeholder": "What would you like to ask?", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Question is required" @@ -1665,14 +1723,18 @@ "label": "Description (Optional)", "rows": 3, "placeholder": "Provide additional context for the poll...", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "divider", "label": "Configuration", "variant": "gradient", "spacing": "lg", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "duration", @@ -1681,7 +1743,9 @@ "label": "Poll Duration", "required": true, "value": "24", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "options": [ { "label": "24 Hours", @@ -1708,7 +1772,9 @@ { "type": "spacer", "height": "sm", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "creatorAddress", @@ -1717,7 +1783,9 @@ "label": "Creator Address", "value": "{{account.address}}", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -1800,6 +1868,7 @@ "address": "{{account.address}}" } }, + "fees": {}, "__options": { "staleTimeMs": 0, "refetchOnMount": true @@ -1817,7 +1886,9 @@ { "type": "spacer", "height": "sm", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "validatorAddress", @@ -1827,7 +1898,9 @@ "required": true, "readOnly": true, "help": "The address of the validator to unstake", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "section", @@ -1836,7 +1909,9 @@ "icon": "Info", "variant": "default", "showIf": "{{ form.validatorAddress && ds.validator }}", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "signerAddress", @@ -1847,7 +1922,9 @@ "required": true, "readOnly": true, "help": "The address that will sign this transaction", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "memo", @@ -1856,14 +1933,16 @@ "label": "Memo (Optional)", "required": false, "placeholder": "Add a note about this unstake action", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "txFee", "name": "txFee", "type": "amount", "label": "Transaction Fee", - "value": "{{ fromMicroDenom<{{fees.raw.unstakeFee}}> }}", + "value": "{{fees.raw?.unstakeFee != null ? fromMicroDenom(fees.raw.unstakeFee) : ''}}", "autoPopulate": "always", "required": true, "min": "{{ fromMicroDenom<{{fees.raw.unstakeFee}}> }}", @@ -1874,7 +1953,9 @@ } }, "help": "Network minimum fee: {{ fromMicroDenom<{{fees.raw.unstakeFee}}> }} {{chain.denom.symbol}}", - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -1999,6 +2080,7 @@ "ds": { "height": {}, "params": {}, + "fees": {}, "account": { "account": { "address": "{{account.address}}" @@ -2025,12 +2107,16 @@ "description": "Parameter change proposals require +2/3 validator approval to pass. Ensure start/end heights allow sufficient voting time.", "icon": "Info", "variant": "info", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "parameterSpace", @@ -2072,7 +2158,9 @@ "required": "Parameter space is required" } }, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "parameterKey", @@ -2088,7 +2176,10 @@ "required": "Parameter key is required" } }, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "parameterValue", @@ -2105,14 +2196,19 @@ "required": "Parameter value is required" } }, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "type": "divider", "label": "Voting Period", "variant": "gradient", "spacing": "lg", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "startHeight", @@ -2130,7 +2226,10 @@ "required": "Start height is required" } }, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "endHeight", @@ -2153,12 +2252,17 @@ "max": "End height must be at most {{max}} (start height + 10,000)." } }, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "type": "spacer", "height": "sm", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "signerAddress", @@ -2169,7 +2273,9 @@ "required": true, "readOnly": true, "help": "Must be a validator address to create proposals", - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -2267,6 +2373,7 @@ "icon": "Coins", "ds": { "height": {}, + "fees": {}, "account": { "account": { "address": "{{account.address}}" @@ -2293,19 +2400,25 @@ "description": "DAO treasury transfers require +2/3 validator approval to pass. Funds will be automatically transferred if proposal is approved.", "icon": "Info", "variant": "primary", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "heading", "text": "Transfer Details", "level": 3, "color": "secondary", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "amount", @@ -2313,9 +2426,11 @@ "type": "amount", "label": "Treasury Amount", "required": true, - "min": 0.000001, + "min": 1e-06, "placeholder": "Amount to transfer from DAO treasury", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Amount is required", @@ -2332,7 +2447,9 @@ "required": true, "placeholder": "Address to receive treasury funds", "help": "The address that will receive the funds if proposal passes", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Recipient address is required" @@ -2350,7 +2467,9 @@ "label": "Voting Period", "variant": "gradient", "spacing": "lg", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "startHeight", @@ -2363,7 +2482,10 @@ "readOnly": true, "placeholder": "Block height to start voting", "help": "Block height when voting begins", - "span": { "base": 12, "md": 6 }, + "span": { + "base": 12, + "md": 6 + }, "validation": { "messages": { "required": "Start height is required" @@ -2382,7 +2504,10 @@ "max": "{{Number(form.startHeight || resolveHeight(ds.height)) + 10000}}", "placeholder": "Block height to end voting", "help": "Block height when voting ends. Must be > start height and within 10,000 blocks.", - "span": { "base": 12, "md": 6 }, + "span": { + "base": 12, + "md": 6 + }, "validation": { "min": "{{Number(form.startHeight || resolveHeight(ds.height)) + 1}}", "max": "{{Number(form.startHeight || resolveHeight(ds.height)) + 10000}}", @@ -2396,7 +2521,9 @@ { "type": "divider", "spacing": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "memo", @@ -2407,12 +2534,16 @@ "rows": 3, "placeholder": "Provide justification for this treasury request...", "help": "Explanation of why this transfer is needed", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "sm", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "signerAddress", @@ -2423,7 +2554,9 @@ "required": true, "readOnly": true, "help": "Must be a validator address to create proposals", - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -2536,12 +2669,16 @@ "description": "This will permanently remove your vote from the specified proposal. You can vote again later if the voting period is still active.", "icon": "AlertTriangle", "variant": "warning", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "proposalHash", @@ -2551,7 +2688,9 @@ "required": true, "placeholder": "Enter proposal hash", "help": "The hash of the proposal to remove your vote from", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Proposal hash is required" @@ -2567,7 +2706,9 @@ { "type": "spacer", "height": "sm", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "voterAddress", @@ -2577,7 +2718,9 @@ "value": "{{account.address}}", "readOnly": true, "help": "Your validator address", - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -2624,6 +2767,7 @@ "icon": "CheckSquare", "ds": { "height": {}, + "fees": {}, "polls": { "id": "gov.poll" }, @@ -2648,19 +2792,25 @@ "description": "Community polls are non-binding votes to gauge community sentiment. Your vote will be publicly recorded but will not trigger any on-chain actions.", "icon": "Info", "variant": "info", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "spacer", "height": "md", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "type": "heading", "text": "Poll Identification", "level": 3, "color": "secondary", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "pollQuestion", @@ -2670,7 +2820,9 @@ "required": true, "placeholder": "Enter the poll question", "help": "The exact question from the poll you want to vote on", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Poll question is required" @@ -2686,7 +2838,10 @@ "min": "{{resolveHeight(ds.height) + 1}}", "placeholder": "Block height when poll ends", "help": "The end block height of the poll", - "span": { "base": 12, "md": 6 }, + "span": { + "base": 12, + "md": 6 + }, "validation": { "min": "{{resolveHeight(ds.height) + 1}}", "messages": { @@ -2703,14 +2858,19 @@ "required": false, "placeholder": "https://discord.com/...", "help": "Optional URL for poll discussion", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "type": "divider", "label": "Your Vote", "variant": "gradient", "spacing": "lg", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "voteApprove", @@ -2719,7 +2879,9 @@ "label": "Your Vote", "required": true, "value": true, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "options": [ { "label": "Approve", @@ -2738,7 +2900,9 @@ { "type": "spacer", "height": "sm", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "voterAddress", @@ -2747,7 +2911,9 @@ "label": "Voter Address", "value": "{{account.address}}", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -2850,7 +3016,9 @@ "label": "Address", "value": "{{account.address}}", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "proposal", @@ -2860,7 +3028,9 @@ "required": true, "placeholder": "Poll proposal or question", "help": "Clearly describe the question or decision being voted on.", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "endBlock", @@ -2868,7 +3038,7 @@ "type": "number", "label": "End Block", "required": true, - "value": "{{resolveHeight(ds.height) + 1}}", + "value": "{{resolveHeight(ds.height) + 1000}}", "autoPopulate": "once", "min": "{{resolveHeight(ds.height) + 1}}", "validation": { @@ -2878,8 +3048,11 @@ } }, "placeholder": "Block height when poll ends", - "help": "Use a valid future block height to allow participation.", - "span": { "base": 12, "md": 6 } + "help": "Use a valid future block height to allow participation. Pre-filled with current height + 1000.", + "span": { + "base": 12, + "md": 6 + } }, { "id": "URL", @@ -2889,7 +3062,10 @@ "required": false, "placeholder": "https://...", "help": "Optional link to Discord, forum, or proposal context.", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } } ], "confirmation": { @@ -2982,7 +3158,9 @@ "label": "Address", "value": "{{account.address}}", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "proposalHash", @@ -2992,17 +3170,22 @@ "required": true, "map": "{{Object.entries(ds['gov.poll'] || {}).map(([k, v]) => { const h = v?.proposalHash || k; return { label: String(h).slice(0, 12) + '...', value: h }; })}}", "help": "Select the poll you want to vote on.", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "proposal", "name": "proposal", - "type": "text", + "type": "textarea", "label": "Proposal Text", "required": true, + "rows": 3, "placeholder": "Enter the exact proposal text used to start the poll", "help": "Must exactly match the proposal text that was used when this poll was created.", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "endBlock", @@ -3012,7 +3195,10 @@ "required": true, "placeholder": "Enter the end block used to start the poll", "help": "Must exactly match the endBlock value that was used when this poll was created.", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "URL", @@ -3024,7 +3210,10 @@ "autoPopulate": "always", "placeholder": "https://...", "help": "Auto-filled from the poll data.", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "voteApprove", @@ -3045,7 +3234,9 @@ "help": "Vote against" } ], - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -3110,6 +3301,7 @@ "ds": { "height": {}, "params": {}, + "fees": {}, "account": { "account": { "address": "{{account.address}}" @@ -3138,7 +3330,9 @@ "label": "Proposer Address", "value": "{{account.address}}", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "paramSpace", @@ -3149,13 +3343,31 @@ "value": "fee", "map": "{{(() => { const root = ds.params?.params ?? ds.params ?? {}; const toValue = { consensus: 'cons', validator: 'val', fee: 'fee', governance: 'gov', economics: 'eco' }; const toLabel = { consensus: 'Consensus Parameters', validator: 'Validator Parameters', fee: 'Fee Parameters', governance: 'Governance Parameters', economics: 'Economics Parameters' }; const keys = Object.keys(root || {}); if (!keys.length) return null; return keys.map(k => ({ label: toLabel[k] || k, value: toValue[k] || k })); })()}}", "options": [ - { "label": "Fee Parameters", "value": "fee" }, - { "label": "Consensus Parameters", "value": "cons" }, - { "label": "Validator Parameters", "value": "val" }, - { "label": "Governance Parameters", "value": "gov" }, - { "label": "Economics Parameters", "value": "eco" } + { + "label": "Fee Parameters", + "value": "fee" + }, + { + "label": "Consensus Parameters", + "value": "cons" + }, + { + "label": "Validator Parameters", + "value": "val" + }, + { + "label": "Governance Parameters", + "value": "gov" + }, + { + "label": "Economics Parameters", + "value": "eco" + } ], - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "paramKey", @@ -3163,8 +3375,11 @@ "type": "select", "label": "Parameter Key", "required": true, - "map": "{{(() => { const root = ds.params?.params ?? ds.params ?? {}; const aliasToRoot = { cons: 'consensus', val: 'validator', fee: 'fee', gov: 'governance', eco: 'economics' }; const selected = form.paramSpace || ''; const resolved = root[selected] ? selected : (aliasToRoot[selected] || selected); const space = root?.[resolved] || {}; return Object.keys(space).map(k => ({ label: k, value: k })); })()}}", - "span": { "base": 12, "md": 6 } + "map": "{{(() => { const root = ds.params?.params ?? ds.params ?? {}; const aliasToRoot = { cons: 'consensus', val: 'validator', fee: 'fee', gov: 'governance', eco: 'economics' }; const selected = form.paramSpace || 'fee'; const resolved = root[selected] ? selected : (aliasToRoot[selected] || selected); const space = root?.[resolved] || {}; return Object.keys(space).map(k => ({ label: k, value: k })); })()}}", + "span": { + "base": 12, + "md": 6 + } }, { "id": "paramValue", @@ -3172,9 +3387,11 @@ "type": "text", "label": "New Value", "required": true, - "value": "{{(() => { const root = ds.params?.params ?? ds.params ?? {}; const aliasToRoot = { cons: 'consensus', val: 'validator', fee: 'fee', gov: 'governance', eco: 'economics' }; const selected = form.paramSpace || ''; const resolved = root[selected] ? selected : (aliasToRoot[selected] || selected); return root?.[resolved]?.[form.paramKey] ?? ''; })()}}", + "value": "{{(() => { const root = ds.params?.params ?? ds.params ?? {}; const aliasToRoot = { cons: 'consensus', val: 'validator', fee: 'fee', gov: 'governance', eco: 'economics' }; const selected = form.paramSpace || 'fee'; const resolved = root[selected] ? selected : (aliasToRoot[selected] || selected); return root?.[resolved]?.[form.paramKey] ?? ''; })()}}", "autoPopulate": "once", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "startBlock", @@ -3186,7 +3403,10 @@ "autoPopulate": "always", "readOnly": true, "help": "Always uses current chain height.", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "endBlock", @@ -3207,7 +3427,10 @@ } }, "help": "Must be greater than start block and within 10,000 blocks.", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "memo", @@ -3215,7 +3438,9 @@ "type": "textarea", "label": "Memo", "placeholder": "Description/reason for change", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "fee", @@ -3223,9 +3448,12 @@ "type": "amount", "label": "Fee", "required": true, - "value": "{{fromMicroDenom<{{fees.raw.changeParameterFee ?? 0}}>}}", - "autoPopulate": "once", - "span": { "base": 12, "md": 12 } + "value": "{{fees.raw?.changeParameterFee != null ? fromMicroDenom(fees.raw.changeParameterFee) : ''}}", + "autoPopulate": "always", + "span": { + "base": 12, + "md": 12 + } } ], "confirmation": { @@ -3315,6 +3543,7 @@ "icon": "Coins", "ds": { "height": {}, + "fees": {}, "account": { "account": { "address": "{{account.address}}" @@ -3343,7 +3572,9 @@ "label": "Proposer Address", "value": "{{account.address}}", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "amount", @@ -3351,7 +3582,10 @@ "type": "amount", "label": "Amount (CNPY)", "required": true, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "fee", @@ -3359,7 +3593,7 @@ "type": "amount", "label": "Fee", "required": true, - "value": "{{fromMicroDenom<{{fees.raw.subsidyFee ?? 0}}>}}", + "value": "{{fees.raw?.subsidyFee != null ? fromMicroDenom(fees.raw.subsidyFee) : ''}}", "min": "{{ fromMicroDenom<{{fees.raw.subsidyFee ?? 0}}> }}", "validation": { "messages": { @@ -3367,8 +3601,11 @@ "min": "Transaction fee cannot be less than the network minimum: {{min}} {{chain.denom.symbol}}" } }, - "autoPopulate": "once", - "span": { "base": 12, "md": 6 } + "autoPopulate": "always", + "span": { + "base": 12, + "md": 6 + } }, { "id": "startBlock", @@ -3380,7 +3617,10 @@ "autoPopulate": "always", "readOnly": true, "help": "Always uses current chain height.", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "endBlock", @@ -3401,7 +3641,10 @@ } }, "help": "Must be greater than start block and within 10,000 blocks.", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "memo", @@ -3409,7 +3652,9 @@ "type": "textarea", "label": "Memo", "placeholder": "Description/reason for transfer", - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -3515,7 +3760,9 @@ "placeholder": "{\"type\":\"...\"}", "help": "Advanced mode: paste a fully signed raw transaction JSON to broadcast via /v1/tx.", "rows": 12, - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -3572,7 +3819,9 @@ "placeholder": "Paste the generated proposal JSON here", "help": "Paste the raw transaction JSON from step 1 (Generate Proposal). This adds the proposal to the node's local approve list.", "rows": 12, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "approve", @@ -3591,7 +3840,9 @@ "value": false } ], - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -3659,7 +3910,9 @@ "label": "Proposal", "required": true, "map": "{{Object.keys(ds['gov.proposals'] || {}).map(k => ({ label: k, value: k }))}}", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "proposalMsg", @@ -3670,7 +3923,9 @@ "autoPopulate": "always", "readOnly": true, "rows": 10, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "proposalType", @@ -3680,7 +3935,10 @@ "value": "{{ds['gov.proposals']?.[form.proposalId]?.proposal?.type || 'unknown'}}", "autoPopulate": "always", "readOnly": true, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "currentVoteStatus", @@ -3690,7 +3948,10 @@ "value": "{{(() => { const v = ds['gov.proposals']?.[form.proposalId]?.approve; if (v === true) return 'Approved'; if (v === false) return 'Rejected'; return 'No vote'; })()}}", "autoPopulate": "always", "readOnly": true, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "changeSummary", @@ -3701,7 +3962,9 @@ "autoPopulate": "always", "readOnly": true, "rows": 3, - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -3755,6 +4018,7 @@ } }, "keystore": {}, + "fees": {}, "__options": { "staleTimeMs": 6000, "refetchIntervalMs": 6000, @@ -3816,20 +4080,26 @@ { "id": "address", "name": "address", - "type": "text", + "type": "advancedSelect", "label": "Seller Address", + "required": true, "value": "{{params.address || account.address}}", - "autoPopulate": "always", - "readOnly": true, - "span": { "base": 12 }, + "autoPopulate": "once", + "validation": { + "messages": { + "required": "Seller address is required" + } + }, + "map": "{{ Object.keys(ds.keystore?.addressMap || {}).map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname + ')', value: k })) }}", + "span": { + "base": 12 + }, "step": "context" }, { "id": "receiveAddress", "name": "receiveAddress", "type": "advancedSelect", - "allowFreeInput": true, - "allowCreate": true, "label": "Receive Address (Counter-Asset)", "required": true, "value": "{{params.receiveAddress || account.address || ''}}", @@ -3840,51 +4110,29 @@ } }, "map": "{{ Object.keys(ds.keystore?.addressMap || {}).map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname + ')', value: k })) }}", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "context" }, { "id": "committees", "name": "committees", - "type": "tableSelect", + "type": "text", "label": "Committee", "required": true, - "help": "Select the committee used for this order.", - "multiple": false, - "rowKey": "id", - "selectMode": "action", + "placeholder": "e.g. 1", + "help": "Enter the committee ID for this order.", "value": "{{ params.committees ?? ds['admin.config']?.chainId ?? '' }}", "autoPopulate": "once", - "rows": [ - { - "id": 1, - "name": "Canopy" - }, - { - "id": 2, - "name": "Canary" - } - ], - "columns": [ - { - "title": "Committee", - "type": "committee", - "align": "left" - }, - { - "title": "Committee ID", - "expr": "{{row.id}}", - "align": "left" - } - ], - "rowAction": { - "title": "Action", - "label": "{{ (form.committees ?? '') + '' === (row.id ?? '') + '' ? 'Selected' : 'Select' }}", - "emit": { - "op": "select" + "validation": { + "messages": { + "required": "Committee ID is required" } }, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "context" }, { @@ -3893,7 +4141,7 @@ "type": "amount", "label": "Amount For Sale", "required": true, - "min": 0.000001, + "min": 1e-06, "max": "{{ fromMicroDenom<{{ds.account?.amount ?? 0}}> }}", "validation": { "messages": { @@ -3902,7 +4150,10 @@ "max": "Insufficient balance. Max available: {{max}} {{chain.denom.symbol}}" } }, - "span": { "base": 12, "md": 6 }, + "span": { + "base": 12, + "md": 6 + }, "step": "pricing" }, { @@ -3911,14 +4162,17 @@ "type": "amount", "label": "Requested Amount", "required": true, - "min": 0.000001, + "min": 1e-06, "validation": { "messages": { "required": "Requested amount is required", "min": "Requested amount must be greater than 0" } }, - "span": { "base": 12, "md": 6 }, + "span": { + "base": 12, + "md": 6 + }, "step": "pricing" }, { @@ -3929,7 +4183,9 @@ "placeholder": "Optional message", "value": "{{params.memo || ''}}", "autoPopulate": "once", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "pricing" }, { @@ -3937,10 +4193,12 @@ "name": "fee", "type": "amount", "label": "Fee", - "value": "{{fromMicroDenom<{{fees.raw.createOrderFee ?? 0}}>}}", - "autoPopulate": "once", + "value": "{{fees.raw?.createOrderFee != null ? fromMicroDenom(fees.raw.createOrderFee) : ''}}", + "autoPopulate": "always", "min": "{{ fromMicroDenom<{{fees.raw.createOrderFee ?? 0}}> }}", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "pricing" } ], @@ -4032,6 +4290,7 @@ "ds": { "admin.config": {}, "keystore": {}, + "fees": {}, "__options": { "staleTimeMs": 6000, "refetchIntervalMs": 6000, @@ -4097,7 +4356,9 @@ "value": "{{params.address || account.address}}", "autoPopulate": "always", "readOnly": true, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "context" }, { @@ -4114,7 +4375,9 @@ "required": "Order ID is required" } }, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "context" }, { @@ -4133,53 +4396,29 @@ } }, "map": "{{ Object.keys(ds.keystore?.addressMap || {}).map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname + ')', value: k })) }}", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "context" }, { "id": "committees", "name": "committees", - "type": "tableSelect", + "type": "text", "label": "Committee", "required": true, - "help": "Select the committee used for this order.", - "multiple": false, - "rowKey": "id", - "selectMode": "action", + "placeholder": "e.g. 1", + "help": "Enter the committee ID for this order.", "value": "{{ params.committees ?? ds['admin.config']?.chainId ?? '' }}", "autoPopulate": "once", - "rows": [ - { - "id": 1, - "name": "Canopy", - "minStake": "1 CNPY" - }, - { - "id": 2, - "name": "Canary", - "minStake": "1 CNPY" - } - ], - "columns": [ - { - "title": "Committee", - "type": "committee", - "align": "left" - }, - { - "title": "Committee ID", - "expr": "{{row.id}}", - "align": "left" - } - ], - "rowAction": { - "title": "Action", - "label": "{{ (form.committees ?? '') + '' === (row.id ?? '') + '' ? 'Selected' : 'Select' }}", - "emit": { - "op": "select" + "validation": { + "messages": { + "required": "Committee ID is required" } }, - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "context" }, { @@ -4188,7 +4427,7 @@ "type": "amount", "label": "Amount For Sale", "required": true, - "min": 0.000001, + "min": 1e-06, "value": "{{params.amount ?? ''}}", "autoPopulate": "once", "validation": { @@ -4197,7 +4436,10 @@ "min": "Amount must be greater than 0" } }, - "span": { "base": 12, "md": 6 }, + "span": { + "base": 12, + "md": 6 + }, "step": "pricing" }, { @@ -4208,14 +4450,17 @@ "required": true, "value": "{{params.receiveAmount ?? ''}}", "autoPopulate": "once", - "min": 0.000001, + "min": 1e-06, "validation": { "messages": { "required": "Requested amount is required", "min": "Requested amount must be greater than 0" } }, - "span": { "base": 12, "md": 6 }, + "span": { + "base": 12, + "md": 6 + }, "step": "pricing" }, { @@ -4226,7 +4471,9 @@ "placeholder": "Sub-asset contract address (hex)", "value": "{{params.data || ''}}", "autoPopulate": "once", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "context" }, { @@ -4237,7 +4484,9 @@ "placeholder": "Optional message", "value": "{{params.memo || ''}}", "autoPopulate": "once", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "step": "pricing" }, { @@ -4245,10 +4494,13 @@ "name": "fee", "type": "amount", "label": "Fee", - "value": "{{fromMicroDenom<{{fees.raw.editOrderFee ?? 0}}>}}", - "autoPopulate": "once", + "value": "{{fees.raw?.editOrderFee != null ? fromMicroDenom(fees.raw.editOrderFee) : ''}}", + "autoPopulate": "always", "min": "{{ fromMicroDenom<{{fees.raw.editOrderFee ?? 0}}> }}", - "span": { "base": 12, "md": 6 }, + "span": { + "base": 12, + "md": 6 + }, "step": "pricing" } ], @@ -4343,6 +4595,7 @@ "icon": "Trash2", "ds": { "admin.config": {}, + "fees": {}, "__options": { "staleTimeMs": 6000, "refetchIntervalMs": 6000, @@ -4390,7 +4643,9 @@ "value": "{{params.address || account.address}}", "autoPopulate": "always", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "orderId", @@ -4406,52 +4661,28 @@ "required": "Order ID is required" } }, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "committees", "name": "committees", - "type": "tableSelect", + "type": "text", "label": "Committee", "required": true, - "help": "Select the committee used for this order.", - "multiple": false, - "rowKey": "id", - "selectMode": "action", + "placeholder": "e.g. 1", + "help": "Enter the committee ID for this order.", "value": "{{ params.committees ?? ds['admin.config']?.chainId ?? '' }}", "autoPopulate": "once", - "rows": [ - { - "id": 1, - "name": "Canopy", - "minStake": "1 CNPY" - }, - { - "id": 2, - "name": "Canary", - "minStake": "1 CNPY" - } - ], - "columns": [ - { - "title": "Committee", - "type": "committee", - "align": "left" - }, - { - "title": "Committee ID", - "expr": "{{row.id}}", - "align": "left" - } - ], - "rowAction": { - "title": "Action", - "label": "{{ (form.committees ?? '') + '' === (row.id ?? '') + '' ? 'Selected' : 'Select' }}", - "emit": { - "op": "select" + "validation": { + "messages": { + "required": "Committee ID is required" } }, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "memo", @@ -4461,17 +4692,22 @@ "placeholder": "Optional message", "value": "{{params.memo || ''}}", "autoPopulate": "once", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "fee", "name": "fee", "type": "amount", "label": "Fee", - "value": "{{fromMicroDenom<{{fees.raw.deleteOrderFee ?? 0}}>}}", - "autoPopulate": "once", + "value": "{{fees.raw?.deleteOrderFee != null ? fromMicroDenom(fees.raw.deleteOrderFee) : ''}}", + "autoPopulate": "always", "min": "{{ fromMicroDenom<{{fees.raw.deleteOrderFee ?? 0}}> }}", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } } ], "confirmation": { @@ -4542,6 +4778,7 @@ "ds": { "keystore": {}, "params": {}, + "fees": {}, "__options": { "staleTimeMs": 6000, "refetchIntervalMs": 6000, @@ -4590,7 +4827,9 @@ "value": "{{params.address || account.address}}", "autoPopulate": "always", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "receiveAddress", @@ -4608,7 +4847,9 @@ } }, "map": "{{ Object.keys(ds.keystore?.addressMap || {}).map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname + ')', value: k })) }}", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "orderId", @@ -4624,18 +4865,22 @@ "required": "Order ID is required" } }, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "txFee", "name": "txFee", "type": "amount", "label": "Transaction Fee", - "value": "{{ fromMicroDenom<{{(fees.raw.sendFee ?? 0) * (ds.params?.validator?.lockOrderFeeMultiplier ?? 1)}}> }}", + "value": "{{fees.raw ? fromMicroDenom((fees.raw.sendFee ?? 0) * (ds.params?.validator?.lockOrderFeeMultiplier ?? 1)) : ''}}", "autoPopulate": "always", "required": true, "min": "{{ fromMicroDenom<{{(fees.raw.sendFee ?? 0) * (ds.params?.validator?.lockOrderFeeMultiplier ?? 1)}}> }}", - "span": { "base": 12 }, + "span": { + "base": 12 + }, "validation": { "messages": { "required": "Transaction fee is required", @@ -4712,6 +4957,7 @@ "icon": "CheckCircle2", "ds": { "params": {}, + "fees": {}, "__options": { "staleTimeMs": 6000, "refetchIntervalMs": 6000, @@ -4759,7 +5005,9 @@ "value": "{{params.address || account.address}}", "autoPopulate": "always", "readOnly": true, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "orderId", @@ -4775,15 +5023,17 @@ "required": "Order ID is required" } }, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "fee", "name": "fee", "type": "amount", "label": "Fee", - "value": "{{ fromMicroDenom<{{(fees.raw.sendFee ?? 0) * (ds.params?.validator?.lockOrderFeeMultiplier ?? 1)}}> }}", - "autoPopulate": "once", + "value": "{{fees.raw ? fromMicroDenom((fees.raw.sendFee ?? 0) * (ds.params?.validator?.lockOrderFeeMultiplier ?? 1)) : ''}}", + "autoPopulate": "always", "required": true, "min": "{{ fromMicroDenom<{{(fees.raw.sendFee ?? 0) * (ds.params?.validator?.lockOrderFeeMultiplier ?? 1)}}> }}", "validation": { @@ -4793,7 +5043,9 @@ } }, "help": "Network minimum fee: {{ fromMicroDenom<{{(fees.raw.sendFee ?? 0) * (ds.params?.validator?.lockOrderFeeMultiplier ?? 1)}}> }} {{chain.denom.symbol}}", - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -4855,13 +5107,16 @@ "icon": "ArrowLeftRight", "ds": { "admin.config": {}, + "keystore": {}, + "fees": {}, "__options": { "staleTimeMs": 6000, "refetchIntervalMs": 6000, "refetchOnMount": true, "refetchOnWindowFocus": false, "critical": [ - "admin.config" + "admin.config", + "keystore" ] } }, @@ -4897,57 +5152,39 @@ { "id": "address", "name": "address", - "type": "text", + "type": "advancedSelect", "label": "Address", + "required": true, "value": "{{params.address || account.address}}", - "autoPopulate": "always", - "readOnly": true, - "span": { "base": 12 } + "autoPopulate": "once", + "validation": { + "messages": { + "required": "Address is required" + } + }, + "map": "{{ Object.keys(ds.keystore?.addressMap || {}).map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname + ')', value: k })) }}", + "span": { + "base": 12 + } }, { "id": "committees", "name": "committees", - "type": "tableSelect", + "type": "text", "label": "Committee", "required": true, - "help": "Select the target committee for the limit order.", - "multiple": false, - "rowKey": "id", - "selectMode": "action", + "help": "Enter the target committee ID for the limit order.", + "placeholder": "e.g. 1", "value": "{{ params.committees ?? ds['admin.config']?.chainId ?? '' }}", "autoPopulate": "once", - "rows": [ - { - "id": 1, - "name": "Canopy", - "minStake": "1 CNPY" - }, - { - "id": 2, - "name": "Canary", - "minStake": "1 CNPY" - } - ], - "columns": [ - { - "title": "Committee", - "type": "committee", - "align": "left" - }, - { - "title": "Committee ID", - "expr": "{{row.id}}", - "align": "left" - } - ], - "rowAction": { - "title": "Action", - "label": "{{ (form.committees ?? '') + '' === (row.id ?? '') + '' ? 'Selected' : 'Select' }}", - "emit": { - "op": "select" + "validation": { + "messages": { + "required": "Committee ID is required" } }, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "amount", @@ -4955,14 +5192,17 @@ "type": "amount", "label": "Amount", "required": true, - "min": 0.000001, + "min": 1e-06, "validation": { "messages": { "required": "Amount is required", "min": "Amount must be greater than 0" } }, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "receiveAmount", @@ -4970,14 +5210,17 @@ "type": "amount", "label": "Minimum Receive Amount", "required": true, - "min": 0.000001, + "min": 1e-06, "validation": { "messages": { "required": "Minimum receive amount is required", "min": "Amount must be greater than 0" } }, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "memo", @@ -4987,17 +5230,21 @@ "placeholder": "Optional message", "value": "{{params.memo || ''}}", "autoPopulate": "once", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "fee", "name": "fee", "type": "amount", "label": "Fee", - "value": "{{fromMicroDenom<{{fees.raw.dexLimitOrderFee ?? 0}}>}}", - "autoPopulate": "once", + "value": "{{fees.raw?.dexLimitOrderFee != null ? fromMicroDenom(fees.raw.dexLimitOrderFee) : ''}}", + "autoPopulate": "always", "min": "{{ fromMicroDenom<{{fees.raw.dexLimitOrderFee ?? 0}}> }}", - "span": { "base": 12 } + "span": { + "base": 12 + } } ], "confirmation": { @@ -5075,13 +5322,16 @@ "icon": "Droplets", "ds": { "admin.config": {}, + "keystore": {}, + "fees": {}, "__options": { "staleTimeMs": 6000, "refetchIntervalMs": 6000, "refetchOnMount": true, "refetchOnWindowFocus": false, "critical": [ - "admin.config" + "admin.config", + "keystore" ] } }, @@ -5117,57 +5367,39 @@ { "id": "address", "name": "address", - "type": "text", + "type": "advancedSelect", "label": "Address", + "required": true, "value": "{{params.address || account.address}}", - "autoPopulate": "always", - "readOnly": true, - "span": { "base": 12 } + "autoPopulate": "once", + "validation": { + "messages": { + "required": "Address is required" + } + }, + "map": "{{ Object.keys(ds.keystore?.addressMap || {}).map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname + ')', value: k })) }}", + "span": { + "base": 12 + } }, { "id": "committees", "name": "committees", - "type": "tableSelect", + "type": "text", "label": "Committee", "required": true, - "help": "Select the target committee for the liquidity deposit.", - "multiple": false, - "rowKey": "id", - "selectMode": "action", + "help": "Enter the target committee ID for the liquidity deposit.", + "placeholder": "e.g. 1", "value": "{{ params.committees ?? ds['admin.config']?.chainId ?? '' }}", "autoPopulate": "once", - "rows": [ - { - "id": 1, - "name": "Canopy", - "minStake": "1 CNPY" - }, - { - "id": 2, - "name": "Canary", - "minStake": "1 CNPY" - } - ], - "columns": [ - { - "title": "Committee", - "type": "committee", - "align": "left" - }, - { - "title": "Committee ID", - "expr": "{{row.id}}", - "align": "left" - } - ], - "rowAction": { - "title": "Action", - "label": "{{ (form.committees ?? '') + '' === (row.id ?? '') + '' ? 'Selected' : 'Select' }}", - "emit": { - "op": "select" + "validation": { + "messages": { + "required": "Committee ID is required" } }, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "amount", @@ -5175,14 +5407,17 @@ "type": "amount", "label": "Amount", "required": true, - "min": 0.000001, + "min": 1e-06, "validation": { "messages": { "required": "Amount is required", "min": "Amount must be greater than 0" } }, - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } }, { "id": "memo", @@ -5192,17 +5427,22 @@ "placeholder": "Optional message", "value": "{{params.memo || ''}}", "autoPopulate": "once", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "fee", "name": "fee", "type": "amount", "label": "Fee", - "value": "{{fromMicroDenom<{{fees.raw.dexLiquidityDeposit ?? 0}}>}}", - "autoPopulate": "once", + "value": "{{fees.raw?.dexLiquidityDeposit != null ? fromMicroDenom(fees.raw.dexLiquidityDeposit) : ''}}", + "autoPopulate": "always", "min": "{{ fromMicroDenom<{{fees.raw.dexLiquidityDeposit ?? 0}}> }}", - "span": { "base": 12, "md": 6 } + "span": { + "base": 12, + "md": 6 + } } ], "confirmation": { @@ -5272,13 +5512,16 @@ "icon": "CircleDashed", "ds": { "admin.config": {}, + "keystore": {}, + "fees": {}, "__options": { "staleTimeMs": 6000, "refetchIntervalMs": 6000, "refetchOnMount": true, "refetchOnWindowFocus": false, "critical": [ - "admin.config" + "admin.config", + "keystore" ] } }, @@ -5314,57 +5557,39 @@ { "id": "address", "name": "address", - "type": "text", + "type": "advancedSelect", "label": "Address", + "required": true, "value": "{{params.address || account.address}}", - "autoPopulate": "always", - "readOnly": true, - "span": { "base": 12 } + "autoPopulate": "once", + "validation": { + "messages": { + "required": "Address is required" + } + }, + "map": "{{ Object.keys(ds.keystore?.addressMap || {}).map(k => ({ label: k + ' (' + ds.keystore.addressMap[k].keyNickname + ')', value: k })) }}", + "span": { + "base": 12 + } }, { "id": "committees", "name": "committees", - "type": "tableSelect", + "type": "text", "label": "Committee", "required": true, - "help": "Select the target committee for the liquidity withdraw.", - "multiple": false, - "rowKey": "id", - "selectMode": "action", + "help": "Enter the target committee ID for the liquidity withdraw.", + "placeholder": "e.g. 1", "value": "{{ params.committees ?? ds['admin.config']?.chainId ?? '' }}", "autoPopulate": "once", - "rows": [ - { - "id": 1, - "name": "Canopy", - "minStake": "1 CNPY" - }, - { - "id": 2, - "name": "Canary", - "minStake": "1 CNPY" - } - ], - "columns": [ - { - "title": "Committee", - "type": "committee", - "align": "left" - }, - { - "title": "Committee ID", - "expr": "{{row.id}}", - "align": "left" - } - ], - "rowAction": { - "title": "Action", - "label": "{{ (form.committees ?? '') + '' === (row.id ?? '') + '' ? 'Selected' : 'Select' }}", - "emit": { - "op": "select" + "validation": { + "messages": { + "required": "Committee ID is required" } }, - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "percent", @@ -5377,12 +5602,30 @@ "step": 1, "suffix": "%", "showInput": true, - "marks": [1, 25, 50, 75, 100], + "marks": [ + 1, + 25, + 50, + 75, + 100 + ], "presets": [ - { "label": "25%", "value": 25 }, - { "label": "50%", "value": 50 }, - { "label": "75%", "value": 75 }, - { "label": "100%", "value": 100 } + { + "label": "25%", + "value": 25 + }, + { + "label": "50%", + "value": 50 + }, + { + "label": "75%", + "value": 75 + }, + { + "label": "100%", + "value": 100 + } ], "help": "Use the slider or direct input. Allowed range: 1% to 100%.", "value": "{{params.percent ?? 50}}", @@ -5394,7 +5637,10 @@ "max": "Percent cannot exceed {{max}}" } }, - "span": { "base": 12, "md": 12 } + "span": { + "base": 12, + "md": 12 + } }, { "id": "memo", @@ -5404,17 +5650,22 @@ "placeholder": "Optional message", "value": "{{params.memo || ''}}", "autoPopulate": "once", - "span": { "base": 12 } + "span": { + "base": 12 + } }, { "id": "fee", "name": "fee", "type": "amount", "label": "Fee", - "value": "{{fromMicroDenom<{{fees.raw.dexLiquidityWithdraw ?? 0}}>}}", - "autoPopulate": "once", + "value": "{{fees.raw?.dexLiquidityWithdraw != null ? fromMicroDenom(fees.raw.dexLiquidityWithdraw) : ''}}", + "autoPopulate": "always", "min": "{{ fromMicroDenom<{{fees.raw.dexLiquidityWithdraw ?? 0}}> }}", - "span": { "base": 12, "md": 12 } + "span": { + "base": 12, + "md": 12 + } } ], "confirmation": { diff --git a/cmd/rpc/web/wallet/src/actions/ActionRunner.tsx b/cmd/rpc/web/wallet/src/actions/ActionRunner.tsx index d98340f3d..9ebdebc9b 100644 --- a/cmd/rpc/web/wallet/src/actions/ActionRunner.tsx +++ b/cmd/rpc/web/wallet/src/actions/ActionRunner.tsx @@ -59,6 +59,7 @@ const extractServiceErrorMessage = (response: any, statusText?: string): string response?.error?.reason, response?.error?.details, response?.message, + response?.msg, response?.reason, response?.detail, response?.description, @@ -644,6 +645,30 @@ export default function ActionRunner({ isEditMode, }); + // _skipToConfirm: if prefilledData requests skipping, jump directly to + // confirm (when available) or auto-execute once all required fields are satisfied + const didSkipToConfirmRef = React.useRef(false); + React.useEffect(() => { + if (didSkipToConfirmRef.current) return; + if (!prefilledData?._skipToConfirm) return; + if (populatePhase !== "ready") return; + + const requiredFields = allFields.filter((f: Record) => f.required); + const allSatisfied = requiredFields.every((f: Record) => { + const val = form[f.name as string]; + return val != null && val !== "" && !(Array.isArray(val) && val.length === 0); + }); + + if (allSatisfied) { + didSkipToConfirmRef.current = true; + if (hasSummary) { + setStage("confirm"); + } else { + void doExecute(); + } + } + }, [prefilledData, populatePhase, hasSummary, allFields, form, doExecute]); + const handleErrorsChange = React.useCallback( (errs: Record, hasErrors: boolean) => { setErrorsMap(errs); diff --git a/cmd/rpc/web/wallet/src/actions/usePopulateController.ts b/cmd/rpc/web/wallet/src/actions/usePopulateController.ts index 4797b3b81..ace95099a 100644 --- a/cmd/rpc/web/wallet/src/actions/usePopulateController.ts +++ b/cmd/rpc/web/wallet/src/actions/usePopulateController.ts @@ -271,8 +271,7 @@ export function usePopulateController({ if (fieldValue != null) { try { const resolved = templateAny(fieldValue, templateContext); - if (resolved !== undefined && resolved !== null) { - // Only update if value changed + if (resolved !== undefined && resolved !== null && resolved !== '') { if (form[fieldName] !== resolved) { updates[fieldName] = resolved; } diff --git a/cmd/rpc/web/wallet/src/app/pages/Accounts.tsx b/cmd/rpc/web/wallet/src/app/pages/Accounts.tsx index edcdf41a2..dbeea4c3b 100644 --- a/cmd/rpc/web/wallet/src/app/pages/Accounts.tsx +++ b/cmd/rpc/web/wallet/src/app/pages/Accounts.tsx @@ -3,6 +3,7 @@ import { motion } from "framer-motion"; import { ArrowLeftRight, Box, + Copy, Layers, Lock, Search, @@ -22,6 +23,7 @@ import { useStakedBalanceHistory } from "@/hooks/useStakedBalanceHistory"; import { useActionModal } from "@/app/providers/ActionModalProvider"; import { useAccounts } from "@/app/providers/AccountsProvider"; import { useConfig } from "@/app/providers/ConfigProvider"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import AnimatedNumber from "@/components/ui/AnimatedNumber"; export const Accounts = () => { @@ -44,6 +46,7 @@ export const Accounts = () => { useStakedBalanceHistory(); const { openAction } = useActionModal(); const { chain } = useConfig(); + const { copyToClipboard } = useCopyToClipboard(); const symbol = chain?.denom?.symbol || "CNPY"; const decimals = chain?.denom?.decimals ?? 6; @@ -94,30 +97,34 @@ export const Accounts = () => { : { label: "Liquid", cls: "bg-muted/40 text-muted-foreground border border-border/60" }; }; - const processedAddresses = accounts.map((account, index) => { - const { liquid, staked, total } = getRealTotal(account.address); - const { label: statusLabel, cls: statusCls } = getStatusInfo(account.address); - const { icon, bg } = getAccountIcon(index); - return { - id: account.address, - fullAddress: account.address, - address: fmtAddress(account.address), - nickname: account.nickname || fmtAddress(account.address), - total, - liquid, - staked, - stakedPct: total > 0 ? (staked / total) * 100 : 0, - liquidPct: total > 0 ? (liquid / total) * 100 : 0, - statusLabel, - statusCls, - icon, - iconBg: bg, - }; - }); + const processedAddresses = accounts + .map((account, index) => { + const { liquid, staked, total } = getRealTotal(account.address); + const { label: statusLabel, cls: statusCls } = getStatusInfo(account.address); + const { icon, bg } = getAccountIcon(index); + return { + id: account.address, + fullAddress: account.address, + address: fmtAddress(account.address), + nickname: account.nickname || fmtAddress(account.address), + publicKey: account.publicKey || "", + total, + liquid, + staked, + stakedPct: total > 0 ? (staked / total) * 100 : 0, + liquidPct: total > 0 ? (liquid / total) * 100 : 0, + statusLabel, + statusCls, + icon, + iconBg: bg, + }; + }) + .sort((a, b) => a.nickname.localeCompare(b.nickname)); const filteredAddresses = processedAddresses.filter(addr => addr.address.toLowerCase().includes(searchTerm.toLowerCase()) || - addr.nickname.toLowerCase().includes(searchTerm.toLowerCase()), + addr.nickname.toLowerCase().includes(searchTerm.toLowerCase()) || + addr.fullAddress.toLowerCase().includes(searchTerm.toLowerCase()), ); const handleSendAction = (address: string) => { @@ -336,10 +343,11 @@ export const Accounts = () => {
- +
+ @@ -350,7 +358,7 @@ export const Accounts = () => { {filteredAddresses.length === 0 ? ( - @@ -380,6 +388,25 @@ export const Accounts = () => { + {/* Public Key */} + + {/* Total */}
AddressPublic Key Total Staked Liquid
+ No addresses found
+ {addr.publicKey ? ( +
+ + {`${addr.publicKey.slice(0, 8)}…${addr.publicKey.slice(-6)}`} + + +
+ ) : ( + + )} +
diff --git a/cmd/rpc/web/wallet/src/app/pages/AllTransactions.tsx b/cmd/rpc/web/wallet/src/app/pages/AllTransactions.tsx index cabba1faa..c768e1fd9 100644 --- a/cmd/rpc/web/wallet/src/app/pages/AllTransactions.tsx +++ b/cmd/rpc/web/wallet/src/app/pages/AllTransactions.tsx @@ -3,6 +3,7 @@ import { motion } from "framer-motion"; import { Search, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; import { useDashboard } from "@/hooks/useDashboard"; import { useConfig } from "@/app/providers/ConfigProvider"; +import { useAccountsList } from "@/app/providers/AccountsProvider"; import { LucideIcon } from "@/components/ui/LucideIcon"; import { TransactionDetailModal, type TxDetail } from "@/components/transactions/TransactionDetailModal"; @@ -162,15 +163,14 @@ export const AllTransactions = () => { const { allTxs, isTxLoading, - hasMoreTxs, - isFetchingMoreTxs, - fetchMoreTxs, } = useDashboard(); const { manifest, chain } = useConfig(); + const { accounts } = useAccountsList(); const [searchTerm, setSearchTerm] = useState(""); const [filterType, setFilterType] = useState("all"); const [filterStatus, setFilterStatus] = useState("all"); + const [filterAccount, setFilterAccount] = useState("all"); const [selectedTx, setSelectedTx] = useState(null); const [currentPage, setCurrentPage] = useState(1); @@ -212,17 +212,19 @@ export const AllTransactions = () => { getTxMap(tx.type).toLowerCase().includes(searchTerm.toLowerCase()); const matchesType = filterType === "all" || tx.type === filterType; const matchesStatus = filterStatus === "all" || tx.status === filterStatus; - return matchesSearch && matchesType && matchesStatus; + const matchesAccount = + filterAccount === "all" || + (tx.relatedAccounts?.includes(filterAccount) ?? false); + return matchesSearch && matchesType && matchesStatus && matchesAccount; }); - }, [allTxs, searchTerm, filterType, filterStatus, getTxMap]); + }, [allTxs, searchTerm, filterType, filterStatus, filterAccount, getTxMap]); // Total pages for current filter const totalPages = Math.max(1, Math.ceil(filteredTransactions.length / ITEMS_PER_PAGE)); - // Reset to page 1 whenever filters change useEffect(() => { setCurrentPage(1); - }, [searchTerm, filterType, filterStatus]); + }, [searchTerm, filterType, filterStatus, filterAccount]); // Current page slice const from = (currentPage - 1) * ITEMS_PER_PAGE; @@ -274,9 +276,9 @@ export const AllTransactions = () => { {/* Filters */}
-
+
{/* Search */} -
+
{ />
+ {/* Account filter */} + + {/* Type filter */} + + + + + + {accounts.map((account) => ( + + {account.nickname + ? `${account.nickname} (${shortHex(account.address)})` + : shortHex(account.address)} + + ))} + +
-
- } - /> - } - /> - } - /> - } - /> +
+
-
-
-

Cross-Chain Orderbook

-

- Seller and buyer lifecycle on committee orders: create, lock, reprice, void, and close. -

-
-
- - -
+

Committee Orderbook

+

+ Seller and buyer lifecycle on committee orders: create, lock, reprice, void, and close. +

+
+ } + variant="default" + disabled={!selectedAddress} + onClick={() => runAction(ACTION_IDS.createOrder, { + ...prefill, + receiveAddress: selectedAddress || "", + })} + /> + } + disabled={!selectedAddress} + onClick={() => runAction(ACTION_IDS.repriceOrder, prefill)} + /> + } + disabled={!selectedAddress} + onClick={() => runAction(ACTION_IDS.voidOrder, prefill)} + /> + } + disabled={!selectedAddress} + onClick={() => runAction(ACTION_IDS.lockOrder, { + ...prefill, + receiveAddress: selectedAddress || "", + })} + /> + } + disabled={!selectedAddress} + onClick={() => runAction(ACTION_IDS.closeOrder, prefill)} + />
- - - runAction(ACTION_IDS.createOrder, { - address: selectedAddress || "", - receiveAddress: selectedAddress || "", - committees: String(committeeId ?? ""), - }) - } - disabled={!selectedAddress} - > - - Create Order - - } - > - - - - - } - /> - -
- - - - - - - - - - - - - - {myOrders.map((order) => { - const locked = isOrderLocked(order); - return ( - - - - - - - - - - ); - })} - -
Order IDTypeCommitteeAmount For SaleRequestedStatusActions
- {shortHex(order.id, 8, 6)} - - - {order.committee} - {formatAmount(order.amountForSale, decimals, symbol)} - - {formatAmount(order.requestedAmount, decimals)} - - - - {!locked ? ( -
- - -
- ) : ( - Waiting buyer close - )} -
-
-
-
- - - } - /> - -
- - - - - - - - - - - - - - {visibleAvailableOrders.map((order) => ( - - - - - - - - - - ))} - -
Order IDTypeSellerFor SaleRequestedReceive AddressAction
- {shortHex(order.id, 8, 6)} - - - - {shortHex(order.sellersSendAddress, 8, 6)} - - {formatAmount(order.amountForSale, decimals, symbol)} - - {formatAmount(order.requestedAmount, decimals)} - - {shortHex(order.sellerReceiveAddress, 8, 6)} - - -
-
-
-
- - - - - {nextFulfillDeadline ? ( - - ) : null} - - } - /> - -
- - - - - - - - - - - - - - - {fulfillOrders.map((order) => ( - - - - - - - - - - - ))} - -
Order IDTypeSellerAmount To ReceiveAmount To SendDeadlineSend ToAction
- {shortHex(order.id, 8, 6)} - - - - {shortHex(order.sellersSendAddress, 8, 6)} - - {formatAmount(order.amountForSale, decimals, symbol)} - - {formatAmount(order.requestedAmount, decimals)} - - {asNumber(order.buyerChainDeadline || 0).toLocaleString()} - - {shortHex(order.sellerReceiveAddress, 8, 6)} - - -
-
-
-
-
diff --git a/cmd/rpc/web/wallet/src/app/pages/Staking.tsx b/cmd/rpc/web/wallet/src/app/pages/Staking.tsx index 2b970c157..14b5929ae 100644 --- a/cmd/rpc/web/wallet/src/app/pages/Staking.tsx +++ b/cmd/rpc/web/wallet/src/app/pages/Staking.tsx @@ -23,9 +23,7 @@ type ValidatorRow = { stakedAmount: number; status: "Staked" | "Paused" | "Unstaking" | "Delegate"; rewards24h: number; - chains?: string[]; isSynced: boolean; - // Additional validator information committees?: number[]; compound?: boolean; delegate?: boolean; @@ -36,8 +34,6 @@ type ValidatorRow = { unstakingHeight?: number; }; -const chainLabels = ["DEX", "CAN"] as const; - const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: 0.6, staggerChildren: 0.1 } }, @@ -96,27 +92,25 @@ export default function Staking(): JSX.Element { // 🧮 Construir filas memoizadas const rows: ValidatorRow[] = useMemo(() => { - return validators.map((v: any) => ({ - address: v.address, - nickname: v.nickname, - stakedAmount: v.stakedAmount || 0, - status: v.unstaking ? "Unstaking" : v.paused ? "Paused" : v.delegate ? "Delegate" : "Staked", - rewards24h: rewardsHistory[v.address]?.rewards24h || 0, - chains: - v.committees?.map( - (id: number) => chainLabels[id % chainLabels.length], - ) || [], - isSynced: !v.paused, - // Additional info - committees: v.committees, - compound: v.compound, - delegate: v.delegate, - maxPausedHeight: v.maxPausedHeight, - netAddress: v.netAddress, - output: v.output, - publicKey: v.publicKey, - unstakingHeight: v.unstakingHeight, - })); + return validators.map((v) => { + const extra = v as unknown as Record; + return { + address: v.address, + nickname: v.nickname, + stakedAmount: v.stakedAmount || 0, + status: (v.unstaking ? "Unstaking" : v.paused ? "Paused" : v.delegate ? "Delegate" : "Staked") as ValidatorRow["status"], + rewards24h: rewardsHistory[v.address]?.rewards24h || 0, + isSynced: !v.paused, + committees: extra.committees as number[] | undefined, + compound: extra.compound as boolean | undefined, + delegate: v.delegate, + maxPausedHeight: extra.maxPausedHeight as number | undefined, + netAddress: extra.netAddress as string | undefined, + output: extra.output as string | undefined, + publicKey: v.publicKey, + unstakingHeight: v.unstakingHeight, + }; + }); }, [validators, rewardsHistory]); const filtered: ValidatorRow[] = useMemo(() => { @@ -133,18 +127,30 @@ export default function Staking(): JSX.Element { const header = [ "address", "nickname", + "publicKey", "stakedAmount", "rewards24h", "status", + "netAddress", + "output", + "compound", + "committees", + "unstakingHeight", ]; const lines = [header.join(",")].concat( filtered.map((r) => [ r.address, r.nickname || "", + r.publicKey || "", r.stakedAmount, r.rewards24h, r.status, + r.netAddress || "", + r.output || "", + String(r.compound ?? ""), + r.committees?.join(";") ?? "", + r.unstakingHeight ?? "", ].join(","), ), ); diff --git a/cmd/rpc/web/wallet/src/components/dashboard/AllAddressesCard.tsx b/cmd/rpc/web/wallet/src/components/dashboard/AllAddressesCard.tsx index 47d379349..fd3c8bc69 100644 --- a/cmd/rpc/web/wallet/src/components/dashboard/AllAddressesCard.tsx +++ b/cmd/rpc/web/wallet/src/components/dashboard/AllAddressesCard.tsx @@ -2,8 +2,8 @@ import React, { useMemo, useCallback } from 'react'; import { motion } from 'framer-motion'; import { ChevronRight, WalletCards} from 'lucide-react'; import { useAccountData } from '@/hooks/useAccountData'; -import { useAccountsList } from '@/app/providers/AccountsProvider'; -import { NavLink } from 'react-router-dom'; +import { useAccountsList, useSelectedAccount } from '@/app/providers/AccountsProvider'; +import { NavLink, useNavigate } from 'react-router-dom'; import { StatusBadge } from '@/components/ui/StatusBadge'; import { LoadingState } from '@/components/ui/LoadingState'; import { EmptyState } from '@/components/ui/EmptyState'; @@ -18,12 +18,13 @@ interface AddressData { status: string; } -const AddressRow = React.memo<{ address: AddressData; index: number }>(({ address, index }) => ( +const AddressRow = React.memo<{ address: AddressData; index: number; onClick?: () => void }>(({ address, index, onClick }) => (
@@ -47,6 +48,8 @@ AddressRow.displayName = 'AddressRow'; export const AllAddressesCard = React.memo(() => { const { accounts, loading: accountsLoading } = useAccountsList(); + const { switchAccount } = useSelectedAccount(); + const navigate = useNavigate(); const { balances, stakingData, loading: dataLoading } = useAccountData(); const formatBalance = useCallback((amount: number) => (amount / 1_000_000).toFixed(2), []); @@ -112,7 +115,15 @@ export const AllAddressesCard = React.memo(() => {
{processedAddresses.length > 0 ? ( processedAddresses.slice(0, 4).map((address, index) => ( - + { + switchAccount(address.id); + navigate('/accounts'); + }} + /> )) ) : ( diff --git a/cmd/rpc/web/wallet/src/components/dashboard/RecentTransactionsCard.tsx b/cmd/rpc/web/wallet/src/components/dashboard/RecentTransactionsCard.tsx index 0aa79a1eb..2bfc484bb 100644 --- a/cmd/rpc/web/wallet/src/components/dashboard/RecentTransactionsCard.tsx +++ b/cmd/rpc/web/wallet/src/components/dashboard/RecentTransactionsCard.tsx @@ -24,6 +24,7 @@ export interface Transaction { status: string; address?: string; error?: TxError; + relatedAccounts?: string[]; } export interface RecentTransactionsCardProps { diff --git a/cmd/rpc/web/wallet/src/components/layouts/TopBar.tsx b/cmd/rpc/web/wallet/src/components/layouts/TopBar.tsx index 361b7b52f..2f1c7f212 100644 --- a/cmd/rpc/web/wallet/src/components/layouts/TopBar.tsx +++ b/cmd/rpc/web/wallet/src/components/layouts/TopBar.tsx @@ -2,21 +2,11 @@ import React from 'react'; import { motion } from 'framer-motion'; import { Link } from 'react-router-dom'; import { Key, Blocks } from 'lucide-react'; -import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/Select'; -import { useAccounts } from '@/app/providers/AccountsProvider'; import { useTotalStage } from '@/hooks/useTotalStage'; import { useDS } from '@/core/useDs'; import AnimatedNumber from '@/components/ui/AnimatedNumber'; export const TopBar = (): JSX.Element => { - const { - accounts, - loading, - error: hasErrorInAccounts, - switchAccount, - selectedAccount, - } = useAccounts(); - const { data: totalStage, isLoading: stageLoading } = useTotalStage(); const { data: blockHeight } = useDS<{ height: number }>('height', {}, { staleTimeMs: 10_000, @@ -62,54 +52,6 @@ export const TopBar = (): JSX.Element => {
- - {currentNode?.name || "Current Node"}

- {currentNode?.netAddress && ( -

{currentNode.netAddress}

- )} +

+ {truncate(nodeStatus.nodeAddress)} +

@@ -75,7 +75,7 @@ export default function NodeStatus({ {/* Status bar */}
{/* Sync */} @@ -106,13 +106,24 @@ export default function NodeStatus({ {nodeStatus.syncProgress}%
- {/* Address */} + {/* Node Address */}
Node Address {truncate(nodeStatus.nodeAddress)}
+ + {/* Net Address */} +
+ + + Net Address + + + {currentNode?.netAddress || "N/A"} + +
); diff --git a/cmd/rpc/web/wallet/src/components/staking/ValidatorCard.tsx b/cmd/rpc/web/wallet/src/components/staking/ValidatorCard.tsx index 0877af686..e180a3570 100644 --- a/cmd/rpc/web/wallet/src/components/staking/ValidatorCard.tsx +++ b/cmd/rpc/web/wallet/src/components/staking/ValidatorCard.tsx @@ -1,10 +1,9 @@ import React from "react"; import { motion } from "framer-motion"; -import { useManifest } from "@/hooks/useManifest"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { useValidatorRewardsHistory } from "@/hooks/useValidatorRewardsHistory"; import { useActionModal } from "@/app/providers/ActionModalProvider"; -import {LockOpen, Pause, Pen, Play} from "lucide-react"; +import { LockOpen, Pause, Pen, Play, Globe, Key, Copy } from "lucide-react"; interface ValidatorCardProps { validator: { @@ -13,9 +12,12 @@ interface ValidatorCardProps { stakedAmount: number; status: "Staked" | "Paused" | "Unstaking" | "Delegate"; rewards24h: number; - committees?: string[]; + committees?: number[]; isSynced: boolean; delegate?: boolean; + netAddress?: string; + publicKey?: string; + output?: string; }; index: number; } @@ -48,7 +50,6 @@ export const ValidatorCard: React.FC = ({ const { copyToClipboard } = useCopyToClipboard(); const { openAction } = useActionModal(); - // Fetch real rewards data using block height comparison const { data: rewardsHistory, isLoading: rewardsLoading } = useValidatorRewardsHistory(validator.address); @@ -86,9 +87,8 @@ export const ValidatorCard: React.FC = ({ className="bg-card rounded-xl border border-border/60 relative overflow-hidden" >
- {/* Grid layout for responsive design */}
- {/* Validator identity - takes 3 columns on large screens */} + {/* Validator identity */}
@@ -114,28 +114,59 @@ export const ValidatorCard: React.FC = ({ Copy - {/* Chain badges */} -
- {(validator.committees || []).slice(0, 2).map((chain, i) => ( - - {chain} + {/* Public Key */} + {validator.publicKey && ( +
+ + + {truncateAddress(validator.publicKey)} - ))} - {(validator.committees || []).length > 2 && ( - - +{(validator.committees || []).length - 2} more + +
+ )} + + {/* Net Address */} + {validator.netAddress && ( +
+ + + {validator.netAddress} - )} -
+ +
+ )} + + {/* Committees */} + {(validator.committees?.length ?? 0) > 0 && ( +
+ Committees: + {validator.committees!.map((id) => ( + + {id} + + ))} +
+ )}
- {/* Stats section - responsive grid */} + {/* Stats section */}
- {/* Total Staked */}
{formatStakedAmount(validator.stakedAmount)} CNPY @@ -143,7 +174,6 @@ export const ValidatorCard: React.FC = ({
Total Staked
- {/* 24h Rewards */}
{rewardsLoading @@ -154,9 +184,8 @@ export const ValidatorCard: React.FC = ({
- {/* Status and Actions - takes 3 columns on large screens */} + {/* Status and Actions */}
- {/* Status badges */}
= ({ >
- {/* Action buttons */} {validator.status !== "Unstaking" && (
- + + {validator.status === 'Paused' ? 'Resume' : 'Pause'} + + + )}
)} diff --git a/cmd/rpc/web/wallet/src/components/staking/ValidatorList.tsx b/cmd/rpc/web/wallet/src/components/staking/ValidatorList.tsx index ea8685f7a..8ffb0bad5 100644 --- a/cmd/rpc/web/wallet/src/components/staking/ValidatorList.tsx +++ b/cmd/rpc/web/wallet/src/components/staking/ValidatorList.tsx @@ -8,9 +8,12 @@ interface Validator { stakedAmount: number; status: 'Staked' | 'Paused' | 'Unstaking' | 'Delegate'; rewards24h: number; - chains?: string[]; + committees?: number[]; isSynced: boolean; delegate?: boolean; + netAddress?: string; + publicKey?: string; + output?: string; } interface ValidatorListProps { diff --git a/cmd/rpc/web/wallet/src/core/fees.ts b/cmd/rpc/web/wallet/src/core/fees.ts index 807466a89..ea62fc6d1 100644 --- a/cmd/rpc/web/wallet/src/core/fees.ts +++ b/cmd/rpc/web/wallet/src/core/fees.ts @@ -7,7 +7,7 @@ export type FeeBuckets = Record< >; export type FeeProviderQuery = { type: "query"; - base: "rpc" | "admin" | "root"; + base: "rpc" | "admin"; path: string; method?: "GET" | "POST"; encoding?: "json" | "text"; @@ -154,5 +154,5 @@ export function useResolvedFees( return applyBucket(base, bucketDef); }, [raw, opts.actionId, opts.bucket, buckets]); - return { raw, amount, denom }; + return useMemo(() => ({ raw, amount, denom }), [raw, amount, denom]); } diff --git a/cmd/rpc/web/wallet/src/core/rpcHost.ts b/cmd/rpc/web/wallet/src/core/rpcHost.ts index 3329232b5..9afcf2e7b 100644 --- a/cmd/rpc/web/wallet/src/core/rpcHost.ts +++ b/cmd/rpc/web/wallet/src/core/rpcHost.ts @@ -1,15 +1,11 @@ -export type RpcBase = "rpc" | "admin" | "root"; +export type RpcBase = "rpc" | "admin"; export function resolveRpcHost(chain: any, base: RpcBase = "rpc"): string { if (!chain?.rpc) return ""; if (base === "admin") { - return chain.rpc.admin ?? chain.rpc.base ?? chain.rpc.root ?? ""; + return chain.rpc.admin ?? chain.rpc.base ?? ""; } - if (base === "root") { - return chain.rpc.root ?? chain.rpc.base ?? ""; - } - - return chain.rpc.base ?? chain.rpc.root ?? ""; + return chain.rpc.base ?? ""; } diff --git a/cmd/rpc/web/wallet/src/hooks/useBalanceHistory.ts b/cmd/rpc/web/wallet/src/hooks/useBalanceHistory.ts index 9b5997fdb..af3c0ca5a 100644 --- a/cmd/rpc/web/wallet/src/hooks/useBalanceHistory.ts +++ b/cmd/rpc/web/wallet/src/hooks/useBalanceHistory.ts @@ -1,10 +1,10 @@ import { useQuery } from '@tanstack/react-query' import { useDSFetcher } from '@/core/dsFetch' import { useHistoryCalculation, HistoryResult } from './useHistoryCalculation' -import {useAccounts} from "@/app/providers/AccountsProvider"; +import { useAccountsList } from "@/app/providers/AccountsProvider"; export function useBalanceHistory() { - const { accounts, loading: accountsLoading } = useAccounts() + const { accounts, loading: accountsLoading } = useAccountsList() const addresses = accounts.map(a => a.address).filter(Boolean) const dsFetch = useDSFetcher() const { currentHeight, height24hAgo, calculateHistory, isReady } = useHistoryCalculation() @@ -21,21 +21,38 @@ export function useBalanceHistory() { return { current: 0, previous24h: 0, change24h: 0, changePercentage: 0, progressPercentage: 0 } } - // Fetch current and previous balances in parallel - const currentPromises = addresses.map(address => - dsFetch('accountByHeight', { address: address, height: currentHeight }) - ) - const previousPromises = addresses.map(address => - dsFetch('accountByHeight', { address, height: height24hAgo }) - ) + const fetchBalance = async (address: string, height: number): Promise => { + try { + const result = await dsFetch('accountByHeight', { address, height }) + return typeof result === 'number' && Number.isFinite(result) ? result : 0 + } catch { + return 0 + } + } const [currentBalances, previousBalances] = await Promise.all([ - Promise.all(currentPromises), - Promise.all(previousPromises), + Promise.all(addresses.map(addr => fetchBalance(addr, currentHeight))), + Promise.all(addresses.map(addr => fetchBalance(addr, height24hAgo))), ]) - const currentTotal = currentBalances.reduce((sum: any, v: any) => sum + (v || 0), 0) - const previousTotal = previousBalances.reduce((sum: any, v: any) => sum + (v || 0), 0) + const currentTotal = currentBalances.reduce((sum, v) => sum + v, 0) + const previousTotal = previousBalances.reduce((sum, v) => sum + v, 0) + + if (currentTotal === 0 && previousTotal === 0) { + try { + const liveBalances = await Promise.all( + addresses.map(addr => + dsFetch<{ amount?: number }>('account', { account: { address: addr } }) + .then(r => (typeof r === 'number' ? r : Number(r?.amount ?? 0))) + .catch(() => 0) + ) + ) + const liveTotal = liveBalances.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0) + if (liveTotal > 0) { + return calculateHistory(liveTotal, liveTotal) + } + } catch { /* fall through */ } + } return calculateHistory(currentTotal, previousTotal) } diff --git a/cmd/rpc/web/wallet/src/hooks/useDashboard.ts b/cmd/rpc/web/wallet/src/hooks/useDashboard.ts index 676f6156b..f4561764c 100644 --- a/cmd/rpc/web/wallet/src/hooks/useDashboard.ts +++ b/cmd/rpc/web/wallet/src/hooks/useDashboard.ts @@ -1,15 +1,16 @@ -import { useDSInfinite } from "@/core/useDSInfinite"; +import { useQuery } from "@tanstack/react-query"; +import { useDSFetcher } from "@/core/dsFetch"; import React, { useCallback, useMemo } from "react"; import { Transaction } from "@/components/dashboard/RecentTransactionsCard"; -import { useAccounts } from "@/app/providers/AccountsProvider"; +import { useAccounts, useAccountsList } from "@/app/providers/AccountsProvider"; import { useManifest } from "@/hooks/useManifest"; import { Action as ManifestAction } from "@/manifest/types"; -import { useConfig } from "@/app/providers/ConfigProvider"; + const TX_POLL_INTERVAL_MS = 6000; const TX_PER_PAGE = 20; -const parseMemoJson = (memo: unknown): Record | null => { +const parseMemoJson = (memo: unknown): Record | null => { if (typeof memo !== "string" || memo.trim() === "") return null; try { const parsed = JSON.parse(memo); @@ -19,11 +20,12 @@ const parseMemoJson = (memo: unknown): Record | null => { } }; -const inferTxType = (row: any): string => { - const type = String(row?.transaction?.type ?? row?.messageType ?? ""); +const inferTxType = (row: Record): string => { + const txn = row?.transaction as Record | undefined; + const type = String(txn?.type ?? (row as Record)?.messageType ?? ""); if (type && type !== "send") return type; - const memo = parseMemoJson(row?.transaction?.memo); + const memo = parseMemoJson(txn?.memo); if (memo) { if (memo.closeOrder === true && memo.orderId) return "closeOrder"; @@ -52,178 +54,117 @@ const toNumber = (value: unknown): number => { return Number.isFinite(n) ? n : 0; }; +const makeTx = ( + i: Record, + overrides?: { type?: string; status?: string }, +): Transaction => { + const txn = i.transaction as Record | undefined; + const msg = txn?.msg as Record | undefined; + return { + hash: String(i.txHash ?? i.hash ?? ""), + type: overrides?.type ?? inferTxType(i), + amount: toNumber(msg?.amount), + fee: txn?.fee as number | undefined, + status: normalizeStatus(overrides?.status ?? txn?.status, "Confirmed"), + time: toNumber(txn?.time ?? i.time), + address: (i.address ?? i.sender) as string | undefined, + error: i.error as Transaction["error"], + }; +}; + +interface TxPage { + results?: Record[]; + txs?: Record[]; + transactions?: Record[]; + data?: Record[]; + totalCount?: number; + totalPages?: number; + paging?: { totalPages?: number }; + [key: string]: unknown; +} + +function extractItems(raw: TxPage | Record[] | null): Record[] { + if (!raw) return []; + if (Array.isArray(raw)) return raw; + if (Array.isArray(raw.results)) return raw.results; + if (Array.isArray(raw.txs)) return raw.txs; + if (Array.isArray(raw.transactions)) return raw.transactions; + if (Array.isArray(raw.data)) return raw.data; + return []; +} + export const useDashboard = () => { const [isActionModalOpen, setIsActionModalOpen] = React.useState(false); const [selectedActions, setSelectedActions] = React.useState([]); - const [prefilledData, setPrefilledData] = React.useState | undefined>(undefined); + const [prefilledData, setPrefilledData] = React.useState | undefined>(undefined); const { manifest, loading: manifestLoading } = useManifest(); - const { chain } = useConfig(); - const { selectedAddress, isReady: isAccountReady } = useAccounts(); - - const hasDistinctRootRpc = useMemo(() => { - const base = String(chain?.rpc?.base ?? "").trim(); - const root = String(chain?.rpc?.root ?? "").trim(); - return !!root && root !== base; - }, [chain?.rpc?.base, chain?.rpc?.root]); - - const txCtx = { account: { address: selectedAddress } }; - - const txSentQuery = useDSInfinite("txs.sent", txCtx, { - enabled: !!selectedAddress && isAccountReady, - refetchIntervalMs: TX_POLL_INTERVAL_MS, - perPage: TX_PER_PAGE, - }); - - const txReceivedQuery = useDSInfinite("txs.received", txCtx, { - enabled: !!selectedAddress && isAccountReady, - refetchIntervalMs: TX_POLL_INTERVAL_MS, - perPage: TX_PER_PAGE, - }); - - const txFailedQuery = useDSInfinite("txs.failed", txCtx, { - enabled: !!selectedAddress && isAccountReady, - refetchIntervalMs: TX_POLL_INTERVAL_MS, - perPage: TX_PER_PAGE, - }); - - const txRootSentQuery = useDSInfinite("txs.root.sent", txCtx, { - enabled: !!selectedAddress && isAccountReady && hasDistinctRootRpc, - refetchIntervalMs: TX_POLL_INTERVAL_MS, - perPage: TX_PER_PAGE, - }); - - const txRootReceivedQuery = useDSInfinite("txs.root.received", txCtx, { - enabled: !!selectedAddress && isAccountReady && hasDistinctRootRpc, - refetchIntervalMs: TX_POLL_INTERVAL_MS, - perPage: TX_PER_PAGE, - }); - - const txRootFailedQuery = useDSInfinite("txs.root.failed", txCtx, { - enabled: !!selectedAddress && isAccountReady && hasDistinctRootRpc, - refetchIntervalMs: TX_POLL_INTERVAL_MS, - perPage: TX_PER_PAGE, + const { isReady: isAccountReady } = useAccounts(); + const { accounts, loading: accountsLoading } = useAccountsList(); + const dsFetch = useDSFetcher(); + + const allAddresses = useMemo( + () => accounts.map((a) => a.address).filter(Boolean), + [accounts], + ); + + const addressesKey = useMemo( + () => allAddresses.sort().join(","), + [allAddresses], + ); + + const txQuery = useQuery({ + queryKey: ["dashboard.allTxs", addressesKey], + enabled: !accountsLoading && allAddresses.length > 0 && isAccountReady, + staleTime: TX_POLL_INTERVAL_MS, + refetchInterval: TX_POLL_INTERVAL_MS, + queryFn: async (): Promise => { + const byHash = new Map(); + + const upsertTx = (tx: Transaction, account: string) => { + if (!tx.hash) return; + const existing = byHash.get(tx.hash); + if (existing) { + const accts = existing.relatedAccounts ?? []; + if (!accts.includes(account)) accts.push(account); + existing.relatedAccounts = accts; + } else { + tx.relatedAccounts = [account]; + byHash.set(tx.hash, tx); + } + }; + + const fetchForAddress = async (address: string) => { + const ctx = { account: { address }, page: 1, perPage: TX_PER_PAGE }; + + const [sent, received, failed] = await Promise.all([ + dsFetch("txs.sent", ctx).catch(() => null), + dsFetch("txs.received", ctx).catch(() => null), + dsFetch("txs.failed", ctx).catch(() => null), + ]); + + for (const item of extractItems(received as TxPage | null)) { + upsertTx(makeTx(item, { type: "receive" }), address); + } + for (const item of extractItems(sent as TxPage | null)) { + upsertTx(makeTx(item), address); + } + for (const item of extractItems(failed as TxPage | null)) { + upsertTx(makeTx(item, { status: "Failed" }), address); + } + }; + + await Promise.all(allAddresses.map(fetchForAddress)); + + return Array.from(byHash.values()).sort((a, b) => b.time - a.time); + }, }); - const isTxLoading = - txSentQuery.isLoading || - txReceivedQuery.isLoading || - txFailedQuery.isLoading || - (hasDistinctRootRpc && - (txRootSentQuery.isLoading || - txRootReceivedQuery.isLoading || - txRootFailedQuery.isLoading)); - - const hasMoreTxs = - (txSentQuery.hasNextPage ?? false) || - (txReceivedQuery.hasNextPage ?? false) || - (txFailedQuery.hasNextPage ?? false) || - (txRootSentQuery.hasNextPage ?? false) || - (txRootReceivedQuery.hasNextPage ?? false) || - (txRootFailedQuery.hasNextPage ?? false); - - const isFetchingMoreTxs = - txSentQuery.isFetchingNextPage || - txReceivedQuery.isFetchingNextPage || - txFailedQuery.isFetchingNextPage || - txRootSentQuery.isFetchingNextPage || - txRootReceivedQuery.isFetchingNextPage || - txRootFailedQuery.isFetchingNextPage; - - const fetchMoreTxs = useCallback(async () => { - const promises: Promise[] = []; - - if (txSentQuery.hasNextPage) promises.push(txSentQuery.fetchNextPage()); - if (txReceivedQuery.hasNextPage) promises.push(txReceivedQuery.fetchNextPage()); - if (txFailedQuery.hasNextPage) promises.push(txFailedQuery.fetchNextPage()); - - if (txRootSentQuery.hasNextPage) promises.push(txRootSentQuery.fetchNextPage()); - if (txRootReceivedQuery.hasNextPage) promises.push(txRootReceivedQuery.fetchNextPage()); - if (txRootFailedQuery.hasNextPage) promises.push(txRootFailedQuery.fetchNextPage()); - - if (promises.length > 0) await Promise.all(promises); - }, [ - txSentQuery, - txReceivedQuery, - txFailedQuery, - txRootSentQuery, - txRootReceivedQuery, - txRootFailedQuery, - ]); - - const serverTotalCount = useMemo(() => { - const localRaw = txSentQuery.data?.pages?.[0]?.raw; - const rootRaw = txRootSentQuery.data?.pages?.[0]?.raw; - const localCount = typeof localRaw?.totalCount === "number" ? localRaw.totalCount : undefined; - const rootCount = typeof rootRaw?.totalCount === "number" ? rootRaw.totalCount : undefined; - - if (typeof localCount === "number" && typeof rootCount === "number") { - return Math.max(localCount, rootCount); - } - return localCount ?? rootCount; - }, [txSentQuery.data, txRootSentQuery.data]); - - const allTxs = useMemo(() => { - const makeTx = ( - i: any, - overrides?: { - type?: string; - status?: string; - }, - ): Transaction => ({ - hash: String(i.txHash ?? i.hash ?? ""), - type: overrides?.type ?? inferTxType(i), - amount: toNumber(i.transaction?.msg?.amount), - fee: i.transaction?.fee, - status: normalizeStatus(overrides?.status ?? i.transaction?.status, "Confirmed"), - time: toNumber(i.transaction?.time ?? i.time), - address: i.address ?? i.sender, - error: i.error ?? undefined, - }); - - const localReceived = (txReceivedQuery.data?.pages.flatMap((p) => p.items) ?? []).map((i) => - makeTx(i, { type: "receive" }), - ); - const localSent = (txSentQuery.data?.pages.flatMap((p) => p.items) ?? []).map((i) => makeTx(i)); - const localFailed = (txFailedQuery.data?.pages.flatMap((p) => p.items) ?? []).map((i) => - makeTx(i, { status: "Failed" }), - ); - - const rootReceived = (txRootReceivedQuery.data?.pages.flatMap((p) => p.items) ?? []).map((i) => - makeTx(i, { type: "receive" }), - ); - const rootSent = (txRootSentQuery.data?.pages.flatMap((p) => p.items) ?? []).map((i) => makeTx(i)); - const rootFailed = (txRootFailedQuery.data?.pages.flatMap((p) => p.items) ?? []).map((i) => - makeTx(i, { status: "Failed" }), - ); - - // Deduplicate by hash. Priority (last write wins): failed > sent > received. - const byHash = new Map(); - for (const tx of [ - ...localReceived, - ...rootReceived, - ...localSent, - ...rootSent, - ...localFailed, - ...rootFailed, - ]) { - if (tx.hash) byHash.set(tx.hash, tx); - } - - return Array.from(byHash.values()).sort((a, b) => b.time - a.time); - }, [ - txSentQuery.data, - txReceivedQuery.data, - txFailedQuery.data, - txRootSentQuery.data, - txRootReceivedQuery.data, - txRootFailedQuery.data, - ]); - - const onRunAction = (action: ManifestAction, actionPrefilledData?: Record) => { + const onRunAction = (action: ManifestAction, actionPrefilledData?: Record) => { const actions = [action]; if (action.relatedActions) { - const relatedActions = manifest?.actions.filter((a) => action?.relatedActions?.includes(a.id)); - + const relatedActions = manifest?.actions.filter((a: ManifestAction) => + action?.relatedActions?.includes(a.id), + ); if (relatedActions) actions.push(...relatedActions); } setSelectedActions(actions); @@ -231,7 +172,6 @@ export const useDashboard = () => { setIsActionModalOpen(true); }; - // Clear prefilledData when modal closes const handleCloseModal = React.useCallback(() => { setIsActionModalOpen(false); setPrefilledData(undefined); @@ -244,13 +184,9 @@ export const useDashboard = () => { setSelectedActions, manifest, manifestLoading, - isTxLoading, - allTxs, + isTxLoading: txQuery.isLoading, + allTxs: txQuery.data ?? [], onRunAction, prefilledData, - hasMoreTxs, - isFetchingMoreTxs, - fetchMoreTxs, - serverTotalCount, }; }; diff --git a/cmd/rpc/web/wallet/src/hooks/useOrdersData.ts b/cmd/rpc/web/wallet/src/hooks/useOrdersData.ts deleted file mode 100644 index 1a6e70e7f..000000000 --- a/cmd/rpc/web/wallet/src/hooks/useOrdersData.ts +++ /dev/null @@ -1,176 +0,0 @@ -import React from "react"; -import { useAccounts } from "@/app/providers/AccountsProvider"; -import { useDS } from "@/core/useDs"; - -const DEFAULT_PER_PAGE = 20; -const DEFAULT_POLL_INTERVAL_MS = 6000; - -export type RpcOrder = { - id: string; - committee: number; - data?: string; - amountForSale: number; - requestedAmount: number; - sellerReceiveAddress: string; - buyerSendAddress?: string; - buyerChainDeadline?: number; - sellersSendAddress: string; -}; - -type OrdersResponse = { - pageNumber?: number; - perPage?: number; - results?: RpcOrder[]; - type?: string; - count?: number; - totalPages?: number; - totalCount?: number; -}; - -type AdminConfigResponse = { - chainId?: number | string; -}; - -const toSafeInt = (value: unknown): number | undefined => { - const n = Number(value); - if (!Number.isFinite(n)) return undefined; - return Math.trunc(n); -}; - -const asList = (payload: OrdersResponse | undefined): RpcOrder[] => - Array.isArray(payload?.results) ? payload!.results! : []; - -export const isOrderLocked = (order: RpcOrder): boolean => - !!String(order?.buyerSendAddress ?? "").trim(); - -export function useOrdersData(options?: { - perPage?: number; - pollIntervalMs?: number; -}) { - const perPage = options?.perPage ?? DEFAULT_PER_PAGE; - const pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; - - const { selectedAddress, isReady: accountsReady } = useAccounts(); - const configQ = useDS( - "admin.config", - {}, - { - enabled: accountsReady, - staleTimeMs: 5000, - refetchIntervalMs: 10000, - refetchOnWindowFocus: false, - }, - ); - - const committeeId = React.useMemo( - () => toSafeInt(configQ.data?.chainId), - [configQ.data], - ); - - const hasCommittee = typeof committeeId === "number" && committeeId > 0; - const hasSelectedAddress = !!selectedAddress; - - const myOrdersQ = useDS( - "orders.bySeller", - { - account: { address: selectedAddress }, - page: 1, - perPage, - }, - { - enabled: hasSelectedAddress && accountsReady, - staleTimeMs: pollIntervalMs, - refetchIntervalMs: pollIntervalMs, - refetchOnWindowFocus: false, - }, - ); - - const availableOrdersQ = useDS( - "orders.byCommittee", - { - committee: committeeId, - page: 1, - perPage, - }, - { - enabled: hasCommittee && accountsReady, - staleTimeMs: pollIntervalMs, - refetchIntervalMs: pollIntervalMs, - refetchOnWindowFocus: false, - }, - ); - - const fulfillOrdersQ = useDS( - "orders.byBuyer", - { - account: { address: selectedAddress }, - committee: committeeId, - page: 1, - perPage, - }, - { - enabled: hasSelectedAddress && hasCommittee && accountsReady, - staleTimeMs: pollIntervalMs, - refetchIntervalMs: pollIntervalMs, - refetchOnWindowFocus: false, - }, - ); - - const myOrders = React.useMemo(() => asList(myOrdersQ.data), [myOrdersQ.data]); - - const availableOrders = React.useMemo(() => { - const raw = asList(availableOrdersQ.data); - if (!selectedAddress) return raw.filter((order) => !isOrderLocked(order)); - - return raw.filter((order) => { - const unlocked = !isOrderLocked(order); - const isOwnOrder = - String(order?.sellersSendAddress ?? "").toLowerCase() === - selectedAddress.toLowerCase(); - return unlocked && !isOwnOrder; - }); - }, [availableOrdersQ.data, selectedAddress]); - - const fulfillOrders = React.useMemo( - () => asList(fulfillOrdersQ.data), - [fulfillOrdersQ.data], - ); - - const isLoadingAny = - configQ.isLoading || - (hasSelectedAddress && myOrdersQ.isLoading) || - (hasCommittee && availableOrdersQ.isLoading) || - (hasSelectedAddress && hasCommittee && fulfillOrdersQ.isLoading); - - const hasAnyError = - !!configQ.error || !!myOrdersQ.error || !!availableOrdersQ.error || !!fulfillOrdersQ.error; - - const refetchAll = React.useCallback(async () => { - await Promise.all([ - configQ.refetch(), - myOrdersQ.refetch(), - availableOrdersQ.refetch(), - fulfillOrdersQ.refetch(), - ]); - }, [configQ, myOrdersQ, availableOrdersQ, fulfillOrdersQ]); - - return { - selectedAddress, - committeeId, - hasCommittee, - hasSelectedAddress, - myOrders, - availableOrders, - fulfillOrders, - queries: { - config: configQ, - myOrders: myOrdersQ, - availableOrders: availableOrdersQ, - fulfillOrders: fulfillOrdersQ, - }, - isLoadingAny, - hasAnyError, - refetchAll, - }; -} - diff --git a/cmd/rpc/web/wallet/src/hooks/useValidatorRewardsHistory.ts b/cmd/rpc/web/wallet/src/hooks/useValidatorRewardsHistory.ts index 0c5f274fd..a597e8af7 100644 --- a/cmd/rpc/web/wallet/src/hooks/useValidatorRewardsHistory.ts +++ b/cmd/rpc/web/wallet/src/hooks/useValidatorRewardsHistory.ts @@ -17,26 +17,34 @@ export function useValidatorRewardsHistory(address?: string) { staleTime: 30_000, queryFn: async (): Promise => { - const { events } = await fetchRewardEventsInRange(dsFetch, { - address: address || "", - toHeight: currentHeight, - secondsPerBlock, - hours: 24, - perPage: 100, - maxPages: 100, - }); + try { + const { events } = await fetchRewardEventsInRange(dsFetch, { + address: address || "", + toHeight: currentHeight, + secondsPerBlock, + hours: 24, + perPage: 100, + maxPages: 100, + }); - const rewardsLast24h = sumRewards(events); + const rewardsLast24h = sumRewards(events); - // Return the total as both current and change24h - // This will display the actual rewards earned in the last 24h - return { - current: rewardsLast24h, - previous24h: 0, - change24h: rewardsLast24h, - changePercentage: 0, - progressPercentage: 100 - }; + return { + current: rewardsLast24h, + previous24h: 0, + change24h: rewardsLast24h, + changePercentage: 0, + progressPercentage: 100 + }; + } catch { + return { + current: 0, + previous24h: 0, + change24h: 0, + changePercentage: 0, + progressPercentage: 0 + }; + } } }); } diff --git a/cmd/rpc/web/wallet/src/manifest/loader.ts b/cmd/rpc/web/wallet/src/manifest/loader.ts index 994240547..0f8024087 100644 --- a/cmd/rpc/web/wallet/src/manifest/loader.ts +++ b/cmd/rpc/web/wallet/src/manifest/loader.ts @@ -35,7 +35,6 @@ function applyWindowConfig>(chain: T): T { ...rpc, base: window.__CONFIG__.rpcURL, admin: window.__CONFIG__.adminRPCURL, - root: window.__CONFIG__.rpcURL, }, } } diff --git a/cmd/rpc/web/wallet/src/manifest/types.ts b/cmd/rpc/web/wallet/src/manifest/types.ts index aad5f16b8..2e6396257 100644 --- a/cmd/rpc/web/wallet/src/manifest/types.ts +++ b/cmd/rpc/web/wallet/src/manifest/types.ts @@ -92,7 +92,7 @@ export type Action = { // RPC configuration rpc?: { - base: "rpc" | "admin" | "root"; + base: "rpc" | "admin"; path: string; method: string; payload?: any; @@ -275,7 +275,7 @@ export type UIOp = * =========================== */ export type Submit = { - base: "rpc" | "admin" | "root"; + base: "rpc" | "admin"; path: string; // e.g. '/v1/admin/tx-send' method?: "GET" | "POST"; headers?: Record; @@ -317,7 +317,7 @@ export type FeeBuckets = { export type FeeProviderQuery = { type: "query"; - base: "rpc" | "admin" | "root"; + base: "rpc" | "admin"; path: string; method?: "GET" | "POST"; headers?: Record; @@ -328,7 +328,7 @@ export type FeeProviderQuery = { export type FeeProviderSimulate = { type: "simulate"; - base: "rpc" | "admin" | "root"; + base: "rpc" | "admin"; path: string; method?: "GET" | "POST"; headers?: Record; @@ -339,7 +339,7 @@ export type FeeProviderSimulate = { | { type: "static"; value: string } | { type: "query"; - base: "rpc" | "admin" | "root"; + base: "rpc" | "admin"; path: string; selector?: string; }; diff --git a/fsm/key.go b/fsm/key.go index 1df6d29d9..a31d5fb43 100644 --- a/fsm/key.go +++ b/fsm/key.go @@ -43,9 +43,6 @@ var ( orderBookPrefix = []byte{13} // store key prefix for 'sell orders' before they are bid on retiredCommitteePrefix = []byte{14} // store key prefix for 'retired' (dead) committees dexPrefix = []byte{15} // store key prefix for 'dex' functionality - orderBySellerPrefix = []byte{16} // store key prefix for 'sell orders' indexed by seller address - orderByBuyerPrefix = []byte{17} // store key prefix for 'sell orders' indexed by buyer address - lockedBatchSegment = []byte{1} nextBatchSement = []byte{2} ) @@ -81,25 +78,6 @@ func KeyForOrder(chainId uint64, orderId []byte) []byte { return append(OrderBookPrefix(chainId), lib.JoinLenPrefix(orderId)...) } -func OrderBySellerPrefix(seller []byte) []byte { - return lib.JoinLenPrefix(orderBySellerPrefix, seller) -} -func OrderBySellerAndChainPrefix(seller []byte, chainId uint64) []byte { - return append(OrderBySellerPrefix(seller), lib.JoinLenPrefix(formatUint64(chainId))...) -} -func KeyForOrderBySeller(seller []byte, chainId uint64, orderId []byte) []byte { - return append(OrderBySellerAndChainPrefix(seller, chainId), lib.JoinLenPrefix(orderId)...) -} - -func OrderByBuyerPrefix(buyer []byte) []byte { - return lib.JoinLenPrefix(orderByBuyerPrefix, buyer) -} -func OrderByBuyerAndChainPrefix(buyer []byte, chainId uint64) []byte { - return append(OrderByBuyerPrefix(buyer), lib.JoinLenPrefix(formatUint64(chainId))...) -} -func KeyForOrderByBuyer(buyer []byte, chainId uint64, orderId []byte) []byte { - return append(OrderByBuyerAndChainPrefix(buyer, chainId), lib.JoinLenPrefix(orderId)...) -} func KeyForUnstaking(height uint64, address crypto.AddressI) []byte { return append(UnstakingPrefix(height), lib.JoinLenPrefix(address.Bytes())...) } diff --git a/fsm/swap.go b/fsm/swap.go index 666d19654..3fc7e74da 100644 --- a/fsm/swap.go +++ b/fsm/swap.go @@ -2,7 +2,6 @@ package fsm import ( "bytes" - "encoding/binary" "encoding/json" "math" "sort" @@ -324,74 +323,21 @@ func (s *StateMachine) CloseOrder(orderId []byte, chainId uint64) (err lib.Error // SetOrder() sets the sell order in state func (s *StateMachine) SetOrder(order *lib.SellOrder, chainId uint64) (err lib.ErrorI) { - // clean up stale buyer index if buyer changed or was removed - if err = s.cleanupStaleBuyerIndex(order, chainId); err != nil { - return - } - // convert the order into proto bytes protoBytes, err := s.marshalOrder(order) if err != nil { return } - // set the order book in state if err = s.Set(KeyForOrder(chainId, order.Id), protoBytes); err != nil { return } - // set the secondary index for seller address lookup (value is empty, key is sufficient) - if err = s.Set(KeyForOrderBySeller(order.SellersSendAddress, chainId, order.Id), []byte{}); err != nil { - return - } - // set the secondary index for buyer address lookup if buyer exists (locked order) - if len(order.BuyerSendAddress) > 0 { - if err = s.Set(KeyForOrderByBuyer(order.BuyerSendAddress, chainId, order.Id), []byte{}); err != nil { - return - } - } return } -// cleanupStaleBuyerIndex() removes the old buyer index entry if the buyer changed or was removed -func (s *StateMachine) cleanupStaleBuyerIndex(order *lib.SellOrder, chainId uint64) lib.ErrorI { - // check if order already exists - existingOrder, err := s.GetOrder(order.Id, chainId) - // if order not found, nothing to clean up - if err != nil && err.Code() == lib.CodeOrderNotFound { - return nil - } - // if other error, return it - if err != nil { - return err - } - // if existing order has a buyer and it differs from the new order's buyer, clean up the old index - if existingOrder != nil && len(existingOrder.BuyerSendAddress) > 0 { - if !bytes.Equal(existingOrder.BuyerSendAddress, order.BuyerSendAddress) { - return s.Delete(KeyForOrderByBuyer(existingOrder.BuyerSendAddress, chainId, order.Id)) - } - } - return nil -} - // DeleteOrder() deletes an existing order in the order book for a committee in the state db func (s *StateMachine) DeleteOrder(orderId []byte, chainId uint64) (err lib.ErrorI) { - // get the order first to retrieve addresses for index cleanup - order, err := s.GetOrder(orderId, chainId) - if err != nil { - return - } - // delete the primary order entry if err = s.Delete(KeyForOrder(chainId, orderId)); err != nil { return } - // delete the seller secondary index entry - if err = s.Delete(KeyForOrderBySeller(order.SellersSendAddress, chainId, orderId)); err != nil { - return - } - // delete the buyer secondary index entry if buyer exists - if len(order.BuyerSendAddress) > 0 { - if err = s.Delete(KeyForOrderByBuyer(order.BuyerSendAddress, chainId, orderId)); err != nil { - return - } - } return } @@ -520,157 +466,16 @@ func (s *StateMachine) GetOrderBooks() (b *lib.OrderBooks, err lib.ErrorI) { return } -// GetOrdersBySeller() retrieves all orders for a specific seller address, optionally filtered by chainId -func (s *StateMachine) GetOrdersBySeller(seller []byte, chainId uint64) (b *lib.OrderBooks, err lib.ErrorI) { - b = new(lib.OrderBooks) - // determine the prefix to iterate based on whether chainId filter is provided - var prefix []byte - if chainId == 0 { - prefix = OrderBySellerPrefix(seller) - } else { - prefix = OrderBySellerAndChainPrefix(seller, chainId) - } - // create an iterator over the seller index prefix - it, err := s.Iterator(prefix) - if err != nil { - return - } - defer it.Close() - // map to collect orders by chainId - ordersByChain := make(map[uint64][]*lib.SellOrder) - // for each index entry - for ; it.Valid(); it.Next() { - // extract chainId and orderId from the index key - cId, orderId, e := s.parseOrderBySellerKey(it.Key()) - if e != nil { - s.log.Error(e.Error()) - continue - } - // get the actual order from the primary store - order, e := s.GetOrder(orderId, cId) - if e != nil { - s.log.Error(e.Error()) - continue - } - ordersByChain[cId] = append(ordersByChain[cId], order) - } - // convert map to OrderBooks structure - for cId, orders := range ordersByChain { - b.OrderBooks = append(b.OrderBooks, &lib.OrderBook{ - ChainId: cId, - Orders: orders, - }) - } - // sort by chain id for consistent output - sort.Slice(b.OrderBooks, func(i, j int) bool { - return b.OrderBooks[i].ChainId < b.OrderBooks[j].ChainId - }) - return -} - -// parseOrderBySellerKey() extracts chainId and orderId from an order-by-seller index key -func (s *StateMachine) parseOrderBySellerKey(key []byte) (chainId uint64, orderId []byte, err lib.ErrorI) { - // key format: orderBySellerPrefix + seller + chainId + orderId (all length-prefixed) - segments := lib.DecodeLengthPrefixed(key) - if len(segments) < 4 { - return 0, nil, ErrInvalidKey(key) - } - // segments[0] = prefix, segments[1] = seller, segments[2] = chainId, segments[3] = orderId - chainId = binary.BigEndian.Uint64(segments[2]) - orderId = segments[3] - return -} - -// parseOrderByBuyerKey() extracts chainId and orderId from an order-by-buyer index key -func (s *StateMachine) parseOrderByBuyerKey(key []byte) (chainId uint64, orderId []byte, err lib.ErrorI) { - // key format: orderByBuyerPrefix + buyer + chainId + orderId (all length-prefixed) - segments := lib.DecodeLengthPrefixed(key) - if len(segments) < 4 { - return 0, nil, ErrInvalidKey(key) - } - // segments[0] = prefix, segments[1] = buyer, segments[2] = chainId, segments[3] = orderId - chainId = binary.BigEndian.Uint64(segments[2]) - orderId = segments[3] - return -} - -// GetOrdersPaginated() retrieves orders with optional filters and pagination -// Note: seller and buyer filters are mutually exclusive -func (s *StateMachine) GetOrdersPaginated(seller, buyer []byte, chainId uint64, p lib.PageParams) (*lib.Page, lib.ErrorI) { - // create the page object +// GetOrdersPaginated() retrieves orders with pagination, optionally filtered by chainId +func (s *StateMachine) GetOrdersPaginated(chainId uint64, p lib.PageParams) (*lib.Page, lib.ErrorI) { page := lib.NewPage(p, "orders") results := &lib.SellOrders{} - // determine which query path to use based on filters - if len(seller) > 0 { - // use seller index for efficient lookup - return s.getOrdersBySellerPaginated(seller, chainId, page, results) - } - if len(buyer) > 0 { - // use buyer index for efficient lookup - return s.getOrdersByBuyerPaginated(buyer, chainId, page, results) - } if chainId != 0 { - // filter by chainId only - iterate orders for that chain return s.getOrdersByChainPaginated(chainId, page, results) } - // no filters - iterate all orders across all chains return s.getAllOrdersPaginated(page, results) } -// getOrdersBySellerPaginated() retrieves paginated orders using the seller index -func (s *StateMachine) getOrdersBySellerPaginated(seller []byte, chainId uint64, page *lib.Page, results *lib.SellOrders) (*lib.Page, lib.ErrorI) { - // determine the prefix based on whether chainId filter is provided - var prefix []byte - if chainId == 0 { - prefix = OrderBySellerPrefix(seller) - } else { - prefix = OrderBySellerAndChainPrefix(seller, chainId) - } - // use the page Load function with the index prefix - err := page.Load(prefix, false, results, s, func(k, v []byte) lib.ErrorI { - // extract chainId and orderId from the index key - cId, orderId, e := s.parseOrderBySellerKey(k) - if e != nil { - return e - } - // get the actual order from the primary store - order, e := s.GetOrder(orderId, cId) - if e != nil { - return e - } - *results = append(*results, order) - return nil - }) - return page, err -} - -// getOrdersByBuyerPaginated() retrieves paginated orders using the buyer index -func (s *StateMachine) getOrdersByBuyerPaginated(buyer []byte, chainId uint64, page *lib.Page, results *lib.SellOrders) (*lib.Page, lib.ErrorI) { - // determine the prefix based on whether chainId filter is provided - var prefix []byte - if chainId == 0 { - prefix = OrderByBuyerPrefix(buyer) - } else { - prefix = OrderByBuyerAndChainPrefix(buyer, chainId) - } - // use the page Load function with the index prefix - err := page.Load(prefix, false, results, s, func(k, v []byte) lib.ErrorI { - // extract chainId and orderId from the index key - cId, orderId, e := s.parseOrderByBuyerKey(k) - if e != nil { - return e - } - // get the actual order from the primary store - order, e := s.GetOrder(orderId, cId) - if e != nil { - return e - } - *results = append(*results, order) - return nil - }) - return page, err -} - // getOrdersByChainPaginated() retrieves paginated orders for a specific chain func (s *StateMachine) getOrdersByChainPaginated(chainId uint64, page *lib.Page, results *lib.SellOrders) (*lib.Page, lib.ErrorI) { // use the page Load function with the order book prefix