Skip to content

Commit 2549c06

Browse files
authored
Test/e2e coverage improvements (#81)
* ci(coverage): expand test coverage for indexer operator, sender, and storage components * ci(indexer): warm postgres testcontainers image in unit-tests job * update llvm_cov_version * fix(ci): add llvm-cov clean step before integration E2E coverage accumulation * test(integration): fix flaky E2E tests and add operator lifecycle suite Fix several race conditions and correctness bugs across the indexer integration test suite, and add missing tests to CI coverage: - mint_idempotency: poll for TransactionStatusService indexing before calling find_existing_mint_signature, fixing a race where getSignaturesForAddress returned empty immediately after confirmation - reconciliation: pass derived PDA instead of raw seed keypair to run_startup_reconciliation, and poll for finalized commitment before querying on-chain balance - indexer integration: guard Phase 10 (tree rotation) behind #[cfg(feature = "test-tree")] to prevent accidental 65k-tx runs - operator_lifecycle: add new test suite covering operator startup, withdrawal processing, SMT state management, and tree rotation - reconciliation_e2e_test: move from bare cargo test in CI to cargo llvm-cov so it contributes to coverage report - Makefiles: fix integration-test and integration-coverage targets to rebuild escrow .so at the correct points (production before contra_integration, test-tree before indexer/operator-lifecycle); use cargo nextest consistently for contra_integration * test(integration): add gap-detection, resync, and reconciliation e2e tests - Add test_gap_detection_rpc_polling_fallback (RPC-polling gap recovery) - Add resync_integration and reconciliation_e2e test suites - Fix startup reconciliation blocking gap-recovery restarts (mismatch_threshold_raw: u64::MAX in test helpers) - Wire new test targets into Cargo.toml, Makefile, and CI coverage * fix: use sorted set for redis MIN semantics, fix double decode, use pre-built nextest * test(operator_lifecycle): ignore flaky alert-on-failure test test_failed_withdrawals_and_mints_fire_alerts is skipped pending investigation into why bad deposits with wrong mint authority never reach "failed" DB status within the timeout window. * remove unnecessary readme files * perf(redis): replace SCAN with sorted-set index for get_blocks Add block_slot_index sorted set in write_batch_redis so both get_blocks_redis (ZRANGE BYSCORE) and get_first_available_block_redis (ZRANGE rank 0) can be served in O(log N + M) instead of O(total_keys) via SCAN or O(range_size) via sequential EXISTS calls. Also fix inline use imports in mint_idempotency.rs per review nit. * fixed fmt * fix(redis): upgrade to Redis 7 to support ZRANGE BYSCORE, fix review nits ZRANGE ... BYSCORE requires Redis 6.2+. The testcontainer was pinned to redis:5.0 (EOL 2020), causing test_with_redis to fail with a syntax error on the block_slot_index range query. Upgraded to redis:7 in both the testcontainer and the CI warm-image step.
1 parent 3aa8dbf commit 2549c06

32 files changed

Lines changed: 2418 additions & 201 deletions

.config/nextest.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[profile.default]
2+
# Integration tests can take up to 5 minutes
3+
slow-timeout = { period = "60s", terminate-after = 20 }
4+
# Each test runs in its own process — required for Lazy<Signer> singleton isolation
5+
# and for unique_port_range_for_tests to assign non-overlapping validator port ranges

.github/workflows/rust.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,12 @@ jobs:
280280
- name: Warm testcontainers images
281281
run: |
282282
docker image inspect postgres:11-alpine >/dev/null 2>&1 || docker pull postgres:11-alpine
283-
docker image inspect redis:5.0 >/dev/null 2>&1 || docker pull redis:5.0
283+
docker image inspect redis:7 >/dev/null 2>&1 || docker pull redis:7
284+
285+
- name: Install cargo-nextest
286+
uses: taiki-e/install-action@v2
287+
with:
288+
tool: cargo-nextest
284289

285290
- name: Run contra integration tests with coverage
286291
run: make -C integration integration-coverage-contra

Cargo.lock

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,21 @@ integration-test:
6565
@$(MAKE) -C contra-escrow-program integration-test
6666
@$(MAKE) -C contra-withdraw-program integration-test
6767
@echo "Running contra integration test (with production build)..."
68-
@cd integration && cargo test --test contra_integration -- --nocapture
69-
@echo "Building escrow with test-tree for indexer tests..."
68+
@cd integration && cargo nextest run --test contra_integration
69+
@echo "Running reconciliation integration tests..."
70+
@cd integration && cargo test --test reconciliation_integration -- --nocapture
71+
@echo "Running mint idempotency integration tests..."
72+
@cd integration && cargo test --test mint_idempotency_integration -- --nocapture
73+
@echo "Running gap detection integration tests..."
74+
@cd integration && cargo test --test gap_detection_integration -- --nocapture
75+
@echo "Running truncate integration tests..."
76+
@cd integration && cargo test --test truncate_integration -- --nocapture
77+
@echo "Building escrow with test-tree for indexer and operator lifecycle tests..."
7078
@$(MAKE) -C contra-escrow-program build-test
7179
@echo "Running indexer integration test (with test-tree build)..."
7280
@cd integration && cargo test --features test-tree --test indexer_integration -- --nocapture
81+
@echo "Running operator lifecycle integration tests (with test-tree build)..."
82+
@cd integration && cargo test --features test-tree --test operator_lifecycle_integration -- --nocapture
7383

7484
ci-unit-test:
7585
@echo "Running CI unit tests for core + indexer..."
@@ -85,21 +95,41 @@ ci-integration-test:
8595

8696
ci-integration-test-build-test-tree:
8797
@$(MAKE) ci-integration-test-prebuilt
88-
@echo "Building escrow with test-tree for indexer tests..."
98+
@echo "Building escrow with test-tree for indexer and operator lifecycle tests..."
8999
@$(MAKE) -C contra-escrow-program build-test
90100
@echo "Running indexer integration test (with test-tree build)..."
91101
@cd integration && cargo test --features test-tree --test indexer_integration -- --nocapture
102+
@echo "Running operator lifecycle integration tests (with test-tree build)..."
103+
@cd integration && cargo test --features test-tree --test operator_lifecycle_integration -- --nocapture
92104

93105
ci-integration-test-prebuilt:
94106
@echo "Running contra integration test (with production build)..."
95-
@cd integration && cargo test --test contra_integration -- --nocapture
107+
@cd integration && cargo nextest run --test contra_integration
108+
@echo "Running reconciliation integration tests..."
109+
@cd integration && cargo test --test reconciliation_integration -- --nocapture
110+
@echo "Running mint idempotency integration tests..."
111+
@cd integration && cargo test --test mint_idempotency_integration -- --nocapture
112+
@echo "Running gap detection integration tests..."
113+
@cd integration && cargo test --test gap_detection_integration -- --nocapture
114+
@echo "Running truncate integration tests..."
115+
@cd integration && cargo test --test truncate_integration -- --nocapture
96116

97117
# CI-focused integration target that runs indexer integration tests only.
98118
ci-integration-test-indexer:
99-
@echo "Building escrow with test-tree for indexer tests..."
119+
@echo "Building escrow with test-tree for indexer and operator lifecycle tests..."
100120
@$(MAKE) -C contra-escrow-program build-test
101121
@echo "Running indexer integration test (with test-tree build)..."
102122
@cd integration && cargo test --features test-tree --test indexer_integration -- --nocapture
123+
@echo "Running reconciliation integration tests..."
124+
@cd integration && cargo test --test reconciliation_integration -- --nocapture
125+
@echo "Running mint idempotency integration tests..."
126+
@cd integration && cargo test --test mint_idempotency_integration -- --nocapture
127+
@echo "Running gap detection integration tests..."
128+
@cd integration && cargo test --test gap_detection_integration -- --nocapture
129+
@echo "Running truncate integration tests..."
130+
@cd integration && cargo test --test truncate_integration -- --nocapture
131+
@echo "Running operator lifecycle integration tests (with test-tree build)..."
132+
@cd integration && cargo test --features test-tree --test operator_lifecycle_integration -- --nocapture
103133
@echo "Running reconciliation e2e tests..."
104134
@cd indexer && cargo test --test reconciliation_e2e_test -- --nocapture
105135

core/src/accounts/get_blocks.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,17 +85,17 @@ async fn get_blocks_redis(
8585
));
8686
}
8787

