Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
73 changes: 73 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-Dwarnings"

jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
run: |
rustup update stable
rustup default stable
- uses: Swatinem/rust-cache@v2
- run: cargo check --all-targets

fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
run: |
rustup update stable
rustup default stable
rustup component add rustfmt
- run: cargo fmt --all --check

clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
run: |
rustup update stable
rustup default stable
rustup component add clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --all-targets -- -D warnings

test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
run: |
rustup update stable
rustup default stable
- uses: Swatinem/rust-cache@v2
- run: cargo test --all

build-release:
name: Build Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
run: |
rustup update stable
rustup default stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --release
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
.DS_Store
.DS_Store
/notes
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# DEX Aggregator

## Overview
Multi-hop DEX aggregator that routes swap requests across multiple orderbooks to find optimal execution paths using BFS.

## Architecture
- `crates/aggregator` - Core library and CLI binary
- `crates/aggregator-utils` - Shared types (OrderbookState, SwapRequest, Token, etc.)

## Key Patterns
- `scc::HashMap` for lock-free concurrent orderbook state
- Token-bucket rate limiting with atomic operations
- Circuit breaker for graceful degradation under failures
- Arena-based BFS for memory-efficient route finding
- Arc-wrapped snapshots to avoid per-request cloning

## Commands
```bash
cargo test --all # Run all tests
cargo clippy # Lint check
cargo fmt --check # Format check
cargo llvm-cov --html # Generate coverage report (requires cargo-llvm-cov)
cargo deny check # Dependency audit (requires cargo-deny)
```

## Error Handling
- Library code uses `thiserror` for typed errors
- CLI uses `Box<dyn std::error::Error>` at boundaries
- Errors are sanitized before returning to clients (no token existence leakage)
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[workspace]
members = ["packages/*"]
members = ["crates/*"]
resolver = "2"
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,26 @@ RUST_LOG=aggregator=info cargo run --release -p aggregator -- run
### Running Tests

```bash
cargo test
cargo test --all
```

### Test Coverage

```bash
# Install coverage tool
cargo install cargo-llvm-cov

# Generate HTML report
cargo llvm-cov --html

# View report
open target/llvm-cov/html/index.html
```

## Project Structure

```
packages/
crates/
├── aggregator/
│ └── src/
│ ├── core/ # Main implementation
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,33 @@ use crate::core::format_duration;
use crate::core::state::{AggregatorState, SharedState};
use crate::traits::EventProcessor;

/// Errors that can occur during event processing.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid orderbook: {0}")]
InvalidOrderbook(String),
}

/// Processes incoming orderbook events and updates the shared aggregator state.
///
/// The event processor validates each orderbook update (checking for crossed spreads
/// and empty books) before ingesting it into the state. Invalid orderbooks are
/// rejected and tracked in metrics.
///
/// # Example
///
/// ```ignore
/// let state = create_shared_state();
/// let processor = DexEventProcessor::new(state);
/// processor.process_orderbook(orderbook)?;
/// ```
#[derive(Debug, Clone)]
pub struct DexEventProcessor {
state: SharedState,
}

impl DexEventProcessor {
/// Create a new event processor with the given shared state.
pub fn new(state: SharedState) -> Self {
Self { state }
}
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -33,40 +33,19 @@ impl RequestProcessor for DexRequestProcessor {
.requests_total
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);

// Circuit breaker check: reject early if system is degraded
if !self.state.circuit_breaker.allow_request() {
warn!(
circuit_state = %self.state.circuit_breaker.current_state(),
"swap rejected: circuit breaker open"
);
self.state
.metrics
.requests_failed
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
return Ok(SwapResponse::Failure("service degraded".to_string()));
}

// Reject swaps with the same token
if request.input_token == request.output_token {
warn!(token = %request.input_token, "swap rejected: same token");
return Ok(SwapResponse::Failure("same token".to_string()));
}

// Reject swaps with zero amount
if request.input_amount == 0 {
warn!(
input_token = %request.input_token,
output_token = %request.output_token,
"swap rejected: zero amount"
);
warn!("swap rejected: zero amount");
return Ok(SwapResponse::Failure("zero amount".to_string()));
}

// Check tokens exist before searching (better error messages)
let input_known = self.state.contains_token(request.input_token);
let output_known = self.state.contains_token(request.output_token);

// Reject swaps with unknown tokens (infrastructure issue, affects circuit breaker)
if !input_known || !output_known {
let reason = match (input_known, output_known) {
(false, false) => "unknown tokens",
Expand All @@ -83,11 +62,9 @@ impl RequestProcessor for DexRequestProcessor {
.metrics
.requests_failed
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.state.circuit_breaker.record_failure();
return Ok(SwapResponse::Failure(reason.to_string()));
}

// Find best route (infrastructure issue if fails, affects circuit breaker)
let route = match find_best_route(
&self.state,
request.input_token,
Expand All @@ -99,27 +76,21 @@ impl RequestProcessor for DexRequestProcessor {
warn!(
input_token = %request.input_token,
output_token = %request.output_token,
input_amount = request.input_amount,
"swap rejected: no path"
"swap rejected: no route found"
);
self.state
.metrics
.requests_failed
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.state.circuit_breaker.record_failure();
return Ok(SwapResponse::Failure("no path".to_string()));
return Ok(SwapResponse::Failure("no route".to_string()));
}
};

let output = route.last().map(|s| s.expected_output_amount).unwrap_or(0);

// Slippage protection: reject if output is below user's minimum
if output < request.min_output_amount {
warn!(
input_token = %request.input_token,
output_token = %request.output_token,
input_amount = request.input_amount,
output_amount = output,
output = output,
min_output = request.min_output_amount,
"swap rejected: slippage"
);
Expand All @@ -142,9 +113,6 @@ impl RequestProcessor for DexRequestProcessor {
"route found"
);

// Successful route resets circuit breaker failure count
self.state.circuit_breaker.record_success();

Ok(SwapResponse::Success(SwapResponseSuccess { route }))
}
}
Loading
Loading