|
| 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