Skip to content

create fc cli tool#902

Merged
topocount merged 6 commits into
mainfrom
kjs/neyn-11600-fc-cli
May 28, 2026
Merged

create fc cli tool#902
topocount merged 6 commits into
mainfrom
kjs/neyn-11600-fc-cli

Conversation

@topocount
Copy link
Copy Markdown
Contributor

create a cli tool for quickly testing functionality e2e from the client side

  • wip: cli
  • feat(cli): grpc event subscriptions
  • chore: add live-at subcommand and rename to fc

@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
snapchain-docs Ready Ready Preview, Comment May 28, 2026 8:30pm

Request Review

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 28, 2026

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • cli/src/eip712.rs (0.0%): Missing lines 34-38,40,42-46,48,50-60,63,65-72,75,77-83,86,88-91,94-98,100-111,114-115,117-129,132-133,135-148,151-152
  • cli/src/factory.rs (0.0%): Missing lines 17-25,27-33,35-37,39-48,54-74,76,78-92,94,101-117,119,145,147-170,172-185,187-204,206-207,209-220,222-227,229,231-254,256-262,264-269,271,273-293,295
  • cli/src/helpers.rs (0.0%): Missing lines 12-14,16-21,23-25
  • cli/src/main.rs (0.0%): Missing lines 257-265,267,270-278,280,290-294,296,322-335,337,340-353,355,359-361,363-371,373-381,383-390,392-398,400-412,416-423,428-450,452-459,461,463-466,470-481,483-486,488,490,492,495-497,500-502,504-514,516-518,520-527,529,531-548,552-557,561-563,565-574,576-586,588-597,603-605,607,609-614,617-621,624-632,634-638,640-643,649-656,658-663,665-666,668,672,674-680,682-686,688,693-696,698,701-703,705,708-722,728-730,734-735,738-749
  • src/network/http_server.rs (0.0%): Missing lines 2432

Summary

  • Total: 691 lines
  • Missing: 691 lines
  • Coverage: 0%

cli/src/eip712.rs

   30     pub nonce: u32,
   31     pub deadline: u32,
   32 }
   33 
!  34 fn key_eip712_domain(chain_id: u32) -> Value {
!  35     json!({
!  36         "name": KEY_DOMAIN_NAME,
!  37         "version": KEY_DOMAIN_VERSION,
!  38         "chainId": chain_id,
   39     })
!  40 }
   41 
!  42 fn eip712_domain_type() -> Value {
!  43     json!([
!  44         { "name": "name", "type": "string" },
!  45         { "name": "version", "type": "string" },
!  46         { "name": "chainId", "type": "uint256" },
   47     ])
!  48 }
   49 
!  50 fn key_add_types() -> Value {
!  51     json!({
!  52         "EIP712Domain": eip712_domain_type(),
!  53         "KeyAdd": [
!  54             { "name": "fid", "type": "uint256" },
!  55             { "name": "key", "type": "bytes" },
!  56             { "name": "keyType", "type": "uint32" },
!  57             { "name": "scopes", "type": "uint32[]" },
!  58             { "name": "ttl", "type": "uint32" },
!  59             { "name": "nonce", "type": "uint32" },
!  60             { "name": "deadline", "type": "uint256" },
   61         ],
   62     })
!  63 }
   64 
!  65 fn key_remove_types() -> Value {
!  66     json!({
!  67         "EIP712Domain": eip712_domain_type(),
!  68         "KeyRemove": [
!  69             { "name": "fid", "type": "uint256" },
!  70             { "name": "key", "type": "bytes" },
!  71             { "name": "nonce", "type": "uint32" },
!  72             { "name": "deadline", "type": "uint256" },
   73         ],
   74     })
!  75 }
   76 
!  77 fn signed_key_request_types() -> Value {
!  78     json!({
!  79         "EIP712Domain": eip712_domain_type(),
!  80         "SignedKeyRequest": [
!  81             { "name": "requestFid", "type": "uint256" },
!  82             { "name": "key", "type": "bytes" },
!  83             { "name": "deadline", "type": "uint256" },
   84         ],
   85     })
!  86 }
   87 
!  88 pub fn key_add_typed_data(
!  89     payload: &KeyAddPayload,
!  90     chain_id: u32,
!  91 ) -> Result<TypedData, serde_json::Error> {
   92     // scopes is `int32` on the wire but `uint32[]` in the signed payload. We don't accept
   93     // negative scope discriminants — proto only emits non-negative values for `MessageType`.
!  94     let scopes: Vec<u32> = payload
!  95         .scopes
!  96         .iter()
!  97         .map(|s| u32::try_from(*s).expect("MessageType discriminants are non-negative"))
!  98         .collect();
   99 
! 100     let json = json!({
! 101         "types": key_add_types(),
! 102         "primaryType": "KeyAdd",
! 103         "domain": key_eip712_domain(chain_id),
! 104         "message": {
! 105             "fid": payload.fid,
! 106             "key": format!("0x{}", hex::encode(payload.key)),
! 107             "keyType": payload.key_type,
! 108             "scopes": scopes,
! 109             "ttl": payload.ttl,
! 110             "nonce": payload.nonce,
! 111             "deadline": payload.deadline,
  112         },
  113     });
! 114     serde_json::from_value::<TypedData>(json)
! 115 }
  116 
