Skip to content

Commit 4ac3a08

Browse files
committed
feat: implement relay calculation algorithm with tests
1 parent 8e66d4c commit 4ac3a08

File tree

3 files changed

+223
-1
lines changed

3 files changed

+223
-1
lines changed

crates/bcr-ebill-transport/src/handler/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ mod test_utils {
507507
impl NostrContactStoreApi for NostrContactStore {
508508
async fn by_node_id(&self, node_id: &NodeId) -> Result<Option<NostrContact>>;
509509
async fn by_node_ids(&self, node_ids: Vec<NodeId>) -> Result<Vec<NostrContact>>;
510+
async fn get_all(&self) -> Result<Vec<NostrContact>>;
510511
async fn by_npub(&self, npub: &NostrPublicKey) -> Result<Option<NostrContact>>;
511512
async fn upsert(&self, data: &NostrContact) -> Result<()>;
512513
async fn delete(&self, node_id: &NodeId) -> Result<()>;

crates/bcr-ebill-transport/src/nostr.rs

Lines changed: 220 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use nostr_sdk::{
2020
PublicKey, RelayPoolNotification, RelayUrl, SingleLetterTag, TagKind, TagStandard, ToBech32,
2121
};
2222
use std::sync::{Arc, Mutex, atomic::Ordering};
23-
use std::{collections::HashMap, sync::atomic::AtomicBool, time::Duration};
23+
use std::{collections::{HashMap, HashSet}, sync::atomic::AtomicBool, time::Duration};
2424

2525
use bcr_ebill_api::{
2626
constants::NOSTR_EVENT_TIME_SLACK,
@@ -1251,3 +1251,222 @@ mod tests {
12511251
tasks.abort_all();
12521252
}
12531253
}
1254+
1255+
/// Internal relay calculation function (pure function for testing)
1256+
fn calculate_relay_set_internal(
1257+
user_relays: &[url::Url],
1258+
contacts: &[bcr_ebill_core::application::nostr_contact::NostrContact],
1259+
max_relays: Option<usize>,
1260+
) -> HashSet<url::Url> {
1261+
use bcr_ebill_core::application::nostr_contact::TrustLevel;
1262+
use std::collections::HashSet;
1263+
1264+
let mut relay_set = HashSet::new();
1265+
1266+
// Pass 1: Add all user relays (exempt from limit)
1267+
for relay in user_relays {
1268+
relay_set.insert(relay.clone());
1269+
}
1270+
1271+
// Filter and sort contacts by trust level
1272+
let mut eligible_contacts: Vec<&bcr_ebill_core::application::nostr_contact::NostrContact> = contacts
1273+
.iter()
1274+
.filter(|c| matches!(c.trust_level, TrustLevel::Trusted | TrustLevel::Participant))
1275+
.collect();
1276+
1277+
// Sort: Trusted (0) before Participant (1)
1278+
eligible_contacts.sort_by_key(|c| match c.trust_level {
1279+
TrustLevel::Trusted => 0,
1280+
TrustLevel::Participant => 1,
1281+
_ => 2, // unreachable due to filter
1282+
});
1283+
1284+
let limit = max_relays.unwrap_or(usize::MAX);
1285+
1286+
// Pass 2: Add first relay from each contact (priority order)
1287+
for contact in &eligible_contacts {
1288+
if relay_set.len() >= limit {
1289+
break;
1290+
}
1291+
if let Some(first_relay) = contact.relays.first() {
1292+
relay_set.insert(first_relay.clone());
1293+
}
1294+
}
1295+
1296+
// Pass 3: Fill remaining slots with additional contact relays
1297+
for contact in &eligible_contacts {
1298+
for relay in contact.relays.iter().skip(1) {
1299+
if relay_set.len() >= limit {
1300+
return relay_set;
1301+
}
1302+
relay_set.insert(relay.clone());
1303+
}
1304+
}
1305+
1306+
relay_set
1307+
}
1308+
1309+
#[cfg(test)]
1310+
mod relay_calculation_tests {
1311+
use super::*;
1312+
use bcr_ebill_core::application::nostr_contact::{NostrContact, TrustLevel, HandshakeStatus, NostrPublicKey};
1313+
use std::collections::HashSet;
1314+
1315+
fn create_test_contact(trust_level: TrustLevel, relays: Vec<&str>) -> NostrContact {
1316+
use bcr_ebill_core::protocol::crypto::BcrKeys;
1317+
let keys = BcrKeys::new();
1318+
let node_id = NodeId::new(keys.pub_key(), bitcoin::Network::Testnet);
1319+
NostrContact {
1320+
npub: node_id.npub(),
1321+
node_id,
1322+
name: None,
1323+
relays: relays.iter().map(|r| url::Url::parse(r).unwrap()).collect(),
1324+
trust_level,
1325+
handshake_status: HandshakeStatus::None,
1326+
contact_private_key: None,
1327+
}
1328+
}
1329+
1330+
#[test]
1331+
fn test_user_relays_always_included() {
1332+
let user_relays = vec![
1333+
url::Url::parse("wss://relay1.com").unwrap(),
1334+
url::Url::parse("wss://relay2.com").unwrap(),
1335+
];
1336+
let contacts = vec![];
1337+
let max_relays = Some(1); // Very low limit
1338+
1339+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1340+
1341+
// User relays should all be present despite low limit
1342+
assert_eq!(result.len(), 2);
1343+
assert!(result.contains(&url::Url::parse("wss://relay1.com").unwrap()));
1344+
assert!(result.contains(&url::Url::parse("wss://relay2.com").unwrap()));
1345+
}
1346+
1347+
#[test]
1348+
fn test_trusted_contacts_prioritized() {
1349+
let user_relays = vec![];
1350+
let contacts = vec![
1351+
create_test_contact(TrustLevel::Participant, vec!["wss://participant.com"]),
1352+
create_test_contact(TrustLevel::Trusted, vec!["wss://trusted.com"]),
1353+
];
1354+
let max_relays = Some(1);
1355+
1356+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1357+
1358+
// Should only include trusted contact's relay (higher priority)
1359+
assert_eq!(result.len(), 1);
1360+
assert!(result.contains(&url::Url::parse("wss://trusted.com").unwrap()));
1361+
}
1362+
1363+
#[test]
1364+
fn test_one_relay_per_contact_guaranteed() {
1365+
let user_relays = vec![];
1366+
let contacts = vec![
1367+
create_test_contact(TrustLevel::Trusted, vec!["wss://contact1-relay1.com", "wss://contact1-relay2.com"]),
1368+
create_test_contact(TrustLevel::Trusted, vec!["wss://contact2-relay1.com", "wss://contact2-relay2.com"]),
1369+
create_test_contact(TrustLevel::Trusted, vec!["wss://contact3-relay1.com"]),
1370+
];
1371+
let max_relays = Some(3);
1372+
1373+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1374+
1375+
// Should have exactly 3 relays (first relay from each contact)
1376+
assert_eq!(result.len(), 3);
1377+
assert!(result.contains(&url::Url::parse("wss://contact1-relay1.com").unwrap()));
1378+
assert!(result.contains(&url::Url::parse("wss://contact2-relay1.com").unwrap()));
1379+
assert!(result.contains(&url::Url::parse("wss://contact3-relay1.com").unwrap()));
1380+
}
1381+
1382+
#[test]
1383+
fn test_deduplication_across_contacts() {
1384+
let user_relays = vec![];
1385+
let contacts = vec![
1386+
create_test_contact(TrustLevel::Trusted, vec!["wss://shared.com", "wss://unique1.com"]),
1387+
create_test_contact(TrustLevel::Trusted, vec!["wss://shared.com", "wss://unique2.com"]),
1388+
];
1389+
let max_relays = Some(10);
1390+
1391+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1392+
1393+
// Should only include shared.com once
1394+
assert_eq!(result.len(), 3);
1395+
assert!(result.contains(&url::Url::parse("wss://shared.com").unwrap()));
1396+
assert!(result.contains(&url::Url::parse("wss://unique1.com").unwrap()));
1397+
assert!(result.contains(&url::Url::parse("wss://unique2.com").unwrap()));
1398+
}
1399+
1400+
#[test]
1401+
fn test_banned_contacts_excluded() {
1402+
let user_relays = vec![];
1403+
let contacts = vec![
1404+
create_test_contact(TrustLevel::Banned, vec!["wss://banned.com"]),
1405+
create_test_contact(TrustLevel::Trusted, vec!["wss://trusted.com"]),
1406+
];
1407+
let max_relays = Some(10);
1408+
1409+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1410+
1411+
assert_eq!(result.len(), 1);
1412+
assert!(result.contains(&url::Url::parse("wss://trusted.com").unwrap()));
1413+
assert!(!result.contains(&url::Url::parse("wss://banned.com").unwrap()));
1414+
}
1415+
1416+
#[test]
1417+
fn test_none_trust_level_excluded() {
1418+
let user_relays = vec![];
1419+
let contacts = vec![
1420+
create_test_contact(TrustLevel::None, vec!["wss://unknown.com"]),
1421+
create_test_contact(TrustLevel::Participant, vec!["wss://participant.com"]),
1422+
];
1423+
let max_relays = Some(10);
1424+
1425+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1426+
1427+
assert_eq!(result.len(), 1);
1428+
assert!(result.contains(&url::Url::parse("wss://participant.com").unwrap()));
1429+
assert!(!result.contains(&url::Url::parse("wss://unknown.com").unwrap()));
1430+
}
1431+
1432+
#[test]
1433+
fn test_no_limit_when_max_relays_none() {
1434+
let user_relays = vec![url::Url::parse("wss://user.com").unwrap()];
1435+
let contacts = vec![
1436+
create_test_contact(TrustLevel::Trusted, vec!["wss://relay1.com", "wss://relay2.com"]),
1437+
create_test_contact(TrustLevel::Trusted, vec!["wss://relay3.com", "wss://relay4.com"]),
1438+
];
1439+
let max_relays = None;
1440+
1441+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1442+
1443+
// All relays should be included
1444+
assert_eq!(result.len(), 5);
1445+
}
1446+
1447+
#[test]
1448+
fn test_empty_contacts() {
1449+
let user_relays = vec![url::Url::parse("wss://user.com").unwrap()];
1450+
let contacts = vec![];
1451+
let max_relays = Some(50);
1452+
1453+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1454+
1455+
assert_eq!(result.len(), 1);
1456+
assert!(result.contains(&url::Url::parse("wss://user.com").unwrap()));
1457+
}
1458+
1459+
#[test]
1460+
fn test_contact_with_no_relays() {
1461+
let user_relays = vec![];
1462+
let mut contact = create_test_contact(TrustLevel::Trusted, vec![]);
1463+
contact.relays = vec![]; // Explicitly no relays
1464+
let contacts = vec![contact];
1465+
let max_relays = Some(10);
1466+
1467+
let result = calculate_relay_set_internal(&user_relays, &contacts, max_relays);
1468+
1469+
// Should handle gracefully
1470+
assert_eq!(result.len(), 0);
1471+
}
1472+
}

crates/bcr-ebill-transport/src/test_utils.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ pub fn init_test_cfg() {
134134
nostr_config: bcr_ebill_api::NostrConfig {
135135
only_known_contacts: false,
136136
relays: vec![url::Url::parse("ws://localhost:8080").unwrap()],
137+
max_relays: Some(50),
137138
},
138139
mint_config: bcr_ebill_api::MintConfig {
139140
default_mint_url: url::Url::parse("http://localhost:4242/").unwrap(),
@@ -842,6 +843,7 @@ mockall::mock! {
842843
impl NostrContactStoreApi for NostrContactStore {
843844
async fn by_node_id(&self, node_id: &NodeId) -> bcr_ebill_persistence::Result<Option<NostrContact>>;
844845
async fn by_node_ids(&self, node_ids: Vec<NodeId>) -> bcr_ebill_persistence::Result<Vec<NostrContact>>;
846+
async fn get_all(&self) -> bcr_ebill_persistence::Result<Vec<NostrContact>>;
845847
async fn by_npub(&self, npub: &NostrPublicKey) -> bcr_ebill_persistence::Result<Option<NostrContact>>;
846848
async fn upsert(&self, data: &NostrContact) -> bcr_ebill_persistence::Result<()>;
847849
async fn delete(&self, node_id: &NodeId) -> bcr_ebill_persistence::Result<()>;

0 commit comments

Comments
 (0)