88-
// Query blocks within the range
89-
// In Redis, blocks are stored with keys like "block:{slot}"
90-
// We need to check which slots have blocks
91-
let mut slots = Vec::new();
92-
for slot in start_slot..=end_slot {
93-
let key = format!("block:{}", slot);
94-
let exists: redis::RedisResult<bool> = conn.exists(&key).await;
95-
if exists.unwrap_or(false) {
96-
slots.push(slot);
97-
}
98-
}
88+
// ZRANGE ... BYSCORE queries only the requested range in O(log N + M),
89+
// where N is total slots indexed and M is the number of results returned.
90+
// Results are returned in ascending score order (slot order) by default.
91+
let slots: Vec<u64> = redis::cmd("ZRANGE")
92+
.arg("block_slot_index")
93+
.arg(start_slot)
94+
.arg(end_slot)
95+
.arg("BYSCORE")
96+
.query_async(&mut conn)
97+
.await
98+
.map_err(|e| anyhow!("Failed to query block slot index in Redis: {}", e))?;
9999

100100
Ok(slots)
101101
}

core/src/accounts/get_first_available_block.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@ fn decode_first_available_block(value: &[u8]) -> Option<u64> {
4343

4444
async fn get_first_available_block_redis(db: &RedisAccountsDB) -> Result<u64> {
4545
let mut conn = db.connection.clone();
46-
let result: redis::RedisResult<Option<u64>> = conn.get("first_available_block").await;
46+
// ZRANGE 0 0 returns the single member with the lowest score (earliest slot).
47+
// Pairs with write_batch_redis which uses ZADD on block_slot_index for MIN semantics.
48+
let result: redis::RedisResult<Vec<u64>> =
49+
conn.zrange("block_slot_index", 0isize, 0isize).await;
4750
result
4851
.map_err(|e| anyhow!("Failed to get first available block from Redis: {}", e))
49-
.and_then(|opt| opt.ok_or_else(|| anyhow!("No first available block found in Redis")))
52+
.and_then(|slots| {
53+
slots
54+
.into_iter()
55+
.next()
56+
.ok_or_else(|| anyhow!("No first available block found in Redis"))
57+
})
5058
}
5159

5260
#[cfg(test)]

core/src/accounts/write_batch.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,12 @@ pub(crate) async fn write_batch_redis(
219219
let key = format!("block:{}", block.slot);
220220
let serialized = bincode::serialize(&block).unwrap();
221221
pipe.set(key, serialized);
222+
// Index all slots in a sorted set (score = slot value) for two purposes:
223+
// 1. getBlocks: ZRANGE block_slot_index start end BYSCORE for O(log N + M) range queries.
224+
// 2. getFirstAvailableBlock: ZRANGE block_slot_index 0 0 returns the minimum slot.
225+
// ZADD is idempotent for the same (member, score) pair, so replays are safe.
226+
// redis-rs 0.27: zadd(key, member, score) — member first, score second.
227+
pipe.zadd("block_slot_index", block.slot, block.slot as f64);
222228
}
223229

224230
// Execute pipeline - explicitly specify the return type to fix type inference

indexer/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ contra-metrics = { workspace = true }
5454
once_cell = { workspace = true }
5555
thiserror = { workspace = true }
5656
uuid = { workspace = true }
57+
solana-account-decoder-client-types = { workspace = true }
5758
solana-client = { version = "2.3.10" }
5859
solana-rpc-client-api = { workspace = true }
5960
solana-transaction-status = { version = "2.3.10" }

indexer/src/operator/reconciliation.rs

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ use crate::operator::utils::instruction_util::RetryPolicy;
1515
use crate::operator::RpcClientWithRetry;
1616
use crate::storage::Storage;
1717
use contra_core::webhook::{WebhookClient, WebhookRetryConfig};
18+
use solana_account_decoder_client_types::UiAccountData;
1819
use solana_client::rpc_request::TokenAccountsFilter;
1920
use solana_sdk::pubkey::Pubkey;
2021
use spl_token::solana_program::program_pack::Pack;
2122
use spl_token::state::Account as TokenAccount;
2223
use std::collections::HashMap;
24+
use std::str::FromStr;
2325
use std::sync::Arc;
2426
use std::time::Duration;
2527
use tokio_util::sync::CancellationToken;
@@ -305,23 +307,70 @@ async fn fetch_on_chain_balances(
305307
))
306308
})?;
307309

