diff --git a/server/tools/chains/atomone/get-nodes-votes.ts b/server/tools/chains/atomone/get-nodes-votes.ts new file mode 100644 index 00000000..43a7f3df --- /dev/null +++ b/server/tools/chains/atomone/get-nodes-votes.ts @@ -0,0 +1,55 @@ +import atomoneIndexer from '@/services/atomone-indexer-api'; +import logger from '@/logger'; +import { GetNodesVotes, NodeVote } from '@/server/tools/chains/chain-indexer'; +import { fromValoperToAccount } from '@/utils/cosmos-address-converter'; + +const { logError } = logger('atomone-nodes-votes'); + +const PAGE_LIMIT = 100; +const MAX_PAGES = 1000; + +// AtomOne has its own populated citizenweb3 indexer deployment (ATOMONE_INDEXER_BASE_URL). This +// method overrides the inherited cosmoshub one (which guards on chain.name and would return []), +// so it points at the atomone indexer and guards on the atomone chain name. +const getNodesVotes: GetNodesVotes = async (chain, operatorAddress) => { + if (chain.name !== 'atomone') return []; + + // gov.votes is keyed by the voter ACCOUNT address; the job passes the validator operator + // (valoper) address, so convert it. Same bech32 payload, different prefix (atone / atonevaloper). + const voter = fromValoperToAccount(operatorAddress, chain.bech32Prefix); + if (!voter) return []; + + try { + const votes: NodeVote[] = []; + let before: string | undefined; + let pages = 0; + + // Drain the keyset cursor so the validator's full vote history is not truncated. + for (;;) { + const { data, cursor, has_more } = await atomoneIndexer.getGovVotes({ + voter, + limit: PAGE_LIMIT, + before_proposal_id: before, + }); + + for (const v of data) { + votes.push({ address: voter, proposalId: v.proposal_id, vote: v.option, txHash: v.tx_hash }); + } + + if (!has_more || !cursor) break; + + const next = cursor.next_before_proposal_id; + // Defensive bound against a misbehaving indexer: the keyset cursor strictly decreases, + // so stop if it fails to advance or the page count explodes. + if (next === before || ++pages >= MAX_PAGES) break; + before = next; + } + + return votes; + } catch (e) { + logError(`Can't fetch gov votes for ${voter}`, e); + return []; + } +}; + +export default getNodesVotes; diff --git a/server/tools/chains/atomone/methods.ts b/server/tools/chains/atomone/methods.ts index 76aba703..ce77958b 100644 --- a/server/tools/chains/atomone/methods.ts +++ b/server/tools/chains/atomone/methods.ts @@ -1,4 +1,5 @@ import getAvgFee from '@/server/tools/chains/atomone/get-avg-fee'; +import getNodesVotes from '@/server/tools/chains/atomone/get-nodes-votes'; import getProposalParams from '@/server/tools/chains/atomone/get-proposal-params'; import getProposals from '@/server/tools/chains/atomone/get-proposals'; import getTotalTxs from '@/server/tools/chains/atomone/get-total-txs'; @@ -15,6 +16,7 @@ const chainMethods: ChainMethods = { getTxsLast24h, getTps, getAvgFee, + getNodesVotes, }; export default chainMethods; diff --git a/src/actions/get-txs-batch.ts b/src/actions/get-txs-batch.ts index d94f8341..74602921 100644 --- a/src/actions/get-txs-batch.ts +++ b/src/actions/get-txs-batch.ts @@ -6,18 +6,21 @@ import type { Cursor, TxBatchResult } from '@/services/tx-service'; const { logError } = logger('get-txs-batch'); -const COSMOSHUB = 'cosmoshub'; +// Chains with a per-address tx indexer (separate citizenweb3 deployments, same API shape). +const TX_BY_ADDRESS_CHAINS = new Set(['cosmoshub', 'atomone']); -// Loose bech32 shape: 1. Accepts both `cosmos1…` and `cosmosvaloper1…` (the validator -// feed sends both). The indexer validates strictly server-side; this is a cheap pre-filter so a -// malformed input fails fast as INVALID_REQUEST instead of hitting the indexer. +// Loose bech32 shape: 1. Accepts account + operator forms for any hrp (`cosmos1…`, +// `cosmosvaloper1…`, `atone1…`, `atonevaloper1…` — the validator feed sends both). The indexer +// validates strictly server-side; this is a cheap pre-filter so a malformed input fails fast as +// INVALID_REQUEST instead of hitting the indexer. const BECH32 = /^[a-z]+1[02-9ac-hj-np-z]{6,}$/; /** - * Server action: fetch one by-address batch for the client tx list. Cosmoshub-gated. Non-cosmoshub - * and empty address sets short-circuit to an empty OK result (mirrors the SSR gate). A thrown/error - * batch surfaces as SERVICE_ERROR; malformed input as INVALID_REQUEST — the client maps the code to - * a localized message and a Retry affordance. + * Server action: fetch one by-address batch for the client tx list. Gated to chains with a + * per-address indexer (cosmoshub, atomone). Unsupported chains and empty address sets short-circuit + * to an empty OK result (mirrors the SSR gate). A thrown/error batch surfaces as SERVICE_ERROR; + * malformed input as INVALID_REQUEST — the client maps the code to a localized message and a Retry + * affordance. */ export const getTxsBatch = async ( addresses: string[], @@ -25,7 +28,7 @@ export const getTxsBatch = async ( cursor?: Cursor, ): Promise => { try { - if (chainName.toLowerCase() !== COSMOSHUB) { + if (!TX_BY_ADDRESS_CHAINS.has(chainName.toLowerCase())) { return { ok: true, rows: [], nextCursor: null, hasMore: false }; } @@ -37,7 +40,7 @@ export const getTxsBatch = async ( return { ok: false, code: 'INVALID_REQUEST' }; } - const batch = await TxService.getCosmosTxsBatch(list, cursor); + const batch = await TxService.getTxsByAddressBatch(chainName, list, cursor); if (batch.error) { return { ok: false, code: 'SERVICE_ERROR' }; } diff --git a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx index 1a045c80..0ba42b3e 100644 --- a/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx +++ b/src/app/[locale]/networks/[name]/address/[accountAddress]/transactions/transactions-table/account-transactions-list.tsx @@ -17,11 +17,12 @@ interface OwnProps { } const AccountTransactionsList: FC = async ({ chainName, accountAddress, cursorToken, windowIndex }) => { - // CosmosHub carries REAL indexer data via cursor pagination. Other networks keep the static mock - // placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. - if (chainName.toLowerCase() === 'cosmoshub' && accountAddress) { + // CosmosHub and AtomOne carry REAL indexer data via cursor pagination. Other networks keep the + // static mock placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. + const normalizedChainName = chainName.toLowerCase(); + if ((normalizedChainName === 'cosmoshub' || normalizedChainName === 'atomone') && accountAddress) { const cursor = decodeCursorToken(cursorToken); - const initial = await TxService.getCosmosTxsBatch([accountAddress], cursor); + const initial = await TxService.getTxsByAddressBatch(chainName, [accountAddress], cursor); const windows = Math.max(1, Math.ceil(initial.rows.length / PER_PAGE)); const clampedWindow = Math.min(Math.max(0, windowIndex), windows - 1); diff --git a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx index eb2e28ae..f14a150c 100644 --- a/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx +++ b/src/app/[locale]/networks/[name]/proposal/[proposalId]/votes/validators-votes-table/validators-votes-list.tsx @@ -69,7 +69,12 @@ const ValidatorsVotesList: FC = async ({ sort, perPage, currentPage = ); - } else if (chainName === 'namada' || chainName === 'namada-testnet' || chainName === 'cosmoshub') { + } else if ( + chainName === 'namada' || + chainName === 'namada-testnet' || + chainName === 'cosmoshub' || + chainName === 'atomone' + ) { const result = await voteService.getProposalValidatorsVotes( chainName, proposalId, diff --git a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx index 05eb8267..16d8b1f9 100644 --- a/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx +++ b/src/app/[locale]/validators/[id]/[operatorAddress]/tx_summary/txs-table/node-txs-list.tsx @@ -18,16 +18,17 @@ interface OwnProps { } const NodeTxsList: FC = async ({ chainName, accountAddress, operatorAddress, cursorToken, windowIndex }) => { - // CosmosHub carries REAL indexer data via cursor pagination. Other networks keep the static mock - // placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. - if (chainName.toLowerCase() === 'cosmoshub') { + // CosmosHub and AtomOne carry REAL indexer data via cursor pagination. Other networks keep the + // static mock placeholder (no per-address tx indexer yet) — same fallback the global /tx table uses. + const normalizedChainName = chainName.toLowerCase(); + if (normalizedChainName === 'cosmoshub' || normalizedChainName === 'atomone') { // Query the node's account AND operator address (server-side `signers &&` union). A null // accountAddress yields an operator-only feed (account-signed ops omitted) — known gap. const addresses = [accountAddress, operatorAddress].filter((address): address is string => !!address); if (addresses.length > 0) { const cursor = decodeCursorToken(cursorToken); - const initial = await TxService.getCosmosTxsBatch(addresses, cursor); + const initial = await TxService.getTxsByAddressBatch(chainName, addresses, cursor); const windows = Math.max(1, Math.ceil(initial.rows.length / PER_PAGE)); const clampedWindow = Math.min(Math.max(0, windowIndex), windows - 1); diff --git a/src/app/services/atomone-indexer-api/endpoints.ts b/src/app/services/atomone-indexer-api/endpoints.ts index 4cc11111..61a66d4b 100644 --- a/src/app/services/atomone-indexer-api/endpoints.ts +++ b/src/app/services/atomone-indexer-api/endpoints.ts @@ -3,6 +3,7 @@ import { AtomoneBlockDetailResponse, AtomoneBlocksListResponse, AtomoneBlocksStatsResponse, + AtomoneGovVotesResponse, AtomoneIndexerRequestOptions, AtomoneTxDetailResponse, AtomoneTxRawResponse, @@ -91,3 +92,50 @@ export const getTxsStats = ( options?: AtomoneIndexerRequestOptions, ): Promise => client.get('/api/v1/txs/stats', null, options); + +export interface GetGovVotesParams { + voter: string; + limit?: number; + before_proposal_id?: string; +} + +export const getGovVotes = ( + params: GetGovVotesParams, + options?: AtomoneIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/gov/votes', + { + voter: params.voter, + limit: params.limit, + before_proposal_id: params.before_proposal_id, + }, + options, + ); + +export interface GetTxsByAddressParams { + // comma-separated list of 1-5 bech32 addresses (e.g. account, or account+operator for a validator) + address: string; + limit?: number; + before_height?: string; + before_index?: number; + // 'false' skips the exact COUNT(*) `total` server-side. Cursor clients that don't read `total` + // should pass 'false'. Ignored by older deployments (unknown params are stripped). + count?: 'true' | 'false'; +} + +export const getTxsByAddress = ( + params: GetTxsByAddressParams, + options?: AtomoneIndexerRequestOptions, +): Promise => + client.get( + '/api/v1/txs/by-address', + { + address: params.address, + limit: params.limit, + before_height: params.before_height, + before_index: params.before_index, + count: params.count, + }, + options, + ); diff --git a/src/app/services/atomone-indexer-api/index.ts b/src/app/services/atomone-indexer-api/index.ts index e348b4eb..abe6fc8c 100644 --- a/src/app/services/atomone-indexer-api/index.ts +++ b/src/app/services/atomone-indexer-api/index.ts @@ -3,8 +3,10 @@ import { getBlockByHeight, getBlocksList, getBlocksStats, + getGovVotes, getTxByHash, getTxRaw, + getTxsByAddress, getTxsList, getTxsStats, } from './endpoints'; @@ -19,6 +21,8 @@ export const atomoneIndexer = { getTxByHash, getTxRaw, getTxsStats, + getGovVotes, + getTxsByAddress, healthCheck, getBaseUrl, }; diff --git a/src/app/services/atomone-indexer-api/types.ts b/src/app/services/atomone-indexer-api/types.ts index f64f2890..f88a1485 100644 --- a/src/app/services/atomone-indexer-api/types.ts +++ b/src/app/services/atomone-indexer-api/types.ts @@ -130,3 +130,19 @@ export interface AtomoneTxRawResponse { export interface AtomoneTxsStatsResponse { data: AtomoneTxsStats; } + +export type AtomoneGovVoteOption = 'YES' | 'NO' | 'ABSTAIN' | 'VETO' | 'UNSPECIFIED'; + +export interface AtomoneGovVote { + proposal_id: string; + option: AtomoneGovVoteOption; + weight: string | null; + height: string; + tx_hash: string; +} + +export interface AtomoneGovVotesCursor { + next_before_proposal_id: string; +} + +export type AtomoneGovVotesResponse = AtomoneListResponse; diff --git a/src/app/services/redis-cache.ts b/src/app/services/redis-cache.ts index ca40790e..b9ffd3c3 100644 --- a/src/app/services/redis-cache.ts +++ b/src/app/services/redis-cache.ts @@ -123,8 +123,10 @@ export const CACHE_KEYS = { txs: { // order-independent: the indexer predicate is `signers && ARRAY[...]` (array-overlap, commutative, // dedups), so [acc,op] and [op,acc] return identical rows. Do NOT sort if it ever becomes positional. - byAddress: (addresses: string, cursorKey: string) => - `txs:byaddr:${addresses.split(',').sort().join(',')}:${cursorKey}`, + // `chainName` namespaces the key: cosmoshub and atomone are separate indexer deployments, so the + // same-shaped cursorKey must never collide across chains. + byAddress: (chainName: string, addresses: string, cursorKey: string) => + `txs:byaddr:${chainName}:${addresses.split(',').sort().join(',')}:${cursorKey}`, }, }; diff --git a/src/app/services/tx-service.ts b/src/app/services/tx-service.ts index 74cfa43d..acf98254 100644 --- a/src/app/services/tx-service.ts +++ b/src/app/services/tx-service.ts @@ -447,8 +447,17 @@ const getCosmosTxs = async (currentPage: number, perPage: number): Promise => { - const page = await cosmosIndexer.getTxsByAddress( +// CosmosHub and AtomOne are separate deployments of the same citizenweb3 indexer API, so their +// `getTxsByAddress` share an identical request/response shape (structurally interchangeable). One +// client type lets the batch core serve either chain. +type TxsByAddressClient = { getTxsByAddress: typeof cosmosIndexer.getTxsByAddress }; + +const fetchTxsByAddressBatch = async ( + indexer: TxsByAddressClient, + addresses: string[], + cursor?: Cursor, +): Promise => { + const page = await indexer.getTxsByAddress( { address: addresses.join(','), limit: ITEMS_PER_BATCH, @@ -473,26 +482,32 @@ const fetchTxsByAddressBatch = async (addresses: string[], cursor?: Cursor): Pro const txsByAddressInflight = new Map>(); /** - * Cosmoshub: one batch of transactions involving an address set (account, or account+operator for a - * validator — server-side `signers &&` union). Keyset cursor, no COUNT. Read-through Redis warm-cache - * (head 15s — mutable; deep 300s — immutable) + in-process single-flight to collapse cold-key - * stampedes. Indexer failure is caught into ERROR_BATCH so neither the action nor the RSC cold-load - * caller ever receives a rejection (which would blank the Suspense subtree). The catch is outside - * cacheGetOrFetch, so errors are not cached — the next call retries. + * Shared by-address batch core. Picks the indexer client + cache namespace by chain. One batch of + * transactions involving an address set (account, or account+operator for a validator — server-side + * `signers &&` union). Keyset cursor, no COUNT. Read-through Redis warm-cache (head 15s — mutable; + * deep 300s — immutable) + in-process single-flight to collapse cold-key stampedes. Indexer failure + * is caught into ERROR_BATCH so neither the action nor the RSC cold-load caller ever receives a + * rejection (which would blank the Suspense subtree). The catch is outside cacheGetOrFetch, so + * errors are not cached — the next call retries. */ -const getCosmosTxsBatch = async (addresses: string[], cursor?: Cursor): Promise => { +const runTxsByAddressBatch = ( + indexer: TxsByAddressClient, + chainKey: string, + addresses: string[], + cursor?: Cursor, +): Promise => { const list = addresses.filter(Boolean); - if (!list.length) return EMPTY_BATCH; + if (!list.length) return Promise.resolve(EMPTY_BATCH); const address = list.join(','); const cursorKey = cursor ? `c:${cursor.before_height}:${cursor.before_index}` : 'head'; - const key = CACHE_KEYS.txs.byAddress(address, cursorKey); + const key = CACHE_KEYS.txs.byAddress(chainKey, address, cursorKey); const ttl = cursor ? CACHE_TTL.TXS_DEEP : CACHE_TTL.TXS_HEAD; const existing = txsByAddressInflight.get(key); if (existing) return existing; - const promise = cacheGetOrFetch(key, () => fetchTxsByAddressBatch(list, cursor), ttl) + const promise = cacheGetOrFetch(key, () => fetchTxsByAddressBatch(indexer, list, cursor), ttl) .then((v) => v ?? EMPTY_BATCH) .catch(() => ERROR_BATCH) .finally(() => { @@ -503,6 +518,23 @@ const getCosmosTxsBatch = async (addresses: string[], cursor?: Cursor): Promise< return promise; }; +// Routed by-address batch entry — the single path used by both the pages' SSR initial fetch and the +// client action, so chain→client routing lives in one place. Each branch pairs the chain's indexer +// client with its cache namespace. Unsupported chains short-circuit to an empty batch. +const getTxsByAddressBatch = (chainName: string, addresses: string[], cursor?: Cursor): Promise => { + const normalizedChainName = chainName.toLowerCase(); + + if (normalizedChainName === 'cosmoshub') { + return runTxsByAddressBatch(cosmosIndexer, 'cosmoshub', addresses, cursor); + } + + if (normalizedChainName === 'atomone') { + return runTxsByAddressBatch(atomoneIndexer, 'atomone', addresses, cursor); + } + + return Promise.resolve(EMPTY_BATCH); +}; + const getCosmosTxByHash = async ( hash: string, ): Promise<{ status: TxStatus; data: CosmosTxDetail } | null> => { @@ -635,12 +667,12 @@ const TxService = { getMidenTxByHash, getMidenTxMetrics, getCosmosTxs, - getCosmosTxsBatch, getCosmosTxByHash, getCosmosTxMetrics, getAtomoneTxs, getAtomoneTxByHash, getAtomoneTxMetrics, + getTxsByAddressBatch, }; export default TxService;