cli/commands/wallet/
send.rs

1use std::{path::PathBuf, str::FromStr};
2
3use anyhow::{Context, Result};
4use ledger::scan_state::{
5    currency::{Amount, Fee, Nonce, Slot},
6    transaction_logic::{
7        signed_command::{Body, Common, PaymentPayload, SignedCommand, SignedCommandPayload},
8        transaction_union_payload::TransactionUnionPayload,
9        Memo,
10    },
11};
12use mina_node_account::{AccountPublicKey, AccountSecretKey};
13use mina_p2p_messages::v2::MinaBaseSignedCommandStableV2;
14use mina_signer::{CompressedPubKey, Keypair, Signer};
15
16use super::super::Network;
17
18fn network_to_network_id(network: &Network) -> mina_signer::NetworkId {
19    match network {
20        Network::Mainnet => mina_signer::NetworkId::MAINNET,
21        Network::Devnet => mina_signer::NetworkId::TESTNET,
22    }
23}
24
25#[derive(Debug, clap::Args)]
26pub struct Send {
27    /// Path to encrypted sender key file
28    #[arg(long, env)]
29    pub from: PathBuf,
30
31    /// Password to decrypt the sender key
32    #[arg(
33        env = "MINA_PRIVKEY_PASS",
34        default_value = "",
35        help = "Password to decrypt the sender key (env: MINA_PRIVKEY_PASS)"
36    )]
37    pub password: String,
38
39    /// Receiver's public key
40    #[arg(long)]
41    pub to: AccountPublicKey,
42
43    /// Amount in nanomina (1 MINA = 1,000,000,000 nanomina)
44    #[arg(long)]
45    pub amount: u64,
46
47    /// Transaction fee in nanomina
48    #[arg(long)]
49    pub fee: u64,
50
51    /// Optional memo (max 32 bytes)
52    #[arg(long, default_value = "")]
53    pub memo: String,
54
55    /// Transaction nonce (if not provided, will be fetched from node)
56    #[arg(long)]
57    pub nonce: Option<u32>,
58
59    /// Slot number until which transaction is valid
60    /// If not provided, defaults to maximum slot (transaction never expires)
61    #[arg(long)]
62    pub valid_until: Option<u32>,
63
64    /// Optional fee payer public key (if different from sender)
65    /// If not provided, the sender will pay the fee
66    #[arg(long)]
67    pub fee_payer: Option<AccountPublicKey>,
68
69    /// Node RPC endpoint
70    #[arg(long, default_value = "http://localhost:3000")]
71    pub node: String,
72}
73
74impl Send {
75    pub fn run(self, network: Network) -> Result<()> {
76        // Check node is synced and on the correct network
77        println!("Checking node status...");
78        self.check_node_status(&network)?;
79
80        // Load the sender's secret key
81        let sender_key = AccountSecretKey::from_encrypted_file(&self.from, &self.password)
82            .with_context(|| {
83                format!("Failed to decrypt sender key file: {}", self.from.display())
84            })?;
85
86        let sender_pk = sender_key.public_key_compressed();
87        println!("Sender: {}", AccountPublicKey::from(sender_pk.clone()));
88
89        // Determine the fee payer (use fee_payer if provided, otherwise use sender)
90        let fee_payer_pk: CompressedPubKey = if let Some(ref fee_payer) = self.fee_payer {
91            println!("Fee payer: {}", fee_payer);
92            fee_payer
93                .clone()
94                .try_into()
95                .map_err(|_| anyhow::anyhow!("Invalid fee payer public key"))?
96        } else {
97            sender_pk.clone()
98        };
99
100        // Convert receiver public key to CompressedPubKey
101        let receiver_pk: CompressedPubKey = self
102            .to
103            .clone()
104            .try_into()
105            .map_err(|_| anyhow::anyhow!("Invalid receiver public key"))?;
106
107        // Fetch nonce from node if not provided
108        // Note: GraphQL API expects nonce to be account_nonce, but we need to sign
109        // with account_nonce for the first transaction from a new account
110        let nonce = if let Some(nonce) = self.nonce {
111            nonce
112        } else {
113            println!("Fetching nonce from node...");
114            self.fetch_nonce(&fee_payer_pk)?
115        };
116
117        println!("Using nonce: {}", nonce);
118
119        // Create the payment payload
120        let payload = SignedCommandPayload {
121            common: Common {
122                fee: Fee::from_u64(self.fee),
123                fee_payer_pk,
124                nonce: Nonce::from_u32(nonce),
125                valid_until: self
126                    .valid_until
127                    .map(Slot::from_u32)
128                    .unwrap_or_else(Slot::max),
129                memo: Memo::from_str(&self.memo).unwrap_or_else(|_| Memo::empty()),
130            },
131            body: Body::Payment(PaymentPayload {
132                receiver_pk,
133                amount: Amount::from_u64(self.amount),
134            }),
135        };
136
137        // Sign the transaction
138        println!("Signing transaction...");
139        let network_id = network_to_network_id(&network);
140        let signed_command = self.sign_transaction(payload, &sender_key, network_id)?;
141
142        // Submit to node
143        println!("Submitting transaction to node...");
144        let tx_hash = self.submit_transaction(signed_command)?;
145
146        println!("\nTransaction submitted successfully!");
147        println!("Transaction hash: {}", tx_hash);
148        println!("Status: Pending");
149        println!("\nYou can check the transaction status with:");
150        println!("  mina wallet status --hash {}", tx_hash);
151
152        Ok(())
153    }
154
155    fn check_node_status(&self, network: &Network) -> Result<()> {
156        let client = reqwest::blocking::Client::builder()
157            .timeout(std::time::Duration::from_secs(30))
158            .build()
159            .context("Failed to create HTTP client")?;
160        let url = format!("{}/graphql", self.node);
161
162        // GraphQL query to check sync status and network ID
163        let query = serde_json::json!({
164            "query": r#"query {
165                syncStatus
166                networkID
167            }"#
168        });
169
170        let response = client
171            .post(&url)
172            .json(&query)
173            .send()
174            .context("Failed to query node status")?;
175
176        if !response.status().is_success() {
177            anyhow::bail!("Failed to connect to node: HTTP {}", response.status());
178        }
179
180        let response_json: serde_json::Value = response
181            .json()
182            .context("Failed to parse GraphQL response")?;
183
184        // Check for GraphQL errors
185        if let Some(errors) = response_json.get("errors") {
186            let error_msg = serde_json::to_string_pretty(errors)
187                .unwrap_or_else(|_| "Unknown GraphQL error".to_string());
188            anyhow::bail!("GraphQL error: {}", error_msg);
189        }
190
191        // Check sync status
192        let sync_status = response_json["data"]["syncStatus"]
193            .as_str()
194            .context("Sync status not found in GraphQL response")?;
195
196        if sync_status != "SYNCED" {
197            anyhow::bail!(
198                "Node is not synced (status: {}). Please wait for the node to sync before sending transactions.",
199                sync_status
200            );
201        }
202
203        println!("Node is synced: {}", sync_status);
204
205        // Check network ID
206        let network_id = response_json["data"]["networkID"]
207            .as_str()
208            .context("Network ID not found in GraphQL response")?;
209
210        // Expected network ID based on selected network
211        let expected_network = match network {
212            Network::Mainnet => "mina:mainnet",
213            Network::Devnet => "mina:devnet",
214        };
215
216        if !network_id.contains(expected_network) {
217            anyhow::bail!(
218                "Network mismatch: node is on '{}' but you selected {:?}. Use --network to specify the correct network.",
219                network_id,
220                network
221            );
222        }
223
224        println!("Network verified: {}", network_id);
225
226        Ok(())
227    }
228
229    fn fetch_nonce(&self, sender_pk: &CompressedPubKey) -> Result<u32> {
230        let client = reqwest::blocking::Client::builder()
231            .timeout(std::time::Duration::from_secs(30))
232            .build()
233            .context("Failed to create HTTP client")?;
234        let url = format!("{}/graphql", self.node);
235
236        // GraphQL query to fetch account information
237        let query = serde_json::json!({
238            "query": format!(
239                r#"query {{
240                    account(publicKey: "{}") {{
241                        nonce
242                    }}
243                }}"#,
244                AccountPublicKey::from(sender_pk.clone()).to_string()
245            )
246        });
247
248        let response = client
249            .post(&url)
250            .json(&query)
251            .send()
252            .context("Failed to query account from node")?;
253
254        if !response.status().is_success() {
255            anyhow::bail!("Failed to fetch account: HTTP {}", response.status());
256        }
257
258        let response_json: serde_json::Value = response
259            .json()
260            .context("Failed to parse GraphQL response")?;
261
262        // Extract nonce from GraphQL response
263        let nonce_str = response_json["data"]["account"]["nonce"]
264            .as_str()
265            .context("Nonce not found in GraphQL response")?;
266
267        let nonce = nonce_str
268            .parse::<u32>()
269            .context("Failed to parse nonce as u32")?;
270
271        Ok(nonce)
272    }
273
274    fn sign_transaction(
275        &self,
276        payload: SignedCommandPayload,
277        sender_key: &AccountSecretKey,
278        network_id: mina_signer::NetworkId,
279    ) -> Result<SignedCommand> {
280        // Create the transaction union payload for signing
281        let payload_to_sign = TransactionUnionPayload::of_user_command_payload(&payload);
282
283        // Create signer and sign the transaction
284        let mut signer = mina_signer::create_legacy(network_id);
285        let kp: Keypair = sender_key.clone().into();
286        // Use packed=true for OCaml/TypeScript compatibility (required by Mina protocol)
287        let signature = signer.sign(&kp, &payload_to_sign, true);
288
289        Ok(SignedCommand {
290            payload,
291            signer: sender_key.public_key_compressed(),
292            signature,
293        })
294    }
295
296    fn submit_transaction(&self, signed_command: SignedCommand) -> Result<String> {
297        let client = reqwest::blocking::Client::builder()
298            .timeout(std::time::Duration::from_secs(120))
299            .build()
300            .context("Failed to create HTTP client")?;
301        let url = format!("{}/graphql", self.node);
302
303        // Convert to v2 types for easier field extraction
304        let signed_cmd_v2: MinaBaseSignedCommandStableV2 = (&signed_command).into();
305
306        // Convert signature to GraphQL format (field and scalar as decimal strings)
307        let sig_field =
308            mina_p2p_messages::bigint::BigInt::from(signed_command.signature.rx).to_decimal();
309        let sig_scalar =
310            mina_p2p_messages::bigint::BigInt::from(signed_command.signature.s).to_decimal();
311
312        // Extract payment details from signed command
313        let (receiver_pk, amount) = match &signed_cmd_v2.payload.body {
314            mina_p2p_messages::v2::MinaBaseSignedCommandPayloadBodyStableV2::Payment(payment) => {
315                (payment.receiver_pk.to_string(), payment.amount.to_string())
316            }
317            _ => anyhow::bail!("Expected payment body in signed command"),
318        };
319
320        let fee_payer_pk = signed_cmd_v2.payload.common.fee_payer_pk.to_string();
321
322        // Build memo field - omit if empty
323        let memo_field = if self.memo.is_empty() {
324            String::new()
325        } else {
326            format!(r#"memo: "{}""#, self.memo)
327        };
328
329        // Build GraphQL mutation
330        let mutation = format!(
331            r#"mutation {{
332                sendPayment(
333                    input: {{
334                        from: "{}"
335                        to: "{}"
336                        amount: "{}"
337                        fee: "{}"
338                        {}
339                        nonce: "{}"
340                        validUntil: "{}"
341                    }}
342                    signature: {{
343                        field: "{}"
344                        scalar: "{}"
345                    }}
346                ) {{
347                    payment {{
348                        hash
349                        id
350                    }}
351                }}
352            }}"#,
353            fee_payer_pk,
354            receiver_pk,
355            amount,
356            ***signed_cmd_v2.payload.common.fee,
357            memo_field,
358            **signed_cmd_v2.payload.common.nonce,
359            signed_cmd_v2.payload.common.valid_until.as_u32(),
360            sig_field,
361            sig_scalar,
362        );
363
364        let query = serde_json::json!({
365            "query": mutation
366        });
367
368        let response = client
369            .post(&url)
370            .json(&query)
371            .send()
372            .context("Failed to submit transaction to node")?;
373
374        let status = response.status();
375        if !status.is_success() {
376            let error_text = response
377                .text()
378                .unwrap_or_else(|_| "Unknown error".to_string());
379            anyhow::bail!(
380                "Failed to submit transaction: HTTP {} - {}",
381                status,
382                error_text
383            );
384        }
385
386        let response_json: serde_json::Value = response
387            .json()
388            .context("Failed to parse GraphQL response")?;
389
390        // Check for GraphQL errors
391        if let Some(errors) = response_json.get("errors") {
392            let error_msg = serde_json::to_string_pretty(errors)
393                .unwrap_or_else(|_| "Unknown GraphQL error".to_string());
394            anyhow::bail!("GraphQL error: {}", error_msg);
395        }
396
397        // Extract transaction hash from response
398        let hash = response_json["data"]["sendPayment"]["payment"]["hash"]
399            .as_str()
400            .context("Transaction hash not found in GraphQL response")?
401            .to_string();
402
403        Ok(hash)
404    }
405}