308-
// Parse each token account and aggregate balances by mint
310+
// Parse each token account and aggregate balances by mint.
311+
// The RPC may return accounts in either binary (base64) or JSON-parsed format
312+
// depending on the client's requested encoding. We handle both variants.
309313
for keyed_account in accounts {
310-
// Decode the account data from base64
311-
let account_data = keyed_account.account.data.decode().ok_or_else(|| {
312-
OperatorError::RpcError("Failed to decode token account data".to_string())
313-
})?;
314-
315-
// Unpack the token account structure
316-
let token_account = TokenAccount::unpack(&account_data).map_err(|e| {
317-
OperatorError::RpcError(format!(
318-
"Failed to parse token account for program {}: {}",
319-
token_program_id, e
320-
))
321-
})?;
314+
let (mint, amount) = match &keyed_account.account.data {
315+
// Binary encoding: decode base64, then unpack the SPL token layout
316+
data if data.decode().is_some() => {
317+
let account_data = data.decode().unwrap();
318+
let token_account = TokenAccount::unpack(&account_data).map_err(|e| {
319+
OperatorError::RpcError(format!(
320+
"Failed to parse token account for program {}: {}",
321+
token_program_id, e
322+
))
323+
})?;
324+
(token_account.mint, token_account.amount)
325+
}
326+
// JSON-parsed encoding: the RPC has already decoded the account for us;
327+
// extract mint and amount from the nested `info` object.
328+
UiAccountData::Json(parsed) => {
329+
let info = parsed.parsed.get("info").ok_or_else(|| {
330+
OperatorError::RpcError(
331+
"Missing 'info' in parsed token account".to_string(),
332+
)
333+
})?;
334+
let mint_str = info.get("mint").and_then(|v| v.as_str()).ok_or_else(|| {
335+
OperatorError::RpcError(
336+
"Missing 'mint' in parsed token account info".to_string(),
337+
)
338+
})?;
339+
let amount_str = info
340+
.get("tokenAmount")
341+
.and_then(|v| v.get("amount"))
342+
.and_then(|v| v.as_str())
343+
.ok_or_else(|| {
344+
OperatorError::RpcError(
345+
"Missing 'tokenAmount.amount' in parsed token account".to_string(),
346+
)
347+
})?;
348+
let mint = Pubkey::from_str(mint_str).map_err(|e| {
349+
OperatorError::RpcError(format!(
350+
"Invalid mint pubkey '{}': {}",
351+
mint_str, e
352+
))
353+
})?;
354+
let amount = amount_str.parse::<u64>().map_err(|e| {
355+
OperatorError::RpcError(format!(
356+
"Invalid token amount '{}': {}",
357+
amount_str, e
358+
))
359+
})?;
360+
(mint, amount)
361+
}
362+
// Unknown encoding variant — skip with a warning rather than hard-failing
363+
_ => {
364+
warn!(
365+
"Skipping token account with unrecognised data encoding for program {}",
366+
token_program_id
367+
);
368+
continue;
369+
}
370+
};
322371

323372
// Sum balances for each mint (handles multiple token accounts for the same mint)
324-
*balances.entry(token_account.mint).or_insert(0) += token_account.amount;
373+
*balances.entry(mint).or_insert(0) += amount;
325374
}
326375
}
327376

