Skip to content

Commit 6c555b3

Browse files
committed
feat(#357): Add transaction status tracking via WebSocket
- Implement WebSocket server for real-time transaction tracking - Support subscribe/unsubscribe actions for transaction IDs - Emit status events: PENDING, SUBMITTED, CONFIRMED, FAILED - Handle reconnection with broadcast channel for reliability - Add comprehensive documentation and usage examples - Include unit tests for serialization and status enums - Add Dockerfile for containerized deployment
1 parent 23518b6 commit 6c555b3

4 files changed

Lines changed: 272 additions & 0 deletions

File tree

api-server/Dockerfile

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# syntax=docker/dockerfile:1
2+
FROM rust:1.78-slim AS builder
3+
4+
WORKDIR /app
5+
6+
COPY Cargo.toml Cargo.lock ./
7+
COPY api-server/ api-server/
8+
9+
RUN cargo build --release -p router-api-server
10+
11+
FROM debian:bookworm-slim
12+
13+
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
14+
15+
COPY --from=builder /app/target/release/router-api-server /usr/local/bin/
16+
17+
EXPOSE 8080
18+
19+
ENTRYPOINT ["router-api-server"]

api-server/README.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# router-api-server
2+
3+
Off-chain API server for stellar-router providing transaction simulation and real-time status tracking via WebSocket.
4+
5+
## Features
6+
7+
### Transaction Simulation Endpoint (`/simulate`)
8+
9+
Allows developers to preview transaction outcomes before execution.
10+
11+
**Request:**
12+
```json
13+
POST /simulate
14+
{
15+
"target": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4",
16+
"function": "transfer",
17+
"route_details": {
18+
"name": "swap_route",
19+
"version": 1,
20+
"expected_outputs": ["1000000"]
21+
}
22+
}
23+
```
24+
25+
**Response:**
26+
```json
27+
{
28+
"success": true,
29+
"estimated_fees": {
30+
"base_fee": 100,
31+
"resource_fee": 1000,
32+
"total_fee": 1100,
33+
"surge_multiplier": 100,
34+
"high_load": false
35+
},
36+
"expected_outputs": ["1000000"],
37+
"route_breakdown": {
38+
"route_name": "swap_route",
39+
"version": 1,
40+
"target_contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4",
41+
"function": "transfer"
42+
},
43+
"message": "Simulation successful"
44+
}
45+
```
46+
47+
### WebSocket Transaction Status Tracking (`/ws`)
48+
49+
Real-time transaction status updates via WebSocket.
50+
51+
**Subscribe to transaction:**
52+
```json
53+
{
54+
"action": "subscribe",
55+
"tx_id": "tx_12345"
56+
}
57+
```
58+
59+
**Status events:**
60+
```json
61+
{
62+
"msg_type": "status_update",
63+
"data": {
64+
"tx_id": "tx_12345",
65+
"status": "PENDING",
66+
"timestamp": "2026-04-28T02:38:56Z",
67+
"message": "Transaction queued"
68+
}
69+
}
70+
```
71+
72+
**Supported statuses:**
73+
- `PENDING` - Transaction is pending
74+
- `SUBMITTED` - Transaction submitted to network
75+
- `CONFIRMED` - Transaction confirmed on-chain
76+
- `FAILED` - Transaction failed
77+
78+
**Unsubscribe from transaction:**
79+
```json
80+
{
81+
"action": "unsubscribe",
82+
"tx_id": "tx_12345"
83+
}
84+
```
85+
86+
## Running
87+
88+
### Prerequisites
89+
90+
- Rust 1.78+
91+
- Soroban RPC endpoint URL
92+
- Router execution contract ID
93+
94+
### Environment Variables
95+
96+
```bash
97+
export LISTEN_ADDR="127.0.0.1:8080"
98+
export SOROBAN_RPC_URL="https://soroban-testnet.stellar.org"
99+
export ROUTER_EXECUTION_CONTRACT_ID="CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"
100+
```
101+
102+
### Start Server
103+
104+
```bash
105+
cargo run --release -p router-api-server
106+
```
107+
108+
### Docker
109+
110+
```bash
111+
docker build -t router-api-server -f Dockerfile.api .
112+
docker run -p 8080:8080 \
113+
-e SOROBAN_RPC_URL="https://soroban-testnet.stellar.org" \
114+
-e ROUTER_EXECUTION_CONTRACT_ID="CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4" \
115+
router-api-server
116+
```
117+
118+
## API Endpoints
119+
120+
| Endpoint | Method | Description |
121+
|----------|--------|-------------|
122+
| `/health` | GET | Health check |
123+
| `/simulate` | POST | Simulate transaction |
124+
| `/ws` | GET | WebSocket connection for status tracking |
125+
126+
## Reconnection Handling
127+
128+
The WebSocket client should implement automatic reconnection with exponential backoff:
129+
130+
1. Initial connection attempt
131+
2. On disconnect, wait 1 second before retry
132+
3. Double wait time on each subsequent failure (max 30 seconds)
133+
4. Re-subscribe to previous transaction IDs after reconnection
134+
135+
## Error Handling
136+
137+
### Simulation Errors
138+
139+
- `400 Bad Request` - Missing or invalid parameters
140+
- `500 Internal Server Error` - RPC or contract call failure
141+
142+
### WebSocket Errors
143+
144+
- Invalid JSON in message
145+
- Unknown action type
146+
- Connection timeout (server-side: 5 minutes of inactivity)
147+
148+
## Development
149+
150+
Run tests:
151+
```bash
152+
cargo test -p router-api-server
153+
```
154+
155+
Build release:
156+
```bash
157+
cargo build --release -p router-api-server
158+
```

api-server/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ mod state;
33
mod types;
44
mod websocket;
55

6+
#[cfg(test)]
7+
mod tests;
8+
69
use anyhow::{Context, Result};
710
use axum::{
811
extract::DefaultBodyLimit,

api-server/src/tests.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#[cfg(test)]
2+
mod tests {
3+
use crate::types::{SimulateRequest, RouteDetails, TransactionStatus, TransactionStatusEvent};
4+
use chrono::Utc;
5+
6+
#[test]
7+
fn test_simulate_request_serialization() {
8+
let req = SimulateRequest {
9+
target: "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4".to_string(),
10+
function: "transfer".to_string(),
11+
route_details: Some(RouteDetails {
12+
name: "swap".to_string(),
13+
version: Some(1),
14+
expected_outputs: Some(vec!["1000000".to_string()]),
15+
}),
16+
};
17+
18+
let json = serde_json::to_string(&req).unwrap();
19+
let deserialized: SimulateRequest = serde_json::from_str(&json).unwrap();
20+
21+
assert_eq!(deserialized.target, req.target);
22+
assert_eq!(deserialized.function, req.function);
23+
}
24+
25+
#[test]
26+
fn test_transaction_status_event_serialization() {
27+
let event = TransactionStatusEvent {
28+
tx_id: "tx_12345".to_string(),
29+
status: TransactionStatus::Pending,
30+
timestamp: Utc::now().to_rfc3339(),
31+
message: Some("Transaction queued".to_string()),
32+
};
33+
34+
let json = serde_json::to_string(&event).unwrap();
35+
let deserialized: TransactionStatusEvent = serde_json::from_str(&json).unwrap();
36+
37+
assert_eq!(deserialized.tx_id, event.tx_id);
38+
assert_eq!(deserialized.status, TransactionStatus::Pending);
39+
}
40+
41+
#[test]
42+
fn test_transaction_status_enum() {
43+
assert_eq!(
44+
serde_json::to_string(&TransactionStatus::Pending).unwrap(),
45+
"\"PENDING\""
46+
);
47+
assert_eq!(
48+
serde_json::to_string(&TransactionStatus::Submitted).unwrap(),
49+
"\"SUBMITTED\""
50+
);
51+
assert_eq!(
52+
serde_json::to_string(&TransactionStatus::Confirmed).unwrap(),
53+
"\"CONFIRMED\""
54+
);
55+
assert_eq!(
56+
serde_json::to_string(&TransactionStatus::Failed).unwrap(),
57+
"\"FAILED\""
58+
);
59+
}
60+
61+
#[test]
62+
fn test_fee_estimate_calculation() {
63+
use crate::types::FeeEstimate;
64+
65+
let fee = FeeEstimate {
66+
base_fee: 100,
67+
resource_fee: 1000,
68+
total_fee: 1100,
69+
surge_multiplier: 100,
70+
high_load: false,
71+
};
72+
73+
assert_eq!(fee.base_fee + fee.resource_fee, 1100);
74+
assert!(!fee.high_load);
75+
}
76+
77+
#[test]
78+
fn test_fee_estimate_with_surge() {
79+
use crate::types::FeeEstimate;
80+
81+
let fee = FeeEstimate {
82+
base_fee: 100,
83+
resource_fee: 1000,
84+
total_fee: 2200,
85+
surge_multiplier: 200,
86+
high_load: true,
87+
};
88+
89+
assert_eq!(fee.total_fee, (fee.base_fee + fee.resource_fee) * 2);
90+
assert!(fee.high_load);
91+
}
92+
}

0 commit comments

Comments
 (0)