From f20141c09b6f848942351fc0371c2d82450d29cc Mon Sep 17 00:00:00 2001
From: poka <poka@p2p.legal>
Date: Tue, 11 Mar 2025 17:53:02 +0100
Subject: [PATCH] wip

---
 Cargo.lock               |  54 ++++
 Cargo.toml               |   7 +-
 src/commands.rs          |   1 +
 src/commands/profile.rs  | 594 +++++++++++++++++++++++++++++++++++++++
 src/commands/transfer.rs |   2 +-
 src/commands/vault.rs    |   2 +-
 src/conf.rs              |  11 +-
 src/main.rs              |   4 +
 8 files changed, 671 insertions(+), 4 deletions(-)
 create mode 100644 src/commands/profile.rs

diff --git a/Cargo.lock b/Cargo.lock
index a9cd348..7c59f0c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1587,6 +1587,12 @@ dependencies = [
  "parking_lot_core",
 ]
 
+[[package]]
+name = "data-encoding"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "575f75dfd25738df5b91b8e43e14d44bda14637a58fae779fd2b064f8bf3e010"
+
 [[package]]
 name = "der"
 version = "0.7.9"
@@ -2361,12 +2367,16 @@ dependencies = [
  "rstest",
  "scrypt",
  "sea-orm",
+ "secp256k1",
  "serde",
  "serde_json",
+ "sha2 0.10.8",
  "sp-core",
  "sp-runtime",
  "subxt",
  "tokio",
+ "tokio-tungstenite",
+ "url",
 ]
 
 [[package]]
@@ -5172,6 +5182,7 @@ version = "0.28.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10"
 dependencies = [
+ "rand",
  "secp256k1-sys",
 ]
 
@@ -6742,6 +6753,22 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "tokio-tungstenite"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
+dependencies = [
+ "futures-util",
+ "log",
+ "rustls 0.22.4",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls 0.25.0",
+ "tungstenite",
+ "webpki-roots 0.26.6",
+]
+
 [[package]]
 name = "tokio-util"
 version = "0.7.12"
@@ -6969,6 +6996,27 @@ version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
 
+[[package]]
+name = "tungstenite"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
+dependencies = [
+ "byteorder",
+ "bytes",
+ "data-encoding",
+ "http 1.1.0",
+ "httparse",
+ "log",
+ "rand",
+ "rustls 0.22.4",
+ "rustls-pki-types",
+ "sha1",
+ "thiserror",
+ "url",
+ "utf-8",
+]
+
 [[package]]
 name = "twox-hash"
 version = "1.6.3"
@@ -7114,6 +7162,12 @@ dependencies = [
  "percent-encoding",
 ]
 
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.2"
diff --git a/Cargo.toml b/Cargo.toml
index ce66a64..ad58cef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -35,16 +35,21 @@ hex = "^0.4.3"
 log = "^0.4.22"
 reqwest = { version = "^0.11.27", default-features = false, features = [
     "rustls-tls",
+    "json",
 ] }
 inquire = "^0.7.5"
 serde = { version = "^1.0", features = ["derive"] }
 serde_json = "^1.0.128"
-tokio = { version = "^1.40.0", features = ["macros"] }
+tokio = { version = "^1.40.0", features = ["macros", "time"] }
+tokio-tungstenite = { version = "0.21.0", features = ["rustls-tls-webpki-roots"] }
+url = "2.5.0"
 confy = "^0.5.1"
 bs58 = "^0.5.1"
 directories = "^5.0.1"
 comfy-table = "^7.1.1"
 sea-orm = { version = "1.1.0", features = [ "sqlx-sqlite", "runtime-tokio-native-tls", "macros" ] }
+sha2 = "0.10.8"
+secp256k1 = { version = "0.28.2", features = ["rand", "recovery"] }
 
 # crypto
 scrypt = { version = "^0.11", default-features = false } # for old-style key generation
diff --git a/src/commands.rs b/src/commands.rs
index 15f96ba..a1e0dc8 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -8,6 +8,7 @@ pub mod expire;
 pub mod identity;
 pub mod net_test;
 pub mod oneshot;
+pub mod profile;
 pub mod publish;
 pub mod revocation;
 pub mod runtime;