! 117 pub fn key_remove_typed_data(
! 118     payload: &KeyRemovePayload,
! 119     chain_id: u32,
! 120 ) -> Result<TypedData, serde_json::Error> {
! 121     let json = json!({
! 122         "types": key_remove_types(),
! 123         "primaryType": "KeyRemove",
! 124         "domain": key_eip712_domain(chain_id),
! 125         "message": {
! 126             "fid": payload.fid,
! 127             "key": format!("0x{}", hex::encode(payload.key)),
! 128             "nonce": payload.nonce,
! 129             "deadline": payload.deadline,
  130         },
  131     });
! 132     serde_json::from_value::<TypedData>(json)
! 133 }
  134 
! 135 pub fn signed_key_request_typed_data(
! 136     request_fid: u64,
! 137     key: &[u8],
! 138     deadline: u64,
! 139     chain_id: u32,
! 140 ) -> Result<TypedData, serde_json::Error> {
! 141     let json = json!({
! 142         "types": signed_key_request_types(),
! 143         "primaryType": "SignedKeyRequest",
! 144         "domain": key_eip712_domain(chain_id),
! 145         "message": {
! 146             "requestFid": request_fid,
! 147             "key": format!("0x{}", hex::encode(key)),
! 148             "deadline": deadline,
  149         },
  150     });
! 151     serde_json::from_value::<TypedData>(json)
! 152 }

cli/src/factory.rs

  13 use snapchain_proto::{self as proto, FarcasterNetwork, MessageData, MessageType};
  14 
  15 use crate::helpers::{calculate_message_hash, farcaster_time};
  16 
