fedimint-client 0.12.0-beta.2

Library for sending transactions to the Fedimint federation.
Documentation
use std::collections::BTreeMap;

use fedimint_core::config::{ClientConfig, GlobalClientConfig, PeerUrl};
use fedimint_core::db::mem_impl::MemDatabase;
use fedimint_core::db::{Database, IDatabaseTransactionOpsCoreTyped};
use fedimint_core::module::CORE_CONSENSUS_VERSION;
use fedimint_core::module::registry::ModuleRegistry;
use fedimint_core::net::guardian_metadata::GuardianMetadata;
use fedimint_core::secp256k1::rand;
use fedimint_core::util::SafeUrl;
use fedimint_core::{PeerId, secp256k1};
use serde::Deserialize;

use super::get_api_urls;
use crate::guardian_metadata::GuardianMetadataKey;

const STABLE_ENDPOINT_ID: &str = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
const NEXT_ENDPOINT_ID: &str = "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c";

fn test_config(peer_id: PeerId, url: SafeUrl) -> ClientConfig {
    ClientConfig {
        global: GlobalClientConfig {
            api_endpoints: BTreeMap::from([(
                peer_id,
                PeerUrl {
                    url,
                    name: "peer".to_owned(),
                },
            )]),
            broadcast_public_keys: None,
            consensus_version: CORE_CONSENSUS_VERSION,
            meta: BTreeMap::new(),
        },
        modules: BTreeMap::new(),
    }
}

async fn insert_guardian_metadata(
    db: &Database,
    peer_id: PeerId,
    api_url: SafeUrl,
    endpoint: Option<&str>,
) {
    let ctx = secp256k1::Secp256k1::new();
    let keypair = secp256k1::Keypair::new(&ctx, &mut rand::thread_rng());
    let mut metadata = GuardianMetadata::new(vec![api_url], "pkarr-id".to_owned(), 1);
    metadata.iroh_next_endpoint = endpoint.map(str::to_owned);

    let signed = metadata.sign(&ctx, &keypair);
    let mut dbtx = db.begin_transaction().await;
    dbtx.insert_entry(&GuardianMetadataKey(peer_id), &signed)
        .await;
    dbtx.commit_tx().await;
}

#[tokio::test]
async fn advertised_iroh_next_endpoint_rewrites_iroh_url_to_metadata_endpoint() {
    let db = Database::new(MemDatabase::new(), ModuleRegistry::default());
    let peer_id = PeerId::from(0);
    let stable_url = SafeUrl::parse(&format!("iroh://{STABLE_ENDPOINT_ID}")).expect("valid URL");
    let next_endpoint = NEXT_ENDPOINT_ID;
    insert_guardian_metadata(&db, peer_id, stable_url.clone(), Some(next_endpoint)).await;

    let urls = get_api_urls(&db, &test_config(peer_id, stable_url), true).await;

    assert_eq!(
        urls.get(&peer_id),
        Some(&SafeUrl::parse(&format!("iroh://{next_endpoint}/v1")).expect("valid URL"))
    );
}

#[tokio::test]
async fn missing_endpoint_or_disabled_iroh_next_does_not_rewrite_iroh_url() {
    let stable_url = SafeUrl::parse(&format!("iroh://{STABLE_ENDPOINT_ID}")).expect("valid URL");
    let peer_id = PeerId::from(0);

    for (advertised_endpoint, client_iroh_next_enabled) in
        [(None, true), (Some(NEXT_ENDPOINT_ID), false)]
    {
        let db = Database::new(MemDatabase::new(), ModuleRegistry::default());
        insert_guardian_metadata(&db, peer_id, stable_url.clone(), advertised_endpoint).await;

        let urls = get_api_urls(
            &db,
            &test_config(peer_id, stable_url.clone()),
            client_iroh_next_enabled,
        )
        .await;

        assert_eq!(urls.get(&peer_id), Some(&stable_url));
    }
}

#[tokio::test]
async fn non_iroh_url_is_not_rewritten_to_advertised_iroh_next_endpoint() {
    let db = Database::new(MemDatabase::new(), ModuleRegistry::default());
    let peer_id = PeerId::from(0);
    let stable_url = SafeUrl::parse("wss://example.com/ws/").expect("valid URL");
    insert_guardian_metadata(&db, peer_id, stable_url.clone(), Some(NEXT_ENDPOINT_ID)).await;

    let urls = get_api_urls(&db, &test_config(peer_id, stable_url.clone()), true).await;

    assert_eq!(urls.get(&peer_id), Some(&stable_url));
}

#[tokio::test]
async fn malformed_advertised_iroh_next_endpoint_does_not_fall_back() {
    let db = Database::new(MemDatabase::new(), ModuleRegistry::default());
    let peer_id = PeerId::from(0);
    let stable_url = SafeUrl::parse(&format!("iroh://{STABLE_ENDPOINT_ID}")).expect("valid URL");
    insert_guardian_metadata(&db, peer_id, stable_url.clone(), Some("not-an-endpoint-id")).await;

    let urls = get_api_urls(&db, &test_config(peer_id, stable_url), true).await;

    assert!(!urls.contains_key(&peer_id));
}

#[test]
fn legacy_guardian_metadata_ignores_next_endpoint_and_keeps_stable_url() {
    #[derive(Deserialize)]
    struct LegacyGuardianMetadata {
        api_urls: Vec<SafeUrl>,
        pkarr_id_z32: String,
        timestamp_secs: u64,
    }

    let stable_url = SafeUrl::parse(&format!("iroh://{STABLE_ENDPOINT_ID}")).expect("valid URL");
    let metadata = GuardianMetadata::new(vec![stable_url.clone()], "pkarr-id".to_owned(), 1)
        .with_iroh_next_endpoint(NEXT_ENDPOINT_ID.to_owned());

    let legacy: LegacyGuardianMetadata =
        serde_json::from_slice(&serde_json::to_vec(&metadata).expect("serializes"))
            .expect("legacy clients ignore the new optional field");

    assert_eq!(legacy.api_urls, vec![stable_url]);
    assert_eq!(legacy.pkarr_id_z32, "pkarr-id");
    assert_eq!(legacy.timestamp_secs, 1);
}

#[test]
fn current_guardian_metadata_accepts_legacy_payload_without_next_endpoint() {
    let stable_url = SafeUrl::parse(&format!("iroh://{STABLE_ENDPOINT_ID}")).expect("valid URL");
    let legacy_payload = serde_json::json!({
        "api_urls": [stable_url],
        "pkarr_id_z32": "pkarr-id",
        "timestamp_secs": 1,
    });

    let metadata: GuardianMetadata =
        serde_json::from_value(legacy_payload).expect("legacy metadata remains valid");

    assert_eq!(metadata.iroh_next_endpoint, None);
}