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 #[arg(long, env)]
29 pub from: PathBuf,
30
31 #[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 #[arg(long)]
41 pub to: AccountPublicKey,
42
43 #[arg(long)]
45 pub amount: u64,
46
47 #[arg(long)]
49 pub fee: u64,
50
51 #[arg(long, default_value = "")]
53 pub memo: String,
54
55 #[arg(long)]
57 pub nonce: Option<u32>,
58
59 #[arg(long)]
62 pub valid_until: Option<u32>,
63
64 #[arg(long)]
67 pub fee_payer: Option<AccountPublicKey>,
68
69 #[arg(long, default_value = "http://localhost:3000")]
71 pub node: String,
72}
73
74impl Send {
75 pub fn run(self, network: Network) -> Result<()> {
76 println!("Checking node status...");
78 self.check_node_status(&network)?;
79
80 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 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 let receiver_pk: CompressedPubKey = self
102 .to
103 .clone()
104 .try_into()
105 .map_err(|_| anyhow::anyhow!("Invalid receiver public key"))?;
106
107 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 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 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 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 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 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 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 let network_id = response_json["data"]["networkID"]
207 .as_str()
208 .context("Network ID not found in GraphQL response")?;
209
210 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 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 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 let payload_to_sign = TransactionUnionPayload::of_user_command_payload(&payload);
282
283 let mut signer = mina_signer::create_legacy(network_id);
285 let kp: Keypair = sender_key.clone().into();
286 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 let signed_cmd_v2: MinaBaseSignedCommandStableV2 = (&signed_command).into();
305
306 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 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 let memo_field = if self.memo.is_empty() {
324 String::new()
325 } else {
326 format!(r#"memo: "{}""#, self.memo)
327 };
328
329 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 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 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}