integration/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,25 @@ path = "tests/indexer/gap_detection.rs"
2727
name = "truncate_integration"
2828
path = "tests/contra/truncate.rs"
2929

30+
[[test]]
31+
name = "operator_lifecycle_integration"
32+
path = "tests/indexer/operator_lifecycle.rs"
33+
34+
[[test]]
35+
name = "resync_integration"
36+
path = "tests/indexer/resync.rs"
37+
38+
[[test]]
39+
name = "reconciliation_e2e_test"
40+
path = "tests/indexer/reconciliation_e2e.rs"
41+
3042
[features]
3143
test-tree = ["contra-indexer/test-tree"]
3244

3345
[dependencies]
3446
anyhow = "1.0.100"
3547
base64 = { workspace = true }
48+
bs58 = { workspace = true }
3649
bincode = { workspace = true }
3750
chrono = { workspace = true }
3851
contra-core = { path = "../core" }
@@ -59,9 +72,12 @@ spl-memo = { workspace = true }
5972
spl-token = { workspace = true }
6073
spl-token-2022 = { workspace = true }
6174
sqlx = { workspace = true }
75+
mockito = "1.7"
6276
testcontainers = { workspace = true }
6377
testcontainers-modules = { workspace = true }
6478
test_utils = { path = "../test_utils" }
6579
tokio = { workspace = true }
80+
tokio-util = { workspace = true }
6681
tracing = { workspace = true }
6782
tracing-subscriber = { workspace = true }
83+
uuid = { workspace = true }

0 commit comments

Comments
 (0)