Skip to content
Open
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
104 changes: 89 additions & 15 deletions backend/crates/atlas-server/src/api/handlers/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use axum::{
extract::{Path, Query, State},
Json,
};
use serde::Deserialize;
use std::sync::Arc;

use super::get_table_count;
Expand All @@ -11,28 +12,101 @@ use atlas_common::{
AtlasError, Erc20Transfer, NftTransfer, PaginatedResponse, Pagination, Transaction,
};

/// Query parameters for the transaction list endpoint.
/// Supports cursor-based pagination via (block_number, block_index) for O(log N)
/// seeks on large partitioned tables, replacing O(N) OFFSET scans.
#[derive(Debug, Deserialize)]
pub struct TransactionListParams {
#[serde(default = "default_page")]
pub page: u32,
#[serde(default = "default_limit")]
pub limit: u32,
/// Fetch transactions older than this (block_number, block_index).
pub before_block: Option<i64>,
pub before_index: Option<i32>,
/// Fetch transactions newer than this (block_number, block_index).
pub after_block: Option<i64>,
pub after_index: Option<i32>,
/// When true, fetch the oldest page of transactions.
#[serde(default)]
pub last_page: bool,
}

fn default_page() -> u32 {
1
}
fn default_limit() -> u32 {
20
}

pub async fn list_transactions(
State(state): State<Arc<AppState>>,
Query(pagination): Query<Pagination>,
Query(params): Query<TransactionListParams>,
) -> ApiResult<Json<PaginatedResponse<Transaction>>> {
// Use optimized count (approximate for large tables, exact for small)
let total = get_table_count(&state.pool, "transactions").await?;

let transactions: Vec<Transaction> = sqlx::query_as(
"SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp
FROM transactions
ORDER BY block_number DESC, block_index DESC
LIMIT $1 OFFSET $2"
)
.bind(pagination.limit())
.bind(pagination.offset())
.fetch_all(&state.pool)
.await?;
let limit = params.limit.min(100) as i64;

let transactions: Vec<Transaction> =
if let (Some(bb), Some(bi)) = (params.before_block, params.before_index) {
// Next page: transactions older than cursor
sqlx::query_as(
"SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp
FROM transactions
WHERE (block_number, block_index) < ($1, $2)
ORDER BY block_number DESC, block_index DESC
LIMIT $3",
)
.bind(bb)
.bind(bi)
.bind(limit)
.fetch_all(&state.pool)
.await?
} else if let (Some(ab), Some(ai)) = (params.after_block, params.after_index) {
// Prev page: transactions newer than cursor (fetch ASC, reverse)
let mut txs: Vec<Transaction> = sqlx::query_as(
"SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp
FROM transactions
WHERE (block_number, block_index) > ($1, $2)
ORDER BY block_number ASC, block_index ASC
LIMIT $3",
)
.bind(ab)
.bind(ai)
.bind(limit)
.fetch_all(&state.pool)
.await?;
txs.reverse();
txs
} else if params.last_page {
// Last page: oldest transactions (fetch ASC, reverse)
let mut txs: Vec<Transaction> = sqlx::query_as(
"SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp
FROM transactions
ORDER BY block_number ASC, block_index ASC
LIMIT $1",
)
.bind(limit)
.fetch_all(&state.pool)
.await?;
txs.reverse();
txs
} else {
// First page (default): newest transactions
sqlx::query_as(
"SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp
FROM transactions
ORDER BY block_number DESC, block_index DESC
LIMIT $1",
)
.bind(limit)
.fetch_all(&state.pool)
.await?
};

Ok(Json(PaginatedResponse::new(
transactions,
pagination.page,
pagination.limit,
params.page,
params.limit,
total,
)))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Composite index for cursor-based (keyset) pagination on transactions.
-- Enables O(log N) index seeks instead of O(N) OFFSET scans for the
-- global transaction listing endpoint.
CREATE INDEX IF NOT EXISTS idx_transactions_block_idx
ON transactions(block_number DESC, block_index DESC);
9 changes: 7 additions & 2 deletions frontend/src/api/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ export interface GetTransactionsParams {
limit?: number;
block_number?: number;
address?: string;
before_block?: number;
before_index?: number;
after_block?: number;
after_index?: number;
last_page?: boolean;
}

export async function getTransactions(params: GetTransactionsParams = {}): Promise<PaginatedResponse<Transaction>> {
const { page = 1, limit = 20, block_number, address } = params;
const { page = 1, limit = 20, block_number, address, before_block, before_index, after_block, after_index, last_page } = params;
return client.get<PaginatedResponse<Transaction>>('/transactions', {
params: { page, limit, block_number, address },
params: { page, limit, block_number, address, before_block, before_index, after_block, after_index, last_page: last_page || undefined },
});
}

Expand Down
45 changes: 38 additions & 7 deletions frontend/src/pages/TransactionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { useEthPrice } from '../hooks';

export default function TransactionsPage() {
const [page, setPage] = useState(1);
const [cursor, setCursor] = useState<{
beforeBlock?: number;
beforeIndex?: number;
afterBlock?: number;
afterIndex?: number;
lastPage?: boolean;
}>({});
const [autoRefresh, setAutoRefresh] = useState<boolean>(() => {
try {
const v = localStorage.getItem('txs:autoRefresh');
Expand All @@ -16,7 +23,15 @@ export default function TransactionsPage() {
}
});
const [, setTick] = useState(0);
const { transactions, pagination, refetch, loading } = useTransactions({ page, limit: 20 });
const { transactions, pagination, refetch, loading } = useTransactions({
page,
limit: 20,
before_block: cursor.beforeBlock,
before_index: cursor.beforeIndex,
after_block: cursor.afterBlock,
after_index: cursor.afterIndex,
last_page: cursor.lastPage,
});
const [hasLoaded, setHasLoaded] = useState(false);
useEffect(() => {
if (!loading) setHasLoaded(true);
Expand Down Expand Up @@ -130,6 +145,22 @@ export default function TransactionsPage() {
}
};

// Cursor-based navigation: derive cursors from server-ordered data
const goFirst = () => { setCursor({}); setPage(1); };
const goPrev = () => {
const f = transactions[0];
if (f) setCursor({ afterBlock: f.block_number, afterIndex: f.block_index });
setPage((p) => Math.max(1, p - 1));
};
const goNext = () => {
const l = transactions[transactions.length - 1];
if (l) setCursor({ beforeBlock: l.block_number, beforeIndex: l.block_index });
setPage((p) => p + 1);
};
const goLast = () => {
if (pagination) { setCursor({ lastPage: true }); setPage(pagination.total_pages); }
};

return (
<div>
<div className="flex items-center justify-between mb-6">
Expand Down Expand Up @@ -321,7 +352,7 @@ export default function TransactionsPage() {
<div className="flex items-center justify-center gap-2">
<button
className="btn btn-secondary text-xs"
onClick={() => setPage(1)}
onClick={goFirst}
disabled={page === 1}
aria-label="First page"
title="First page"
Expand All @@ -333,7 +364,7 @@ export default function TransactionsPage() {
</button>
<button
className="btn btn-secondary text-xs"
onClick={() => setPage(Math.max(1, page - 1))}
onClick={goPrev}
disabled={page === 1}
aria-label="Previous page"
title="Previous page"
Expand All @@ -349,8 +380,8 @@ export default function TransactionsPage() {
</span>
<button
className="btn btn-secondary text-xs"
onClick={() => pagination && setPage(Math.min(pagination.total_pages, page + 1))}
disabled={!pagination || page === pagination?.total_pages}
onClick={goNext}
disabled={!pagination || page >= pagination.total_pages}
aria-label="Next page"
title="Next page"
>
Expand All @@ -360,8 +391,8 @@ export default function TransactionsPage() {
</button>
<button
className="btn btn-secondary text-xs"
onClick={() => pagination && setPage(pagination.total_pages)}
disabled={!pagination || page === pagination?.total_pages}
onClick={goLast}
disabled={!pagination || page >= pagination.total_pages}
aria-label="Last page"
title="Last page"
>
Expand Down
Loading