! 17 fn create_message_with_data(
! 18     fid: u64,
! 19     msg_type: MessageType,
! 20     body: proto::message_data::Body,
! 21     timestamp: Option<u32>,
! 22     private_key: &SigningKey,
! 23 ) -> proto::Message {
! 24     let network = FarcasterNetwork::Mainnet;
! 25     let timestamp = timestamp.unwrap_or_else(farcaster_time);
  26 
! 27     let msg_data = MessageData {
! 28         fid,
! 29         r#type: msg_type as i32,
! 30         timestamp,
! 31         network: network as i32,
! 32         body: Some(body),
! 33     };
  34 
! 35     let msg_data_bytes = msg_data.encode_to_vec();
! 36     let hash = calculate_message_hash(&msg_data_bytes);
! 37     let signature = private_key.sign(&hash).to_bytes();
  38 
! 39     proto::Message {
! 40         data: Some(msg_data),
! 41         hash_scheme: proto::HashScheme::Blake3 as i32,
! 42         hash: hash.clone(),
! 43         signature_scheme: proto::SignatureScheme::Ed25519 as i32,
! 44         signature: signature.to_vec(),
! 45         signer: private_key.verifying_key().to_bytes().to_vec(),
! 46         data_bytes: None,
! 47     }
! 48 }
  49 
  50 pub mod casts {
  51     use super::*;
  52     use snapchain_proto::{CastAddBody, CastRemoveBody, CastType};

  50 pub mod casts {
  51     use super::*;
  52     use snapchain_proto::{CastAddBody, CastRemoveBody, CastType};
  53 
! 54     pub fn create_cast_add(
! 55         fid: u64,
! 56         text: &str,
! 57         timestamp: Option<u32>,
! 58         private_key: &SigningKey,
! 59     ) -> proto::Message {
! 60         let body = CastAddBody {
! 61             text: text.to_string(),
! 62             embeds: vec![],
! 63             embeds_deprecated: vec![],
! 64             mentions: vec![],
! 65             mentions_positions: vec![],
! 66             parent: None,
! 67             r#type: CastType::Cast as i32,
! 68         };
! 69         create_message_with_data(
! 70             fid,
! 71             MessageType::CastAdd,
! 72             proto::message_data::Body::CastAddBody(body),
! 73             timestamp,
! 74             private_key,
  75         )
! 76     }
  77 
! 78     pub fn create_cast_remove(
! 79         fid: u64,
! 80         target_hash: &[u8],
! 81         timestamp: Option<u32>,
! 82         private_key: &SigningKey,
! 83     ) -> proto::Message {
! 84         let body = CastRemoveBody {
! 85             target_hash: target_hash.to_vec(),
! 86         };
! 87         create_message_with_data(
! 88             fid,
! 89             MessageType::CastRemove,
! 90             proto::message_data::Body::CastRemoveBody(body),
! 91             timestamp,
! 92             private_key,
  93         )
! 94     }
  95 }
  96 
  97 pub mod user_data {
  98     use super::*;

   97 pub mod user_data {
   98     use super::*;
   99     use snapchain_proto::{UserDataBody, UserDataType};
  100 
! 101     pub fn create_user_data_add(
! 102         fid: u64,
! 103         user_data_type: UserDataType,
! 104         value: &str,
! 105         timestamp: Option<u32>,
! 106         private_key: &SigningKey,
! 107     ) -> proto::Message {
! 108         let body = UserDataBody {
! 109             r#type: user_data_type as i32,
! 110             value: value.to_string(),
! 111         };
! 112         create_message_with_data(
! 113             fid,
! 114             MessageType::UserDataAdd,
! 115             proto::message_data::Body::UserDataBody(body),
! 116             timestamp,
! 117             private_key,
  118         )
! 119     }
  120 }
  121 
  122 pub mod keys {
  123     use super::*;

  141             address requestSigner;
  142             bytes signature;
  143             uint256 deadline;
  144         }
! 145     }
  146 
! 147     fn build_signed_metadata_bytes(
! 148         app_custody: &PrivateKeySigner,
! 149         request_fid: u64,
! 150         key: &[u8],
! 151         deadline: u64,
! 152     ) -> Vec<u8> {
! 153         let typed_data =
! 154             signed_key_request_typed_data(request_fid, key, deadline, ETH_MAINNET_CHAIN_ID)
! 155                 .expect("typed data construction is infallible for valid inputs");
! 156         let prehash = typed_data
! 157             .eip712_signing_hash()
! 158             .expect("eip712 prehash is infallible");
! 159         let sig: Vec<u8> = app_custody
! 160             .sign_hash_sync(&prehash)
! 161             .expect("PrivateKeySigner sign cannot fail")
! 162             .into();
! 163         SignedKeyRequestMetadata {
! 164             requestFid: U256::from(request_fid),
! 165             requestSigner: app_custody.address(),
! 166             signature: Bytes::from(sig),
! 167             deadline: U256::from(deadline),
! 168         }
! 169         .abi_encode()
! 170     }
  171 
! 172     pub fn create_key_add(
! 173         fid: u64,
! 174         fid_custody: &PrivateKeySigner,
! 175         request_fid: u64,
! 176         app_custody: &PrivateKeySigner,
! 177         envelope_signer: &SigningKey,
! 178         scopes: Vec<MessageType>,
! 179         ttl: u32,
! 180         nonce: u32,
! 181         deadline: u32,
! 182         timestamp: Option<u32>,
! 183     ) -> proto::Message {
! 184         let key_bytes: [u8; 32] = envelope_signer.verifying_key().to_bytes();
! 185         let scopes_i32: Vec<i32> = scopes.iter().map(|s| *s as i32).collect();
  186 
! 187         let payload = KeyAddPayload {
! 188             fid,
! 189             key: &key_bytes,
! 190             key_type: KEY_TYPE_ED25519,
! 191             scopes: &scopes_i32,
! 192             ttl,
! 193             nonce,
! 194             deadline,
! 195         };
! 196         let typed_data = key_add_typed_data(&payload, ETH_MAINNET_CHAIN_ID)
! 197             .expect("typed data construction is infallible for valid inputs");
! 198         let prehash = typed_data
! 199             .eip712_signing_hash()
! 200             .expect("eip712 prehash is infallible");
! 201         let custody_sig: Vec<u8> = fid_custody
! 202             .sign_hash_sync(&prehash)
! 203             .expect("PrivateKeySigner sign cannot fail")
! 204             .into();
  205 
! 206         let metadata =
! 207             build_signed_metadata_bytes(app_custody, request_fid, &key_bytes, deadline as u64);
  208 
! 209         let body = KeyAddBody {
! 210             key: key_bytes.to_vec(),
! 211             key_type: KEY_TYPE_ED25519,
! 212             custody_signature: custody_sig,
! 213             deadline,
! 214             nonce,
! 215             metadata,
! 216             metadata_type: METADATA_TYPE_SIGNED_KEY_REQUEST,
! 217             registration_tx_hash: vec![],
! 218             scopes: scopes_i32,
! 219             ttl,
! 220         };
  221 
! 222         create_message_with_data(
! 223             fid,
! 224             MessageType::KeyAdd,
! 225             proto::message_data::Body::KeyAddBody(body),
! 226             timestamp,
! 227             envelope_signer,
  228         )
! 229     }
  230 
! 231     pub fn create_key_remove_custody(
! 232         fid: u64,
! 233         fid_custody: &PrivateKeySigner,
! 234         envelope_signer: &SigningKey,
! 235         target_key: &[u8; 32],
! 236         nonce: u32,
! 237         deadline: u32,
! 238         timestamp: Option<u32>,
! 239     ) -> proto::Message {
! 240         let payload = KeyRemovePayload {
! 241             fid,
! 242             key: target_key,
! 243             nonce,
! 244             deadline,
! 245         };
! 246         let typed_data = key_remove_typed_data(&payload, ETH_MAINNET_CHAIN_ID)
! 247             .expect("typed data construction is infallible for valid inputs");
! 248         let prehash = typed_data
! 249             .eip712_signing_hash()
! 250             .expect("eip712 prehash is infallible");
! 251         let custody_sig: Vec<u8> = fid_custody
! 252             .sign_hash_sync(&prehash)
! 253             .expect("PrivateKeySigner sign cannot fail")
! 254             .into();
  255 
! 256         let body = KeyRemoveBody {
! 257             key: target_key.to_vec(),
! 258             signature: custody_sig,
! 259             signature_type: 1, // Custody
! 260             deadline,
! 261             nonce,
! 262         };
  263 
! 264         create_message_with_data(
! 265             fid,
! 266             MessageType::KeyRemove,
! 267             proto::message_data::Body::KeyRemoveBody(body),
! 268             timestamp,
! 269             envelope_signer,
  270         )
! 271     }
  272 
! 273     pub fn create_key_remove_self_revoke(
! 274         fid: u64,
! 275         envelope_signer: &SigningKey,
! 276         nonce: u32,
! 277         deadline: u32,
! 278         timestamp: Option<u32>,
! 279     ) -> proto::Message {
! 280         let key_bytes: [u8; 32] = envelope_signer.verifying_key().to_bytes();
! 281         let body = KeyRemoveBody {
! 282             key: key_bytes.to_vec(),
! 283             signature: vec![],
! 284             signature_type: 2, // SelfRevoke
! 285             deadline,
! 286             nonce,
! 287         };
! 288         create_message_with_data(
! 289             fid,
! 290             MessageType::KeyRemove,
! 291             proto::message_data::Body::KeyRemoveBody(body),
! 292             timestamp,
! 293             envelope_signer,
  294         )
! 295     }
  296 }