diff --git a/src/commands/profile.rs b/src/commands/profile.rs
new file mode 100644
index 0000000..6996815
--- /dev/null
+++ b/src/commands/profile.rs
@@ -0,0 +1,594 @@
+use crate::*;
+use anyhow::anyhow;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use std::collections::HashMap;
+use std::time::{SystemTime, UNIX_EPOCH};
+use sp_core::crypto::Pair;
+use tokio_tungstenite::{connect_async, tungstenite::protocol::Message};
+use futures::{SinkExt, StreamExt};
+use url::Url;
+use sha2::{Sha256, Digest};
+use secp256k1::{Secp256k1, SecretKey, PublicKey, Message as Secp256k1Message};
+
+/// Derive a Nostr key from a Substrate key
+fn derive_nostr_keys_from_substrate(keypair: &KeyPair) -> Result<(SecretKey, PublicKey), GcliError> {
+    // Get the seed from the keypair
+    let seed = match keypair {
+        KeyPair::Sr25519(pair) => {
+            // Use the public key as seed for deterministic derivation
+            let mut hasher = Sha256::new();
+            hasher.update(&pair.public().0);
+            hasher.finalize().to_vec()
+        }
+        KeyPair::Ed25519(pair) => {
+            // Use the public key as seed for deterministic derivation
+            let mut hasher = Sha256::new();
+            hasher.update(&pair.public().0);
+            hasher.finalize().to_vec()
+        }
+    };
+    
+    // Create a secp256k1 secret key from the seed
+    let secp = Secp256k1::new();
+    let secret_key = SecretKey::from_slice(&seed[0..32])
+        .map_err(|e| anyhow!("Failed to create secp256k1 secret key: {}", e))?;
+    
+    // Derive the public key
+    let public_key = PublicKey::from_secret_key(&secp, &secret_key);
+    
+    Ok((secret_key, public_key))
+}
+
+/// Get Nostr public key in hex format
+fn get_nostr_pubkey(keypair: &KeyPair) -> Result<String, GcliError> {
+    let (_, public_key) = derive_nostr_keys_from_substrate(keypair)?;
+    // Nostr uses the x-only public key (32 bytes)
+    let serialized = public_key.serialize();
+    // Skip the first byte (format byte) and take only the x coordinate
+    Ok(hex::encode(&serialized[1..33]))
+}
+
+/// Profile subcommands
+#[derive(Clone, Debug, clap::Parser)]
+pub enum Subcommand {
+    /// Get Nostr profile data from a relay
+    Get {
+        /// Relay URL to fetch profile from
+        #[clap(short, long)]
+        relay: Option<String>,
+    },
+    /// Set Nostr profile data (NIP-01 kind 0 metadata)
+    Set {
+        /// Profile name
+        #[clap(short, long)]
+        name: Option<String>,
+        
+        /// Profile display name
+        #[clap(short = 'd', long)]
+        display_name: Option<String>,
+        
+        /// Profile picture URL
+        #[clap(short = 'p', long)]
+        picture: Option<String>,
+        
+        /// Profile about/description
+        #[clap(short, long)]
+        about: Option<String>,
+        
+        /// Profile website
+        #[clap(short = 'w', long)]
+        website: Option<String>,
+        
+        /// Profile NIP-05 identifier
+        #[clap(short = 'i', long)]
+        nip05: Option<String>,
+        
+        /// Relay URL to publish profile to
+        #[clap(short, long)]
+        relay: Option<String>,
+    },
+}
+
+/// Nostr profile metadata (NIP-01 kind 0)
+#[derive(Debug, Serialize, Deserialize)]
+pub struct NostrProfile {
+    pub name: Option<String>,
+    pub display_name: Option<String>,
+    pub picture: Option<String>,
+    pub about: Option<String>,
+    pub website: Option<String>,
+    pub nip05: Option<String>,
+    #[serde(flatten)]
+    pub additional_fields: HashMap<String, String>,
+}
+
+impl Default for NostrProfile {
+    fn default() -> Self {
+        Self {
+            name: None,
+            display_name: None,
+            picture: None,
+            about: None,
+            website: None,
+            nip05: None,
+            additional_fields: HashMap::new(),
+        }
+    }
+}
+
+/// Nostr event structure
+#[derive(Debug, Serialize, Deserialize)]
+pub struct NostrEvent {
+    pub id: String,
+    pub pubkey: String,
+    pub created_at: u64,
+    pub kind: u32,
+    pub tags: Vec<Vec<String>>,
+    pub content: String,
+    pub sig: String,
+}
+
+impl NostrEvent {
+    /// Create a new unsigned Nostr event
+    pub fn new(pubkey: String, kind: u32, content: String) -> Self {
+        let created_at = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .unwrap()
+            .as_secs();
+        
+        Self {
+            id: String::new(), // Will be set after serialization
+            pubkey,
+            created_at,
+            kind,
+            tags: Vec::new(),
+            content,
+            sig: String::new(), // Will be set after signing
+        }
+    }
+    
+    /// Calculate the event ID (SHA-256 hash of the serialized event)
+    pub fn calculate_id(&mut self) -> Result<(), GcliError> {
+        // Create a temporary event for serialization
+        let temp_event = json!([
+            0,
+            self.pubkey,
+            self.created_at,
+            self.kind,
+            self.tags,
+            self.content
+        ]);
+        
+        // Serialize the event
+        let serialized = serde_json::to_string(&temp_event)
+            .map_err(|e| anyhow!("Failed to serialize event: {}", e))?;
+        
+        // Calculate SHA-256 hash
+        let mut hasher = Sha256::new();
+        hasher.update(serialized.as_bytes());
+        let result = hasher.finalize();
+        
+        // Set the ID
+        self.id = hex::encode(result);
+        
+        Ok(())
+    }
+    
+    /// Sign the event with the given keypair
+    pub fn sign(&mut self, keypair: &KeyPair) -> Result<(), GcliError> {
+        // Calculate ID if not already set
+        if self.id.is_empty() {
+            self.calculate_id()?;
+        }
+        
+        // Derive Nostr keys from Substrate keypair
+        let (secret_key, _) = derive_nostr_keys_from_substrate(keypair)?;
+        
+        // Create a secp256k1 context
+        let secp = Secp256k1::new();
+        
+        // Create a message from the event ID
+        let message = Secp256k1Message::from_digest_slice(&hex::decode(&self.id)
+            .map_err(|e| anyhow!("Failed to decode hex: {}", e))?)
+            .map_err(|e| anyhow!("Failed to create secp256k1 message: {}", e))?;
+        
+        // Sign the message with ECDSA (not Schnorr)
+        let signature = secp.sign_ecdsa(&message, &secret_key);
+        
+        // Set the signature
+        self.sig = hex::encode(signature.serialize_compact());
+        
+        Ok(())
+    }
+}
+
+/// Handle profile commands
+pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliError> {
+    match command {
+        Subcommand::Get { relay } => get_profile(data, relay).await,
+        Subcommand::Set {
+            name,
+            display_name,
+            picture,
+            about,
+            website,
+            nip05,
+            relay,
+        } => {
+            set_profile(
+                data,
+                name,
+                display_name,
+                picture,
+                about,
+                website,
+                nip05,
+                relay,
+            )
+            .await
+        }
+    }
+}
+
+/// Get Nostr profile from a relay
+async fn get_profile(data: Data, relay_url: Option<String>) -> Result<(), GcliError> {
+    // Get keypair from vault using configured address
+    let keypair = fetch_or_get_keypair(&data, data.cfg.address.clone()).await?;
+    
+    // Get Nostr pubkey
+    let pubkey = get_nostr_pubkey(&keypair)?;
+    
+    // Use default relay if none provided
+    let relay = relay_url.unwrap_or_else(|| {
+        data.cfg
+            .relay
+            .clone()
+            .unwrap_or_else(|| "wss://relay.damus.io".to_string())
+    });
+    
+    // Ensure the relay URL starts with ws:// or wss://
+    let relay = if !relay.starts_with("ws://") && !relay.starts_with("wss://") {
+        format!("wss://{}", relay)
+    } else {
+        relay
+    };
+    
+    // Parse the URL
+    let url = Url::parse(&relay).map_err(|e| anyhow!("Invalid relay URL: {}", e))?;
+    
+    // Connect to the WebSocket
+    println!("Connecting to relay {}...", relay);
+    let (mut ws_stream, _) = connect_async(url).await
+        .map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
+    
+    println!("Connected to relay, requesting profile...");
+    
+    // Create subscription request
+    let req = json!({
+        "id": "profile-request",
+        "kinds": [0],
+        "authors": [pubkey],
+        "limit": 1
+    });
+    
+    // Send the request
+    let req_msg = json!({
+        "REQ": "profile-request",
+        "filters": [req]
+    });
+    
+    ws_stream.send(Message::Text(req_msg.to_string())).await
+        .map_err(|e| anyhow!("Failed to send request: {}", e))?;
+    
+    // Wait for response with timeout
+    let mut profile_found = false;
+    let mut profile = NostrProfile::default();
+    
+    // Set a timeout for receiving messages
+    let timeout = tokio::time::Duration::from_secs(5);
+    let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
+    
+    let start_time = tokio::time::Instant::now();
+    
+    while start_time.elapsed() < timeout {
+        interval.tick().await;
+        
+        // Check for messages with a timeout
+        match tokio::time::timeout(
+            tokio::time::Duration::from_millis(100),
+            ws_stream.next()
+        ).await {
+            Ok(Some(Ok(msg))) => {
+                if let Message::Text(text) = msg {
+                    log::debug!("Received message: {}", text);
+                    
+                    // Parse the message
+                    if let Ok(json) = serde_json::from_str::<Value>(&text) {
+                        // Check if it's an EVENT message
+                        if let Some(event_type) = json.get(0).and_then(|v| v.as_str()) {
+                            if event_type == "EVENT" && json.get(1).is_some() && json.get(2).is_some() {
+                                if let Some(content) = json[2]["content"].as_str() {
+                                    // Try to parse the profile
+                                    if let Ok(parsed_profile) = serde_json::from_str::<NostrProfile>(content) {
+                                        profile = parsed_profile;
+                                        profile_found = true;
+                                        
+                                        // Close the subscription
+                                        let close_msg = json!({
+                                            "CLOSE": "profile-request"
+                                        });
+                                        ws_stream.send(Message::Text(close_msg.to_string())).await
+                                            .map_err(|e| anyhow!("Failed to close subscription: {}", e))?;
+                                        
+                                        break;
+                                    }
+                                }
+                            } else if event_type == "EOSE" {
+                                // End of stored events
+                                if !profile_found {
+                                    // No profile found, close the connection
+                                    let close_msg = json!({
+                                        "CLOSE": "profile-request"
+                                    });
+                                    ws_stream.send(Message::Text(close_msg.to_string())).await
+                                        .map_err(|e| anyhow!("Failed to close subscription: {}", e))?;
+                                    
+                                    break;
+                                }
+                            }
+                        }
+                    }
+                }
+            },
+            Ok(Some(Err(e))) => {
+                log::error!("WebSocket error: {}", e);
+                break;
+            },
+            Ok(None) => {
+                // Connection closed
+                break;
+            },
+            Err(_) => {
+                // Timeout, continue
+                continue;
+            }
+        }
+    }
+    
+    // Close the WebSocket connection
+    ws_stream.close(None).await.ok();
+    
+    // Display the profile or a message if not found
+    if profile_found {
+        match data.args.output_format {
+            OutputFormat::Human => {
+                println!("Profile for pubkey: {}", pubkey);
+                if let Some(name) = &profile.name {
+                    println!("Name: {}", name);
+                }
+                if let Some(display_name) = &profile.display_name {
+                    println!("Display Name: {}", display_name);
+                }
+                if let Some(picture) = &profile.picture {
+                    println!("Picture: {}", picture);
+                }
+                if let Some(about) = &profile.about {
+                    println!("About: {}", about);
+                }
+                if let Some(website) = &profile.website {
+                    println!("Website: {}", website);
+                }
+                if let Some(nip05) = &profile.nip05 {
+                    println!("NIP-05: {}", nip05);
+                }
+                for (key, value) in &profile.additional_fields {
+                    println!("{}: {}", key, value);
+                }
+            }
+            OutputFormat::Json => {
+                println!("{}", serde_json::to_string_pretty(&profile).unwrap());
+            }
+        }
+    } else {
+        match data.args.output_format {
+            OutputFormat::Human => {
+                println!("No profile found for pubkey: {}", pubkey);
+            }
+            OutputFormat::Json => {
+                println!("{}", serde_json::to_string_pretty(&NostrProfile::default()).unwrap());
+            }
+        }
+    }
+    
+    Ok(())
+}
+
+/// Set Nostr profile and publish to a relay
+async fn set_profile(
+    data: Data,
+    name: Option<String>,
+    display_name: Option<String>,
+    picture: Option<String>,
+    about: Option<String>,
+    website: Option<String>,
+    nip05: Option<String>,
+    relay_url: Option<String>,
+) -> Result<(), GcliError> {
+    // Get keypair from vault using configured address
+    let keypair = fetch_or_get_keypair(&data, data.cfg.address.clone()).await?;
+    
+    // Get Nostr pubkey
+    let pubkey = get_nostr_pubkey(&keypair)?;
+    
+    // Create profile data
+    let mut profile = NostrProfile::default();
+    
+    if let Some(name) = name {
+        profile.name = Some(name);
+    }
+    if let Some(display_name) = display_name {
+        profile.display_name = Some(display_name);
+    }
+    if let Some(picture) = picture {
+        profile.picture = Some(picture);
+    }
+    if let Some(about) = about {
+        profile.about = Some(about);
+    }
+    if let Some(website) = website {
+        profile.website = Some(website);
+    }
+    if let Some(nip05) = nip05 {
+        profile.nip05 = Some(nip05);
+    }
+    
+    // Serialize profile to JSON
+    let profile_json = serde_json::to_string(&profile)
+        .map_err(|e| anyhow!("Failed to serialize profile: {}", e))?;
+    
+    // Create and sign Nostr event
+    let mut event = NostrEvent::new(pubkey.clone(), 0, profile_json);
+    event.calculate_id()?;
+    event.sign(&keypair)?;
+    
+    // Use default relay if none provided
+    let relay = relay_url.unwrap_or_else(|| {
+        data.cfg
+            .relay
+            .clone()
+            .unwrap_or_else(|| "wss://relay.damus.io".to_string())
+    });
+    
+    // Ensure the relay URL starts with ws:// or wss://
+    let relay = if !relay.starts_with("ws://") && !relay.starts_with("wss://") {
+        format!("wss://{}", relay)
+    } else {
+        relay
+    };
+    
+    // Parse the URL
+    let url = Url::parse(&relay).map_err(|e| anyhow!("Invalid relay URL: {}", e))?;
+    
+    // Connect to the WebSocket
+    println!("Connecting to relay {}...", relay);
+    let (mut ws_stream, _) = connect_async(url).await
+        .map_err(|e| anyhow!("Failed to connect to relay: {}", e))?;
+    
+    println!("Connected to relay, publishing profile...");
+    
+    // Create event message
+    let event_msg = json!([
+        "EVENT",
+        event
+    ]);
+    
+    // Send the event
+    ws_stream.send(Message::Text(event_msg.to_string())).await
+        .map_err(|e| anyhow!("Failed to send event: {}", e))?;
+    
+    // Wait for OK message with timeout
+    let mut success = false;
+    let mut response_message = String::new();
+    
+    // Set a timeout for receiving messages
+    let timeout = tokio::time::Duration::from_secs(5);
+    let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(100));
+    
+    let start_time = tokio::time::Instant::now();
+    
+    while start_time.elapsed() < timeout {
+        interval.tick().await;
+        
+        // Check for messages with a timeout
+        match tokio::time::timeout(
+            tokio::time::Duration::from_millis(100),
+            ws_stream.next()
+        ).await {
+            Ok(Some(Ok(msg))) => {
+                if let Message::Text(text) = msg {
+                    log::debug!("Received message: {}", text);
+                    response_message = text.clone();
+                    
+                    // Parse the message
+                    if let Ok(json) = serde_json::from_str::<Value>(&text) {
+                        // Check if it's an OK message
+                        if let Some(msg_type) = json.get(0).and_then(|v| v.as_str()) {
+                            if msg_type == "OK" && json.get(1).is_some() && json.get(2).is_some() {
+                                let success_status = json[2].as_bool().unwrap_or(false);
+                                if success_status {
+                                    success = true;
+                                    break;
+                                } else {
+                                    // Error message
+                                    if let Some(error_msg) = json.get(3).and_then(|v| v.as_str()) {
+                                        return Err(anyhow!("Relay rejected event: {}", error_msg).into());
+                                    }
+                                    break;
+                                }
+                            }
+                        }
+                    }
+                }
+            },
+            Ok(Some(Err(e))) => {
+                log::error!("WebSocket error: {}", e);
+                break;
+            },
+            Ok(None) => {
+                // Connection closed
+                break;
+            },
+            Err(_) => {
+                // Timeout, continue
+                continue;
+            }
+        }
+    }
+    
+    // Close the WebSocket connection
+    ws_stream.close(None).await.ok();
+    
+    match data.args.output_format {
+        OutputFormat::Human => {
+            println!("Profile data published:");
+            if let Some(name) = &profile.name {
+                println!("Name: {}", name);
+            }
+            if let Some(display_name) = &profile.display_name {
+                println!("Display Name: {}", display_name);
+            }
+            if let Some(picture) = &profile.picture {
+                println!("Picture: {}", picture);
+            }
+            if let Some(about) = &profile.about {
+                println!("About: {}", about);
+            }
+            if let Some(website) = &profile.website {
+                println!("Website: {}", website);
+            }
+            if let Some(nip05) = &profile.nip05 {
+                println!("NIP-05: {}", nip05);
+            }
+            
+            println!("\nPublished to relay: {}", relay);
+            if success {
+                println!("Status: Success");
+            } else {
+                println!("Status: No confirmation received from relay");
+                println!("Last response: {}", response_message);
+            }
+        }
+        OutputFormat::Json => {
+            println!("{}", serde_json::to_string_pretty(&json!({
+                "profile": profile,
+                "event": event,
+                "relay": relay,
+                "success": success,
+                "response": response_message
+            })).unwrap());
+        }
+    }
+    
+    Ok(())
+} 
\ No newline at end of file
diff --git a/src/commands/transfer.rs b/src/commands/transfer.rs
index 16f2e5e..1a97717 100644
--- a/src/commands/transfer.rs
+++ b/src/commands/transfer.rs
@@ -1,6 +1,6 @@
 use crate::*;
 
