Skip to content

Commit 0f668c9

Browse files
committed
docs: update design doc with implementation status
1 parent f9be33d commit 0f668c9

File tree

1 file changed

+282
-0
lines changed

1 file changed

+282
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
# Dynamic Relay Management Design
2+
3+
**Date:** 2025-12-08
4+
**Status:** ✅ Implemented
5+
6+
## Implementation Status
7+
8+
**Completed:**
9+
- Added `max_relays` configuration field with default of 50
10+
- Implemented relay calculation algorithm with comprehensive tests (10 unit tests)
11+
- Added relay management methods to NostrClient (calculate_relay_set, update_relays, refresh_relays)
12+
- Integrated relay refresh on startup and contact updates
13+
- All tests passing (694 tests)
14+
15+
**Verification:**
16+
- `cargo test relay_calculation_tests` - 10/10 passing
17+
- `cargo test` - 694 tests passing
18+
- `cargo build` - Success
19+
20+
**Commits:**
21+
- 57d3539: feat: add get_all() method to NostrContactStoreApi
22+
- 8e66d4c: feat: add max_relays config field with default of 50
23+
- 4ac3a08: feat: implement relay calculation algorithm with tests
24+
- 4715817: fix: add max_relays to wasm config and clean up warnings
25+
- b4beef6: feat: add relay management methods and update NostrClient creation
26+
- f9be33d: feat: trigger relay refresh on contact updates
27+
28+
## Overview
29+
30+
Support multiple relays dynamically by connecting to both user-configured relays and relays from nostr contacts. Implement configurable limits with priority-based selection to ensure connectivity while preventing connection sprawl.
31+
32+
## Current State
33+
34+
**Already Working:**
35+
- Single Nostr client with multi-identity support
36+
- Private messages already go to contact-specific relays via `send_event_to()`
37+
- Contact relay storage in `NostrContact.relays`
38+
- Relay fetching via NIP-65 relay list events
39+
- Subscription filters based on contacts
40+
41+
⚠️ **Missing:**
42+
- No relay connection management for contact relays
43+
- No relay limits or deduplication
44+
- No dynamic relay updates when contacts change
45+
46+
## Goals
47+
48+
1. Connect to all user-configured relays (always)
49+
2. Connect to relays from trusted nostr contacts
50+
3. Deduplicate relays across contacts
51+
4. Enforce configurable upper limit on total relays
52+
5. Ensure at least one relay per contact when under limit
53+
6. Update relay connections when contacts change
54+
55+
## Architecture
56+
57+
### Component Changes
58+
59+
**1. Configuration (`NostrConfig` in `bcr-ebill-api/src/lib.rs`)**
60+
```rust
61+
pub struct NostrConfig {
62+
pub only_known_contacts: bool,
63+
pub relays: Vec<url::Url>,
64+
pub max_relays: Option<usize>, // NEW: defaults to Some(50)
65+
}
66+
```
67+
68+
**2. Relay Management (`NostrClient` in `bcr-ebill-transport/src/nostr.rs`)**
69+
70+
New methods:
71+
- `calculate_relay_set()` - computes complete relay set from user + contacts
72+
- `update_relays()` - syncs relay changes with nostr_sdk client
73+
- `refresh_relays()` - public trigger for relay recalculation
74+
75+
**3. Integration Points**
76+
- **Startup**: Calculate and apply relay set in `NostrClient::new()`
77+
- **Contact updates**: Trigger recalculation in `NostrContactProcessor` after upsert
78+
- **Subscription addition**: Trigger after `add_contact_subscription()`
79+
80+
### Data Flow
81+
82+
```
83+
NostrConfig.max_relays (50) + NostrConfig.relays (user relays)
84+
85+
NostrContactStore (all contacts with relays)
86+
87+
calculate_relay_set() applies two-pass algorithm:
88+
Pass 1: Add all user relays (always included)
89+
Pass 2: Add 1 relay per contact (Trusted > Participant priority)
90+
Pass 3: Fill remaining slots with additional contact relays
91+
92+
HashSet<Url> (deduplicated relay set)
93+
94+
update_relays() syncs with nostr_sdk Client
95+
```
96+
97+
## Relay Selection Algorithm
98+
99+
### Two-Pass Algorithm
100+
101+
```rust
102+
fn calculate_relay_set(
103+
user_relays: Vec<Url>,
104+
contacts: Vec<NostrContact>,
105+
max_relays: Option<usize>
106+
) -> HashSet<Url> {
107+
let mut relay_set = HashSet::new();
108+
109+
// Pass 1: Add all user relays (exempt from limit)
110+
for relay in user_relays {
111+
relay_set.insert(relay);
112+
}
113+
114+
// Filter and sort contacts by trust level
115+
let mut eligible_contacts: Vec<&NostrContact> = contacts.iter()
116+
.filter(|c| matches!(c.trust_level, TrustLevel::Trusted | TrustLevel::Participant))
117+
.collect();
118+
119+
// Sort: Trusted before Participant
120+
eligible_contacts.sort_by_key(|c| match c.trust_level {
121+
TrustLevel::Trusted => 0,
122+
TrustLevel::Participant => 1,
123+
_ => 2, // unreachable due to filter
124+
});
125+
126+
let limit = max_relays.unwrap_or(usize::MAX);
127+
128+
// Pass 2: Add first relay from each contact (priority order)
129+
for contact in &eligible_contacts {
130+
if relay_set.len() >= limit {
131+
break;
132+
}
133+
if let Some(first_relay) = contact.relays.first() {
134+
relay_set.insert(first_relay.clone());
135+
}
136+
}
137+
138+
// Pass 3: Fill remaining slots with additional contact relays
139+
for contact in &eligible_contacts {
140+
for relay in contact.relays.iter().skip(1) {
141+
if relay_set.len() >= limit {
142+
return relay_set;
143+
}
144+
relay_set.insert(relay.clone());
145+
}
146+
}
147+
148+
relay_set
149+
}
150+
```
151+
152+
### Trust Level Priority
153+
154+
**Included:**
155+
- `TrustLevel::Trusted` (priority 0) - Successful handshake contacts
156+
- `TrustLevel::Participant` (priority 1) - Encountered in bills (transitively trusted)
157+
158+
**Excluded:**
159+
- `TrustLevel::None` - Unknown contacts
160+
- `TrustLevel::Banned` - Actively blocked contacts
161+
162+
### Edge Cases
163+
164+
1. **No max_relays set**: Use `usize::MAX` - effectively unlimited
165+
2. **Contact with no relays**: Skipped gracefully
166+
3. **Duplicate relays across contacts**: `HashSet` automatically deduplicates
167+
4. **Max < user_relays.len()**: User relays still all added (exempt from limit)
168+
5. **More contacts than slots**: Each gets 1 relay up to limit
169+
6. **Banned/None trust contacts**: Filtered out before processing
170+
171+
### Guarantees
172+
173+
✓ All user relays always included (exempt from limit)
174+
✓ At least 1 relay per contact up to `limit - user_relays.len()`
175+
✓ Trusted contacts prioritized over Participants
176+
✓ No duplicate relays
177+
✓ Deterministic ordering (stable sort by trust level)
178+
179+
## Error Handling
180+
181+
### Failure Modes
182+
183+
**Relay calculation failures:**
184+
- `NostrContactStore` query fails → Log error, fall back to user relays only
185+
- Relay URL parsing fails → Skip invalid relay, continue with valid ones
186+
- `client.add_relay()` fails → Log warning, continue (relay may be unreachable)
187+
188+
**Graceful degradation:**
189+
- Empty contact list → Use only user relays
190+
- All contacts have no relays → Use only user relays
191+
- Network issues → Existing connections maintained
192+
193+
## Implementation Structure
194+
195+
### New Methods in `NostrClient`
196+
197+
```rust
198+
impl NostrClient {
199+
/// Calculate complete relay set from user config + contact relays
200+
async fn calculate_relay_set(&self) -> Result<HashSet<Url>>;
201+
202+
/// Sync relay set with nostr_sdk client (add new, remove old)
203+
async fn update_relays(&self, target_relays: HashSet<Url>) -> Result<()>;
204+
205+
/// Public trigger for external callers to refresh relay connections
206+
pub async fn refresh_relays(&self) -> Result<()>;
207+
}
208+
```
209+
210+
### Dependencies
211+
212+
- `NostrClient` needs access to `NostrContactStore` (pass reference or store during construction)
213+
- `NostrClient` needs `max_relays` config (store during construction)
214+
- Store user relays (already have in `self.relays`)
215+
216+
### Integration Points
217+
218+
**1. Startup** (`NostrClient::new()`)
219+
```rust
220+
// After creating client and adding initial relays
221+
let relay_set = self.calculate_relay_set().await?;
222+
self.update_relays(relay_set).await?;
223+
```
224+
225+
**2. Contact Updates** (`handler/nostr_contact_processor.rs`)
226+
```rust
227+
// After contact_store.upsert()
228+
nostr_client.refresh_relays().await?;
229+
```
230+
231+
**3. Contact Subscription** (`nostr.rs::add_contact_subscription()`)
232+
```rust
233+
// After adding subscription
234+
self.refresh_relays().await?;
235+
```
236+
237+
## Testing Considerations
238+
239+
### Unit Tests
240+
241+
- Relay calculation with various contact scenarios
242+
- Priority ordering (Trusted before Participant)
243+
- Limit enforcement and "1 per contact" guarantee
244+
- Deduplication across contacts
245+
- User relays exempt from limit
246+
- Edge cases (empty contacts, no relays, etc.)
247+
248+
### Integration Tests
249+
250+
- Relay updates triggered by contact changes
251+
- Subscription additions trigger relay refresh
252+
- Startup relay calculation with existing contacts
253+
254+
## Future Enhancements
255+
256+
- **Recency tracking**: Add `last_message_at` to `NostrContact` for recency-based prioritization
257+
- **Relay health monitoring**: Track relay connectivity and deprioritize failing relays
258+
- **Relay ownership tracking**: Map relays to contacts for better removal handling
259+
- **Dynamic limits**: Adjust limits based on bandwidth/connection constraints
260+
261+
## Configuration Example
262+
263+
```rust
264+
NostrConfig {
265+
only_known_contacts: true,
266+
relays: vec![
267+
"wss://relay.damus.io".parse()?,
268+
"wss://relay.nostr.band".parse()?,
269+
],
270+
max_relays: Some(50), // Up to 50 contact relays + 2 user relays = 52 total
271+
}
272+
```
273+
274+
## Summary
275+
276+
This design provides dynamic relay management that:
277+
- Ensures connectivity to user's own relays (always)
278+
- Extends reach to trusted contact relays (priority-based)
279+
- Prevents connection sprawl (configurable limits)
280+
- Maintains fairness (at least 1 relay per contact)
281+
- Updates reactively (on contact changes)
282+
- Degrades gracefully (falls back to user relays on errors)

0 commit comments

Comments
 (0)