cli/src/helpers.rs

   8 pub const FARCASTER_EPOCH: u64 = 1_609_459_200_000;
   9 
  10 pub const MAX_KEY_TTL_SECONDS: u32 = 90 * 24 * 60 * 60;
  11 
! 12 pub fn calculate_message_hash(data_bytes: &[u8]) -> Vec<u8> {
! 13     blake3::hash(data_bytes).as_bytes()[0..20].to_vec()
! 14 }
  15 
! 16 fn current_timestamp() -> u32 {
! 17     std::time::SystemTime::now()
! 18         .duration_since(std::time::UNIX_EPOCH)
! 19         .expect("system clock is before unix epoch")
! 20         .as_secs() as u32
! 21 }
  22 
! 23 pub fn farcaster_time() -> u32 {
! 24     current_timestamp() - (FARCASTER_EPOCH / 1000) as u32
! 25 }

cli/src/main.rs

  253     BlockConfirmed,
  254 }
  255 
  256 impl EventTypeArg {
! 257     fn as_proto(self) -> HubEventType {
! 258         match self {
! 259             EventTypeArg::MergeMessage => HubEventType::MergeMessage,
! 260             EventTypeArg::PruneMessage => HubEventType::PruneMessage,
! 261             EventTypeArg::RevokeMessage => HubEventType::RevokeMessage,
! 262             EventTypeArg::MergeUsernameProof => HubEventType::MergeUsernameProof,
! 263             EventTypeArg::MergeOnChainEvent => HubEventType::MergeOnChainEvent,
! 264             EventTypeArg::MergeFailure => HubEventType::MergeFailure,
! 265             EventTypeArg::BlockConfirmed => HubEventType::BlockConfirmed,
  266         }
! 267     }
  268 }
  269 
! 270 fn default_event_types() -> Vec<HubEventType> {
! 271     vec![
! 272         HubEventType::MergeMessage,
! 273         HubEventType::PruneMessage,
! 274         HubEventType::RevokeMessage,
! 275         HubEventType::MergeUsernameProof,
! 276         HubEventType::MergeOnChainEvent,
! 277         HubEventType::MergeFailure,
! 278         HubEventType::BlockConfirmed,
  279     ]
! 280 }
  281 
  282 #[derive(ValueEnum, Clone, Copy)]
  283 enum NetworkArg {
  284     Mainnet,

  286     Devnet,
  287 }
  288 
  289 impl NetworkArg {
! 290     fn as_proto(self) -> FarcasterNetwork {
! 291         match self {
! 292             NetworkArg::Mainnet => FarcasterNetwork::Mainnet,
! 293             NetworkArg::Testnet => FarcasterNetwork::Testnet,
! 294             NetworkArg::Devnet => FarcasterNetwork::Devnet,
  295         }
! 296     }
  297 }
  298 
  299 #[derive(ValueEnum, Clone, Copy)]
  300 enum KeyRemoveMode {

  318     LinkCompactState,
  319 }
  320 
  321 impl ScopeArg {
! 322     fn as_message_type(self) -> MessageType {
! 323         match self {
! 324             ScopeArg::CastAdd => MessageType::CastAdd,
! 325             ScopeArg::CastRemove => MessageType::CastRemove,
! 326             ScopeArg::ReactionAdd => MessageType::ReactionAdd,
! 327             ScopeArg::ReactionRemove => MessageType::ReactionRemove,
! 328             ScopeArg::LinkAdd => MessageType::LinkAdd,
! 329             ScopeArg::LinkRemove => MessageType::LinkRemove,
! 330             ScopeArg::VerificationAdd => MessageType::VerificationAddEthAddress,
! 331             ScopeArg::VerificationRemove => MessageType::VerificationRemove,
! 332             ScopeArg::UserDataAdd => MessageType::UserDataAdd,
! 333             ScopeArg::UsernameProof => MessageType::UsernameProof,
! 334             ScopeArg::FrameAction => MessageType::FrameAction,
! 335             ScopeArg::LinkCompactState => MessageType::LinkCompactState,
  336         }
! 337     }
  338 }
  339 