-#[cfg(any(feature = "dev", feature = "gdev"))] // find how to get runtime calls
+#[cfg(feature = "gdev")] // find how to get runtime calls
 type Call = runtime::runtime_types::gdev_runtime::RuntimeCall;
 type BalancesCall = runtime::runtime_types::pallet_balances::pallet::Call;
 
diff --git a/src/commands/vault.rs b/src/commands/vault.rs
index 8d6de10..d734879 100644
--- a/src/commands/vault.rs
+++ b/src/commands/vault.rs
@@ -148,7 +148,7 @@ pub fn decrypt(input: &[u8], passphrase: String) -> Result<Vec<u8>, age::Decrypt
 pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliError> {
 	let db = data.connect_db();
 
-	// match subcommand
+	#[allow(deprecated)]
 	match command {
 		Subcommand::List(choice) => match choice {
 			ListChoice::All => {
diff --git a/src/conf.rs b/src/conf.rs
index 509e0c5..7735879 100644
--- a/src/conf.rs
+++ b/src/conf.rs
@@ -15,6 +15,8 @@ pub struct Config {
 	/// user address
 	/// to perform actions, user must provide secret
 	pub address: Option<AccountId>,
+	/// nostr relay endpoint
+	pub relay: Option<String>,
 }
 
 impl std::default::Default for Config {
@@ -23,6 +25,7 @@ impl std::default::Default for Config {
 			duniter_endpoint: String::from(data::LOCAL_DUNITER_ENDPOINT),
 			indexer_endpoint: String::from(data::LOCAL_INDEXER_ENDPOINT),
 			address: None,
+			relay: Some(String::from("wss://relay.damus.io")),
 		}
 	}
 }
@@ -34,10 +37,16 @@ impl std::fmt::Display for Config {
 		} else {
 			"(no address)".to_string()
 		};
+		let relay = if let Some(relay) = &self.relay {
+			relay.clone()
+		} else {
+			"(no relay)".to_string()
+		};
 		writeln!(f, "Äžcli config")?;
 		writeln!(f, "duniter endpoint {}", self.duniter_endpoint)?;
 		writeln!(f, "indexer endpoint {}", self.indexer_endpoint)?;
-		write!(f, "address {address}")
+		writeln!(f, "address {address}")?;
+		write!(f, "nostr relay {relay}")
 	}
 }
 
diff --git a/src/main.rs b/src/main.rs
index 116552d..3f8b9fc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -149,6 +149,9 @@ pub enum Subcommand {
 	/// Key management (import, generate, list...)
 	#[clap(subcommand)]
 	Vault(commands::vault::Subcommand),
+	/// Nostr profile management (get, set...)
+	#[clap(subcommand)]
+	Profile(commands::profile::Subcommand),
 	/// Cesium
 	#[clap(subcommand, hide = true)]
 	Cesium(commands::cesium::Subcommand),
@@ -190,6 +193,7 @@ async fn main() -> Result<(), GcliError> {
 		Subcommand::Indexer(subcommand) => indexer::handle_command(data, subcommand).await,
 		Subcommand::Config(subcommand) => conf::handle_command(data, subcommand).await,
 		Subcommand::Vault(subcommand) => commands::vault::handle_command(data, subcommand).await,
+		Subcommand::Profile(subcommand) => commands::profile::handle_command(data, subcommand).await,
 		Subcommand::Cesium(subcommand) => commands::cesium::handle_command(data, subcommand).await,
 		Subcommand::Publish => commands::publish::handle_command().await,
 	};
-- 
GitLab