Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions server/tools/chains/atomone/get-nodes-votes.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions server/tools/chains/atomone/methods.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +16,7 @@ const chainMethods: ChainMethods = {
getTxsLast24h,
getTps,
getAvgFee,
getNodesVotes,
};

export default chainMethods;
23 changes: 13 additions & 10 deletions src/actions/get-txs-batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,29 @@ 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: <hrp>1<data>. 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: <hrp>1<data>. 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[],
chainName: string,
cursor?: Cursor,
): Promise<TxBatchResult> => {
try {
if (chainName.toLowerCase() !== COSMOSHUB) {
if (!TX_BY_ADDRESS_CHAINS.has(chainName.toLowerCase())) {
return { ok: true, rows: [], nextCursor: null, hasMore: false };
}

Expand All @@ -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' };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ interface OwnProps {
}

const AccountTransactionsList: FC<OwnProps> = 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ const ValidatorsVotesList: FC<OwnProps> = async ({ sort, perPage, currentPage =
</tr>
</tbody>
);
} 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ interface OwnProps {
}

const NodeTxsList: FC<OwnProps> = 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);

Expand Down
48 changes: 48 additions & 0 deletions src/app/services/atomone-indexer-api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
AtomoneBlockDetailResponse,
AtomoneBlocksListResponse,
AtomoneBlocksStatsResponse,
AtomoneGovVotesResponse,
AtomoneIndexerRequestOptions,
AtomoneTxDetailResponse,
AtomoneTxRawResponse,
Expand Down Expand Up @@ -91,3 +92,50 @@ export const getTxsStats = (
options?: AtomoneIndexerRequestOptions,
): Promise<AtomoneTxsStatsResponse> =>
client.get<AtomoneTxsStatsResponse>('/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<AtomoneGovVotesResponse> =>
client.get<AtomoneGovVotesResponse>(
'/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<AtomoneTxsListResponse> =>
client.get<AtomoneTxsListResponse>(
'/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,
);
4 changes: 4 additions & 0 deletions src/app/services/atomone-indexer-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
getBlockByHeight,
getBlocksList,
getBlocksStats,
getGovVotes,
getTxByHash,
getTxRaw,
getTxsByAddress,
getTxsList,
getTxsStats,
} from './endpoints';
Expand All @@ -19,6 +21,8 @@ export const atomoneIndexer = {
getTxByHash,
getTxRaw,
getTxsStats,
getGovVotes,
getTxsByAddress,
healthCheck,
getBaseUrl,
};
Expand Down
16 changes: 16 additions & 0 deletions src/app/services/atomone-indexer-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AtomoneGovVote, AtomoneGovVotesCursor>;
6 changes: 4 additions & 2 deletions src/app/services/redis-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
};

Expand Down
58 changes: 45 additions & 13 deletions src/app/services/tx-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,17 @@ const getCosmosTxs = async (currentPage: number, perPage: number): Promise<TxsRe

// ── by-address batched cursor pagination (read-through + Redis warm-cache + single-flight) ──

const fetchTxsByAddressBatch = async (addresses: string[], cursor?: Cursor): Promise<TxBatch> => {
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<TxBatch> => {
const page = await indexer.getTxsByAddress(
{
address: addresses.join(','),
limit: ITEMS_PER_BATCH,
Expand All @@ -473,26 +482,32 @@ const fetchTxsByAddressBatch = async (addresses: string[], cursor?: Cursor): Pro
const txsByAddressInflight = new Map<string, Promise<TxBatch>>();

/**
* 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<TxBatch> => {
const runTxsByAddressBatch = (
indexer: TxsByAddressClient,
chainKey: string,
addresses: string[],
cursor?: Cursor,
): Promise<TxBatch> => {
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<TxBatch>(key, () => fetchTxsByAddressBatch(list, cursor), ttl)
const promise = cacheGetOrFetch<TxBatch>(key, () => fetchTxsByAddressBatch(indexer, list, cursor), ttl)
.then((v) => v ?? EMPTY_BATCH)
.catch(() => ERROR_BATCH)
.finally(() => {
Expand All @@ -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<TxBatch> => {
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> => {
Expand Down Expand Up @@ -635,12 +667,12 @@ const TxService = {
getMidenTxByHash,
getMidenTxMetrics,
getCosmosTxs,
getCosmosTxsBatch,
getCosmosTxByHash,
getCosmosTxMetrics,
getAtomoneTxs,
getAtomoneTxByHash,
getAtomoneTxMetrics,
getTxsByAddressBatch,
};

export default TxService;
Loading