! 340 fn default_scopes() -> Vec<MessageType> {
! 341     vec![
! 342         MessageType::CastAdd,
! 343         MessageType::CastRemove,
! 344         MessageType::ReactionAdd,
! 345         MessageType::ReactionRemove,
! 346         MessageType::LinkAdd,
! 347         MessageType::LinkRemove,
! 348         MessageType::VerificationAddEthAddress,
! 349         MessageType::VerificationRemove,
! 350         MessageType::UserDataAdd,
! 351         MessageType::UsernameProof,
! 352         MessageType::FrameAction,
! 353         MessageType::LinkCompactState,
  354     ]
! 355 }
  356 
  357 // ---------- helpers ----------
  358 
! 359 fn parse_hex(s: &str) -> Result<Vec<u8>, BoxedError> {
! 360     Ok(hex::decode(s.trim().trim_start_matches("0x"))?)
! 361 }
  362 
! 363 fn parse_secret(s: &str) -> Result<EdSigningKey, BoxedError> {
! 364     let bytes = parse_hex(s)?;
! 365     if bytes.len() != 32 {
! 366         return Err(format!("expected 32-byte secret, got {}", bytes.len()).into());
! 367     }
! 368     let mut arr = [0u8; 32];
! 369     arr.copy_from_slice(&bytes);
! 370     Ok(EdSigningKey::from_bytes(&arr))
! 371 }
  372 
! 373 fn parse_pubkey(s: &str) -> Result<[u8; 32], BoxedError> {
! 374     let bytes = parse_hex(s)?;
! 375     if bytes.len() != 32 {
! 376         return Err(format!("expected 32-byte pubkey, got {}", bytes.len()).into());
! 377     }
! 378     let mut arr = [0u8; 32];
! 379     arr.copy_from_slice(&bytes);
! 380     Ok(arr)
! 381 }
  382 
! 383 fn derive_custody(path: &str) -> Result<PrivateKeySigner, BoxedError> {
! 384     let phrase =
! 385         std::env::var("MNEMONIC").map_err(|_| "Set MNEMONIC env var to your recovery phrase")?;
! 386     Ok(MnemonicBuilder::<English>::default()
! 387         .phrase(phrase.trim())
! 388         .derivation_path(path)?
! 389         .build()?)
! 390 }
  391 
! 392 fn prompt_value(prompt: &str) -> Result<String, BoxedError> {
! 393     print!("{}: ", prompt);
! 394     std::io::Write::flush(&mut std::io::stdout())?;
! 395     let mut answer = String::new();
! 396     std::io::stdin().read_line(&mut answer)?;
! 397     Ok(answer.trim_end_matches(['\n', '\r']).to_string())
! 398 }
  399 
! 400 fn confirm(prompt: &str, skip: bool) -> Result<(), BoxedError> {
! 401     if skip {
! 402         return Ok(());
! 403     }
! 404     print!("{} [y/N]: ", prompt);
! 405     std::io::Write::flush(&mut std::io::stdout())?;
! 406     let mut answer = String::new();
! 407     std::io::stdin().read_line(&mut answer)?;
! 408     if !matches!(answer.trim(), "y" | "Y" | "yes" | "YES") {
! 409         return Err("aborted by user".into());
! 410     }
! 411     Ok(())
! 412 }
  413 
  414 /// Re-tag a message built by the factory (which hard-codes `Mainnet`) for a different
  415 /// network. Mutates `network`, recomputes the BLAKE3 hash, and re-signs the envelope.
! 416 fn retarget_network(msg: &mut proto::Message, network: FarcasterNetwork, signer: &EdSigningKey) {
! 417     let data = msg.data.as_mut().expect("factory always sets data");
! 418     data.network = network as i32;
! 419     let data_bytes = data.encode_to_vec();
! 420     let hash = calculate_message_hash(&data_bytes);
! 421     msg.signature = signer.sign(&hash).to_bytes().to_vec();
! 422     msg.hash = hash;
! 423 }
  424 
  425 /// HTTP submit. Tolerates the KEY_ADD/KEY_REMOVE JSON-mapping TODO (NEYN-10568,
  426 /// `src/network/http_server.rs:1933`): the merge succeeds but the response can't
  427 /// be serialized, so the exact 400 body is treated as success.
