SvelteKit block explorer with real RPC integration#110
Conversation
Co-authored-by: Steake <530040+Steake@users.noreply.github.com>
Co-authored-by: Steake <530040+Steake@users.noreply.github.com>
Co-authored-by: Steake <530040+Steake@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR implements a comprehensive block explorer interface for the BitCell blockchain, providing web UI and API endpoints for viewing blocks, transactions, accounts, and trust scores. The implementation uses mock data currently and documents the integration points needed for connecting real blockchain data sources (StateManager, Blockchain, EBSL).
Key changes:
- New block explorer API with search, transaction details, account information, and trust score endpoints
- Cyberpunk-themed web interface with modals for detailed views
- Integration with existing battle visualization from the dashboard
- Comprehensive documentation of features and future enhancements
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/BLOCK_EXPLORER.md | Complete documentation of explorer features, API endpoints, usage examples, and integration notes |
| crates/bitcell-admin/src/api/explorer.rs | API endpoints for transactions, accounts, trust scores, and universal search with mock data |
| crates/bitcell-admin/src/web/explorer.rs | Single-page web interface with search, block/transaction/account modals, and trust score visualization |
| crates/bitcell-admin/src/api/mod.rs | Adds explorer module to API exports |
| crates/bitcell-admin/src/web/mod.rs | Adds explorer module to web exports |
| crates/bitcell-admin/src/lib.rs | Registers explorer web route and API endpoints in the router |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| timestamp: 1700000000, | ||
| status: "confirmed".to_string(), | ||
| })) | ||
| } | ||
|
|
||
| /// Get account information and balance | ||
| pub async fn get_account( | ||
| State(_state): State<Arc<AppState>>, | ||
| Path(address): Path<String>, | ||
| ) -> Result<Json<AccountInfo>, (StatusCode, Json<String>)> { | ||
| // In a real implementation, this would query the state manager | ||
| // For now, return mock data | ||
|
|
||
| // Validate address format | ||
| if !address.starts_with("0x") || address.len() != 42 { | ||
| return Err(( | ||
| StatusCode::BAD_REQUEST, | ||
| Json("Invalid address format".to_string()), | ||
| )); | ||
| } | ||
|
|
||
| // Mock trust score calculation | ||
| let trust_score = 0.85; | ||
|
|
||
| Ok(Json(AccountInfo { | ||
| address: address.clone(), | ||
| balance: 5000000, // 5 CELL | ||
| nonce: 10, | ||
| transaction_count: 25, | ||
| trust_score, | ||
| is_miner: trust_score > 0.75, | ||
| })) | ||
| } | ||
|
|
||
| /// Get transaction history for an account | ||
| pub async fn get_account_transactions( | ||
| State(_state): State<Arc<AppState>>, | ||
| Path(address): Path<String>, | ||
| Query(pagination): Query<PaginationQuery>, | ||
| ) -> Result<Json<TransactionHistoryResponse>, (StatusCode, Json<String>)> { | ||
| // Validate address format | ||
| if !address.starts_with("0x") || address.len() != 42 { | ||
| return Err(( | ||
| StatusCode::BAD_REQUEST, | ||
| Json("Invalid address format".to_string()), | ||
| )); | ||
| } | ||
|
|
||
| // In a real implementation, this would query the transaction index | ||
| // For now, return mock data | ||
| let mut transactions = Vec::new(); | ||
|
|
||
| // Generate some mock transactions | ||
| for i in 0..10 { | ||
| transactions.push(TransactionDetail { | ||
| hash: format!("0x{:064x}", i * 12345), | ||
| block_height: 12340 + i, | ||
| from: if i % 2 == 0 { address.clone() } else { "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8".to_string() }, | ||
| to: if i % 2 == 0 { "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8".to_string() } else { address.clone() }, | ||
| amount: 100000 * (i + 1), | ||
| fee: 21000, | ||
| nonce: i, | ||
| timestamp: 1700000000 + (i * 600), |
There was a problem hiding this comment.
[nitpick] Magic numbers: The timestamp base value 1700000000 appears multiple times without explanation. This represents a specific date (Nov 14, 2023) but it's not clear why this date was chosen for the mock data.
Consider:
- Defining it as a named constant with documentation
- Using a more current or clearly documented date
- Computing it from the current time minus an offset
| <div class="item" onclick="showBlockDetail(${block.height})"> | ||
| <div class="item-header"> | ||
| <span class="item-title">Block #${block.height}</span> | ||
| <span class="badge">${block.battle_count} Battle${block.battle_count !== 1 ? 's' : ''}</span> | ||
| </div> | ||
| <div class="item-meta"> | ||
| <div class="hash">Hash: ${block.hash}</div> | ||
| <div>Proposer: ${block.proposer} • ${new Date(block.timestamp * 1000).toLocaleString()}</div> | ||
| <div>Transactions: ${block.transaction_count}</div> | ||
| </div> | ||
| </div> | ||
| `).join(''); |
There was a problem hiding this comment.
Accessibility issue: Interactive elements (blocks, transactions, accounts) that trigger onclick events don't have appropriate ARIA roles or keyboard navigation support. Users who rely on keyboard navigation cannot access these features.
Consider:
- Adding
role="button"andtabindex="0"to clickable items - Adding keyboard event handlers for Enter/Space keys
- Alternatively, use actual
<button>elements with appropriate styling
Example:
<div class="item" role="button" tabindex="0" onclick="showBlockDetail(${block.height})" onkeypress="if(event.key==='Enter'||event.key===' ')showBlockDetail(${block.height})">| <div class="detail-value hash" style="cursor: pointer;" onclick="showAccountDetail('${tx.from}')">${tx.from}</div> | ||
| </div> | ||
| <div class="detail-field"> | ||
| <div class="detail-label">To</div> | ||
| <div class="detail-value hash" style="cursor: pointer;" onclick="showAccountDetail('${tx.to}')">${tx.to}</div> |
There was a problem hiding this comment.
XSS vulnerability: User-controlled addresses from the tx.from and tx.to fields are directly injected into the onclick handler without sanitization. A malicious address could contain JavaScript code that would execute when clicked.
Consider using data attributes to store the address and reading it in the click handler:
<div class="detail-value hash" data-address="${tx.from}" onclick="showAccountDetail(this.dataset.address)">${escapeHtml(tx.from)}</div>And add a sanitization function:
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}| // Check if it's a transaction hash (0x + 64 hex chars) | ||
| if q.starts_with("0x") && q.len() == 66 { | ||
| results.push(SearchResult::Transaction { | ||
| hash: q.to_string(), | ||
| }); | ||
| } | ||
|
|
||
| // Check if it's a block hash (0x + 16 hex chars for simplified mock) | ||
| // Note: In production, block hashes would typically be 32 bytes (64 hex chars) | ||
| if q.starts_with("0x") && q.len() == 18 { | ||
| // Try to find block by hash | ||
| results.push(SearchResult::Block { | ||
| height: 12345, // Mock value | ||
| hash: q.to_string(), | ||
| }); | ||
| } | ||
|
|
||
| // Check if it's an account address (0x + 40 hex chars) | ||
| if q.starts_with("0x") && q.len() == 42 { | ||
| results.push(SearchResult::Account { | ||
| address: q.to_string(), | ||
| }); | ||
| } |
There was a problem hiding this comment.
Missing hex validation: Validation is incomplete for addresses and transaction hashes. The checks at lines 206, 214, and 223 validate length but not whether the strings contain valid hexadecimal characters.
Add hex validation to all branches:
// For transaction hash:
if q.starts_with("0x") && q.len() == 66 && q[2..].chars().all(|c| c.is_ascii_hexdigit()) {
results.push(SearchResult::Transaction {
hash: q.to_string(),
});
}| // Check if it's a transaction hash (0x + 64 hex chars) | ||
| if q.starts_with("0x") && q.len() == 66 { | ||
| results.push(SearchResult::Transaction { | ||
| hash: q.to_string(), | ||
| }); | ||
| } | ||
|
|
||
| // Check if it's a block hash (0x + 16 hex chars for simplified mock) | ||
| // Note: In production, block hashes would typically be 32 bytes (64 hex chars) | ||
| if q.starts_with("0x") && q.len() == 18 { | ||
| // Try to find block by hash | ||
| results.push(SearchResult::Block { | ||
| height: 12345, // Mock value | ||
| hash: q.to_string(), | ||
| }); | ||
| } |
There was a problem hiding this comment.
Inconsistent hash validation: The search function accepts block hashes of 18 characters (line 214) while transaction hashes require 66 characters (line 206). The documentation at line 12 states "typically 64 in production", which would be 66 characters with the "0x" prefix. This inconsistency is confusing.
Consider either:
- Using consistent 66-character hashes for both blocks and transactions
- Clearly documenting why block hashes are shorter in the mock implementation
- Making the validation match production expectations
| if !hash.starts_with("0x") || hash.len() != 66 { | ||
| return Err(( | ||
| StatusCode::BAD_REQUEST, | ||
| Json("Invalid transaction hash format".to_string()), | ||
| )); | ||
| } |
There was a problem hiding this comment.
Missing hex validation: The validation checks the length but doesn't verify that the string contains only valid hexadecimal characters. A string like 0x followed by 64 non-hex characters would pass validation.
Add hex character validation:
if !hash.starts_with("0x") || hash.len() != 66 || !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
return Err((
StatusCode::BAD_REQUEST,
Json("Invalid transaction hash format".to_string()),
));
}| if !address.starts_with("0x") || address.len() != 42 { | ||
| return Err(( | ||
| StatusCode::BAD_REQUEST, | ||
| Json("Invalid address format".to_string()), | ||
| )); | ||
| } |
There was a problem hiding this comment.
Missing hex validation: Same issue as in get_transaction - the validation only checks length but not hexadecimal character validity. An address with invalid characters would pass validation.
Add hex character validation:
if !address.starts_with("0x") || address.len() != 42 || !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
return Err((
StatusCode::BAD_REQUEST,
Json("Invalid address format".to_string()),
));
}| function switchTab(tab) { | ||
| document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); | ||
| document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); | ||
|
|
||
| event.target.classList.add('active'); | ||
| document.getElementById(tab + '-tab').classList.add('active'); | ||
| } |
There was a problem hiding this comment.
Missing parameter in function: The switchTab function uses event.target without declaring event as a parameter. This relies on the global event object which is non-standard and deprecated.
Fix by adding the event parameter:
function switchTab(tab, event) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tab + '-tab').classList.add('active');
}And update the onclick calls:
<button class="tab active" onclick="switchTab('blocks', event)">Recent Blocks</button>| ### 🔍 Search Functionality | ||
| - **Universal Search Bar**: Search by: | ||
| - Block height (numeric) | ||
| - Block hash (0x + hex characters - currently 16 for mock, typically 64 in production) |
There was a problem hiding this comment.
Inconsistent documentation: The documentation states block hashes are "0x + hex characters - currently 16 for mock, typically 64 in production", but the actual implementation generates 16 hex characters (18 total with "0x" prefix) in the mock data while searching for 18-character hashes. The discrepancy between "16" and the actual implementation is confusing.
Clarify to: "currently 16 hex characters (18 with 0x prefix) for mock"
| - Block hash (0x + hex characters - currently 16 for mock, typically 64 in production) | |
| - Block hash (0x + hex characters - currently 16 hex characters (18 with 0x prefix) for mock, typically 64 in production) |
| if !address.starts_with("0x") || address.len() != 42 { | ||
| return Err(( | ||
| StatusCode::BAD_REQUEST, | ||
| Json("Invalid address format".to_string()), | ||
| )); | ||
| } | ||
|
|
||
| // Mock trust score calculation | ||
| let trust_score = 0.85; | ||
|
|
||
| Ok(Json(AccountInfo { | ||
| address: address.clone(), | ||
| balance: 5000000, // 5 CELL | ||
| nonce: 10, | ||
| transaction_count: 25, | ||
| trust_score, | ||
| is_miner: trust_score > 0.75, | ||
| })) | ||
| } | ||
|
|
||
| /// Get transaction history for an account | ||
| pub async fn get_account_transactions( | ||
| State(_state): State<Arc<AppState>>, | ||
| Path(address): Path<String>, | ||
| Query(pagination): Query<PaginationQuery>, | ||
| ) -> Result<Json<TransactionHistoryResponse>, (StatusCode, Json<String>)> { | ||
| // Validate address format | ||
| if !address.starts_with("0x") || address.len() != 42 { | ||
| return Err(( | ||
| StatusCode::BAD_REQUEST, | ||
| Json("Invalid address format".to_string()), | ||
| )); | ||
| } | ||
|
|
||
| // In a real implementation, this would query the transaction index | ||
| // For now, return mock data | ||
| let mut transactions = Vec::new(); | ||
|
|
||
| // Generate some mock transactions | ||
| for i in 0..10 { | ||
| transactions.push(TransactionDetail { | ||
| hash: format!("0x{:064x}", i * 12345), | ||
| block_height: 12340 + i, | ||
| from: if i % 2 == 0 { address.clone() } else { "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8".to_string() }, | ||
| to: if i % 2 == 0 { "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb8".to_string() } else { address.clone() }, | ||
| amount: 100000 * (i + 1), | ||
| fee: 21000, | ||
| nonce: i, | ||
| timestamp: 1700000000 + (i * 600), | ||
| status: "confirmed".to_string(), | ||
| }); | ||
| } | ||
|
|
||
| // Apply pagination | ||
| let start = (pagination.page - 1) * pagination.per_page; | ||
| let end = start + pagination.per_page; | ||
| let paginated = transactions.into_iter().skip(start).take(pagination.per_page).collect::<Vec<_>>(); | ||
|
|
||
| Ok(Json(TransactionHistoryResponse { | ||
| transactions: paginated, | ||
| total: 10, | ||
| page: pagination.page, | ||
| per_page: pagination.per_page, | ||
| })) | ||
| } | ||
|
|
||
| /// Search for blocks, transactions, or accounts | ||
| pub async fn search( | ||
| State(_state): State<Arc<AppState>>, | ||
| Query(query): Query<SearchQuery>, | ||
| ) -> Result<Json<SearchResponse>, (StatusCode, Json<String>)> { | ||
| let q = query.q.trim(); | ||
| let mut results = Vec::new(); | ||
|
|
||
| if q.is_empty() { | ||
| return Err(( | ||
| StatusCode::BAD_REQUEST, | ||
| Json("Empty search query".to_string()), | ||
| )); | ||
| } | ||
|
|
||
| // Check if it's a block height (numeric) | ||
| if let Ok(height) = q.parse::<u64>() { | ||
| results.push(SearchResult::Block { | ||
| height, | ||
| hash: format!("0x{:016x}", height * 12345), | ||
| }); | ||
| } | ||
|
|
||
| // Check if it's a transaction hash (0x + 64 hex chars) | ||
| if q.starts_with("0x") && q.len() == 66 { | ||
| results.push(SearchResult::Transaction { | ||
| hash: q.to_string(), | ||
| }); | ||
| } | ||
|
|
||
| // Check if it's a block hash (0x + 16 hex chars for simplified mock) | ||
| // Note: In production, block hashes would typically be 32 bytes (64 hex chars) | ||
| if q.starts_with("0x") && q.len() == 18 { | ||
| // Try to find block by hash | ||
| results.push(SearchResult::Block { | ||
| height: 12345, // Mock value | ||
| hash: q.to_string(), | ||
| }); | ||
| } | ||
|
|
||
| // Check if it's an account address (0x + 40 hex chars) | ||
| if q.starts_with("0x") && q.len() == 42 { | ||
| results.push(SearchResult::Account { | ||
| address: q.to_string(), | ||
| }); | ||
| } | ||
|
|
||
| if results.is_empty() { | ||
| return Err(( | ||
| StatusCode::NOT_FOUND, | ||
| Json("No results found".to_string()), | ||
| )); | ||
| } | ||
|
|
||
| Ok(Json(SearchResponse { results })) | ||
| } | ||
|
|
||
| /// Get trust score for an account | ||
| pub async fn get_trust_score( | ||
| State(_state): State<Arc<AppState>>, | ||
| Path(address): Path<String>, | ||
| ) -> Result<Json<TrustScoreResponse>, (StatusCode, Json<String>)> { | ||
| // Validate address format | ||
| if !address.starts_with("0x") || address.len() != 42 { | ||
| return Err(( | ||
| StatusCode::BAD_REQUEST, | ||
| Json("Invalid address format".to_string()), | ||
| )); | ||
| } |
There was a problem hiding this comment.
Code duplication: Address validation logic is duplicated across four functions (get_account, get_account_transactions, get_trust_score, and within the search function). This violates DRY principle and makes maintenance harder.
Consider extracting to a helper function:
fn validate_address(address: &str) -> Result<(), (StatusCode, Json<String>)> {
if !address.starts_with("0x") || address.len() != 42 || !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
return Err((
StatusCode::BAD_REQUEST,
Json("Invalid address format".to_string()),
));
}
Ok(())
}Then call it: validate_address(&address)?;
|
@copilot apply changes based on the comments in this thread Please re-factor this to use a proper web framework. There should be no dummy data, there should be no mocked data. We should be using real API calls, or real rpc calls. The use of react is explicitly banned. Please use svelte/sveltekit. If there is no need for SSR, then don't use it. |
I'll refactor the block explorer to use SvelteKit with real RPC calls to the BitCell node. This will involve:
The explorer will be a standalone SPA that connects to node RPC endpoints. Should I proceed with this approach?
Refactor complete in commit 8cd0284. The block explorer is now a standalone SvelteKit application that:
Location: To run: cd crates/bitcell-explorer
npm install
npm run dev |
- Remove mock data API endpoints from bitcell-admin - Remove inline HTML/JS explorer - Create new SvelteKit application in crates/bitcell-explorer/ - Connect to real BitCell node RPC endpoints - No SSR, client-side only SPA - Proper input validation and XSS protection - Accessibility improvements (ARIA labels, keyboard nav) - Updated documentation Addresses code review comments: - Removed all mock/dummy data - Added hex validation for addresses and hashes - Fixed XSS vulnerabilities - Added proper accessibility features - Using modern web framework (Svelte, not React) Co-authored-by: Steake <530040+Steake@users.noreply.github.com>
Implements comprehensive block explorer for RC3-004 using SvelteKit with real-time RPC connections to BitCell nodes.
Architecture
Complete Refactor: Removed mock data implementation and created standalone SvelteKit application that connects to live blockchain nodes via JSON-RPC.
New SvelteKit Application (
crates/bitcell-explorer/)Technology Stack:
RPC Client (
src/lib/rpc.js):Connects to BitCell node JSON-RPC endpoints:
eth_blockNumber- Current block heighteth_getBlockByNumber- Block details with transactionseth_getTransactionByHash- Transaction detailseth_getBalance- Account balanceeth_getTransactionCount- Account noncebitcell_getNodeInfo- Node informationbitcell_getTournamentState- Tournament statebitcell_getBattleReplay- Battle replay databitcell_getMinerStats- Miner EBSL statisticsFeatures:
Removed
crates/bitcell-admin/src/api/explorer.rs- Mock API endpoints (280 lines)crates/bitcell-admin/src/web/explorer.rs- Inline HTML/JS explorer (771 lines)Security & Accessibility
Security Improvements:
src/lib/utils.jsAccessibility Improvements:
Development
Configuration
The explorer proxies
/rpcrequests to the BitCell node. Default configuration invite.config.js:Code Review Resolutions
Addressed all 15 code review comments:
Testing
Integration
The explorer is a standalone application that can be:
/rpcto BitCell nodeUpdated documentation in
docs/BLOCK_EXPLORER.mdwith deployment instructions and architecture details.Screenshots
Note: Screenshots show the previous inline implementation. The new SvelteKit application maintains the same visual design and cyberpunk aesthetic while connecting to real blockchain data.
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.