! 428 async fn submit(node: &str, msg: &proto::Message, label: &str) -> Result<(), BoxedError> {
! 429     let url = format!("{}/v1/submitMessage", node.trim_end_matches('/'));
! 430     println!("Submitting {} (hash 0x{})", label, hex::encode(&msg.hash));
! 431     let resp = reqwest::Client::new()
! 432         .post(&url)
! 433         .header("content-type", "application/octet-stream")
! 434         .body(msg.encode_to_vec())
! 435         .send()
! 436         .await?;
! 437     let status = resp.status();
! 438     let text = resp.text().await?;
! 439     println!("HTTP {}: {}", status, text);
! 440     let json_mapping_todo = status == reqwest::StatusCode::BAD_REQUEST
! 441         && text.contains("JSON mapping not yet implemented");
! 442     if json_mapping_todo {
! 443         println!("(merged on-node; response-mapping TODO returns 400 — treating as success.)");
! 444         return Ok(());
! 445     }
! 446     if !status.is_success() {
! 447         return Err(format!("submit failed: {}", status).into());
! 448     }
! 449     Ok(())
! 450 }
  451 
! 452 fn fresh_ed25519() -> EdSigningKey {
! 453     let mut secret = [0u8; 32];
! 454     OsRng.fill_bytes(&mut secret);
! 455     let key = EdSigningKey::from_bytes(&secret);
! 456     println!();
! 457     println!("=== NEW SIGNER (save these now) ===");
! 458     println!("  secret:  0x{}", hex::encode(secret));
! 459     println!(
  460         "  pubkey:  0x{}",
! 461         hex::encode(key.verifying_key().to_bytes())
  462     );
! 463     println!("===================================");
! 464     println!();
! 465     key
! 466 }
  467 
  468 // ---------- subcommands ----------
  469 
! 470 async fn run_key_add(
! 471     args: KeyAddArgs,
! 472     node: &str,
! 473     network: FarcasterNetwork,
! 474 ) -> Result<(), BoxedError> {
! 475     let custody = derive_custody(&args.path)?;
! 476     println!("Custody address: {}", custody.address());
! 477     println!("FID:             {}", args.fid);
! 478     confirm(
! 479         "Does this address match the custody on file for the FID?",
! 480         args.yes,
! 481     )?;
  482 
! 483     let envelope_signer = match args.signer_secret.as_deref() {
! 484         Some(s) => {
! 485             let key = parse_secret(s)?;
! 486             println!(
  487                 "Reusing signer pubkey: 0x{}",
! 488                 hex::encode(key.verifying_key().to_bytes())
  489             );
! 490             key
  491         }
! 492         None => fresh_ed25519(),
  493     };
  494 
! 495     let scopes: Vec<MessageType> = match args.scopes {
! 496         Some(v) => v.into_iter().map(ScopeArg::as_message_type).collect(),
! 497         None => default_scopes(),
  498     };
  499 
! 500     let request_fid = args.request_fid.unwrap_or(args.fid);
! 501     let now = farcaster_time();
! 502     let deadline = now + args.deadline_secs;
  503 
! 504     let mut msg = factory::keys::create_key_add(
! 505         args.fid,
! 506         &custody,
! 507         request_fid,
! 508         &custody,
! 509         &envelope_signer,
! 510         scopes,
! 511         args.ttl,
! 512         args.nonce,
! 513         deadline,
! 514         None,
  515     );
! 516     retarget_network(&mut msg, network, &envelope_signer);
! 517     submit(node, &msg, "KEY_ADD").await
! 518 }
  519 
! 520 async fn run_key_remove(
! 521     args: KeyRemoveArgs,
! 522     node: &str,
! 523     network: FarcasterNetwork,
! 524 ) -> Result<(), BoxedError> {
! 525     let signer = parse_secret(&args.signer_secret)?;
! 526     let now = farcaster_time();
! 527     let deadline = now + args.deadline_secs;
  528 
! 529     let mut msg = match args.mode {
  530         KeyRemoveMode::Custody => {
! 531             let target = args
! 532                 .target_key
! 533                 .as_deref()
! 534                 .ok_or("--target-key is required for custody mode")?;
! 535             let target_key = parse_pubkey(target)?;
! 536             let custody = derive_custody(&args.path)?;
! 537             println!("Custody address: {}", custody.address());
! 538             println!("FID:             {}", args.fid);
! 539             println!("Removing key:    0x{}", hex::encode(target_key));
! 540             confirm("Confirm KEY_REMOVE (custody)?", args.yes)?;
! 541             factory::keys::create_key_remove_custody(
! 542                 args.fid,
! 543                 &custody,
! 544                 &signer,
! 545                 &target_key,
! 546                 args.nonce,
! 547                 deadline,
! 548                 None,
  549             )
  550         }
  551         KeyRemoveMode::SelfRevoke => {
! 552             let pk = signer.verifying_key().to_bytes();
! 553             println!("FID:           {}", args.fid);
! 554             println!("Self-revoking: 0x{}", hex::encode(pk));
! 555             confirm("Confirm KEY_REMOVE (self-revoke)?", args.yes)?;
! 556             factory::keys::create_key_remove_self_revoke(
! 557                 args.fid, &signer, args.nonce, deadline, None,
  558             )
  559         }
  560     };
! 561     retarget_network(&mut msg, network, &signer);
! 562     submit(node, &msg, "KEY_REMOVE").await
! 563 }
  564 
! 565 async fn run_cast_add(
! 566     args: CastAddArgs,
! 567     node: &str,
! 568     network: FarcasterNetwork,
! 569 ) -> Result<(), BoxedError> {
! 570     let signer = parse_secret(&args.signer_secret)?;
! 571     let mut msg = factory::casts::create_cast_add(args.fid, &args.text, None, &signer);
! 572     retarget_network(&mut msg, network, &signer);
! 573     submit(node, &msg, "CAST_ADD").await
! 574 }
  575 
! 576 async fn run_cast_remove(
! 577     args: CastRemoveArgs,
! 578     node: &str,
! 579     network: FarcasterNetwork,
! 580 ) -> Result<(), BoxedError> {
! 581     let signer = parse_secret(&args.signer_secret)?;
! 582     let target_hash = parse_hex(&args.target_hash)?;
! 583     let mut msg = factory::casts::create_cast_remove(args.fid, &target_hash, None, &signer);
! 584     retarget_network(&mut msg, network, &signer);
! 585     submit(node, &msg, "CAST_REMOVE").await
! 586 }
  587 
! 588 async fn run_live_at(
! 589     args: LiveAtArgs,
! 590     node: &str,
! 591     network: FarcasterNetwork,
! 592 ) -> Result<(), BoxedError> {
! 593     let signer = parse_secret(&args.signer_secret)?;
! 594     let value = match (args.clear, args.value) {
! 595         (true, _) => String::new(),
! 596         (false, Some(v)) => v,
! 597         (false, None) => prompt_value("LIVE_AT value (empty = clear heartbeat)")?,
  598     };
  599     // Each iteration needs a unique timestamp; otherwise the mempool sees the
  600     // bit-identical message twice and rejects the second as a duplicate. We bump
  601     // by 1s per iteration and clamp up to wall-clock so long runs stay within

  599     // Each iteration needs a unique timestamp; otherwise the mempool sees the
  600     // bit-identical message twice and rejects the second as a duplicate. We bump
  601     // by 1s per iteration and clamp up to wall-clock so long runs stay within
  602     // the 10-min future-timestamp validation window.
! 603     let mut ts = args.timestamp.unwrap_or_else(farcaster_time);
! 604     let kind = if value.is_empty() {
! 605         "LIVE_AT (CLEAR heartbeat)"
  606     } else {
! 607         "LIVE_AT"
  608     };
! 609     let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
! 610     let mut i: u32 = 0;
! 611     let loop_result: Result<(), BoxedError> = loop {
! 612         if args.count.is_some_and(|n| i >= n) {
! 613             break Ok(());
! 614         }
  615         // First tick fires immediately, so submit-then-wait pacing comes for free.
  616         // Race the tick against ctrl-C so shutdown is responsive even mid-sleep.
! 617         tokio::select! {
! 618             _ = interval.tick() => {}
! 619             _ = tokio::signal::ctrl_c() => {
! 620                 eprintln!("\n(received ctrl-C, shutting down)");
! 621                 break Ok(());
  622             }
  623         }
! 624         if i > 0 {
! 625             ts = std::cmp::max(ts + 1, farcaster_time());
! 626         }
! 627         let mut msg = factory::user_data::create_user_data_add(
! 628             args.fid,
! 629             UserDataType::LiveAt,
! 630             &value,
! 631             Some(ts),
! 632             &signer,
  633         );
! 634         retarget_network(&mut msg, network, &signer);
! 635         let label = match args.count {
! 636             Some(n) if n > 1 => format!("{} [{}/{}] ts={}", kind, i + 1, n, ts),
! 637             Some(_) => kind.to_string(),
! 638             None => format!("{} [{}] ts={}", kind, i + 1, ts),
  639         };
! 640         if let Err(e) = submit(node, &msg, &label).await {
! 641             break Err(e);
! 642         }
! 643         i = i.saturating_add(1);
  644     };
  645 
  646     // FIP-268 specifies that going offline should clear the presence. Always send
  647     // a final CLEAR on exit (clean completion, ctrl-C, or submit error) unless

  645 
  646     // FIP-268 specifies that going offline should clear the presence. Always send
  647     // a final CLEAR on exit (clean completion, ctrl-C, or submit error) unless
  648     // the active value was already empty.
! 649     if !value.is_empty() && i > 0 {
! 650         let clear_ts = std::cmp::max(ts + 1, farcaster_time());
! 651         let mut clear_msg = factory::user_data::create_user_data_add(
! 652             args.fid,
! 653             UserDataType::LiveAt,
! 654             "",
! 655             Some(clear_ts),
! 656             &signer,
  657         );
! 658         retarget_network(&mut clear_msg, network, &signer);
! 659         let label = format!("LIVE_AT (CLEAR heartbeat) [shutdown] ts={}", clear_ts);
! 660         if let Err(e) = submit(node, &clear_msg, &label).await {
! 661             eprintln!("warn: shutdown CLEAR submission failed: {}", e);
! 662         }
! 663     }
  664 
! 665     loop_result
! 666 }
  667 
! 668 async fn run_subscribe(args: SubscribeArgs) -> Result<(), BoxedError> {
  669     // tonic's TLS support uses rustls, which requires a process-wide CryptoProvider.
  670     // Ignore the result — re-running fc in tests can install twice.
  671     let _ =
! 672         rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider());
  673 
! 674     let event_types: Vec<i32> = args
! 675         .event_types
! 676         .map(|v| v.into_iter().map(EventTypeArg::as_proto).collect())
! 677         .unwrap_or_else(default_event_types)
! 678         .into_iter()
! 679         .map(|t| t as i32)
! 680         .collect();
  681 
! 682     let req = SubscribeRequest {
! 683         event_types,
! 684         from_id: args.from_id,
! 685         shard_index: Some(args.shard),
! 686     };
  687 
! 688     eprintln!(
  689         "Subscribing to {} (shard {}, from_id {:?})",
  690         args.grpc_node, args.shard, args.from_id
  691     );
  692 
! 693     let connect_start = std::time::Instant::now();
! 694     let mut client =
! 695         proto::hub_service_client::HubServiceClient::connect(args.grpc_node.clone()).await?;
! 696     eprintln!(
  697         "gRPC handshake (connect + TLS) completed in {:.3}s",
! 698         connect_start.elapsed().as_secs_f64()
  699     );
  700 
! 701     let subscribe_start = std::time::Instant::now();
! 702     let mut stream = client.subscribe(req).await?.into_inner();
! 703     eprintln!(
  704         "Subscribe RPC opened in {:.3}s",
! 705         subscribe_start.elapsed().as_secs_f64()
  706     );
  707 
! 708     let stream_start = std::time::Instant::now();
! 709     let mut first_event_logged = false;
! 710     let mut last_event_at: Option<std::time::Instant> = None;
! 711     while let Some(event) = stream.message().await? {
! 712         let now = std::time::Instant::now();
! 713         if !first_event_logged {
! 714             eprintln!(
! 715                 "First event received {:.3}s after subscribe RPC returned",
! 716                 stream_start.elapsed().as_secs_f64()
! 717             );
! 718             first_event_logged = true;
! 719         } else if let Some(prev) = last_event_at {
! 720             eprintln!("Δ since previous event: {:.3}s", (now - prev).as_secs_f64());
! 721         }
! 722         last_event_at = Some(now);
  723 
  724         // The proto crate derives `serde::Serialize` on every generated type (see
  725         // `proto/build.rs`), so we emit the prost-default JSON shape directly. This drops the
  726         // custom hub-API camelCase mapping that lived in `snapchain::network::http_server`; the

  724         // The proto crate derives `serde::Serialize` on every generated type (see
  725         // `proto/build.rs`), so we emit the prost-default JSON shape directly. This drops the
  726         // custom hub-API camelCase mapping that lived in `snapchain::network::http_server`; the
  727         // CLI README calls out the schema difference.
! 728         match serde_json::to_string(&event) {
! 729             Ok(line) => println!("{}", line),
! 730             Err(err) => eprintln!("warn: failed to serialize event as JSON: {}", err),
  731         }
  732     }
  733 
! 734     Ok(())
! 735 }
  736 
  737 #[tokio::main]
! 738 async fn main() -> Result<(), BoxedError> {
! 739     let cli = Cli::parse();
! 740     let network = cli.network.as_proto();
! 741     match cli.cmd {
! 742         Cmd::KeyAdd(a) => run_key_add(a, &cli.node, network).await,
! 743         Cmd::KeyRemove(a) => run_key_remove(a, &cli.node, network).await,
! 744         Cmd::CastAdd(a) => run_cast_add(a, &cli.node, network).await,
! 745         Cmd::CastRemove(a) => run_cast_remove(a, &cli.node, network).await,
! 746         Cmd::LiveAt(a) => run_live_at(a, &cli.node, network).await,
! 747         Cmd::Subscribe(a) => run_subscribe(a).await,
! 748     }
! 749 }

src/network/http_server.rs

  2428         onchain_event,
  2429     })
  2430 }
  2431 
! 2432 pub fn map_proto_hub_event_to_json_hub_event(
  2433     hub_event: proto::HubEvent,
  2434 ) -> Result<HubEvent, ErrorResponse> {
  2435     let mut merge_message_body: Option<MergeMessageBody> = None;
  2436     let mut prune_message_body: Option<PruneMessageBody> = None;

@topocount topocount enabled auto-merge (squash) May 28, 2026 20:30
@topocount topocount merged commit 35054ae into main May 28, 2026
14 checks passed
@topocount topocount deleted the kjs/neyn-11600-fc-cli branch May 28, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant