diff --git a/.github/scripts/wallet/test-address.sh b/.github/scripts/wallet/test-address.sh new file mode 100755 index 0000000000..13eedab5ce --- /dev/null +++ b/.github/scripts/wallet/test-address.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +set -euo pipefail + +# Test the wallet address command with encrypted key file + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$REPO_ROOT" + +# Define test parameters +KEY_FILE="tests/files/accounts/test-block-producer" +PUBKEY_FILE="tests/files/accounts/test-block-producer.pub" +PASSWORD="test-password" + +# Read expected public key +EXPECTED_PUBKEY=$(cat "$PUBKEY_FILE") + +echo "Testing: mina wallet address" +echo "Key file: $KEY_FILE" +echo "Expected public key: $EXPECTED_PUBKEY" +echo "" + +# Run the wallet address command with password from environment variable +export MINA_PRIVKEY_PASS="$PASSWORD" +ACTUAL_PUBKEY=$(./target/release/mina wallet address --from "$KEY_FILE") + +echo "Actual public key: $ACTUAL_PUBKEY" +echo "" + +# Compare the public keys +if [ "$ACTUAL_PUBKEY" = "$EXPECTED_PUBKEY" ]; then + echo "✓ Test passed: Public key matches expected value" + exit 0 +else + echo "✗ Test failed: Public key mismatch" + echo " Expected: $EXPECTED_PUBKEY" + echo " Got: $ACTUAL_PUBKEY" + exit 1 +fi diff --git a/.github/scripts/wallet/test-balance-from-key.sh b/.github/scripts/wallet/test-balance-from-key.sh new file mode 100755 index 0000000000..7243e77329 --- /dev/null +++ b/.github/scripts/wallet/test-balance-from-key.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# Test the wallet balance command with --from key file (text format) + +# Check for required environment variables before enabling strict mode +if [ -z "${MINA_NODE_ENDPOINT:-}" ]; then + echo "Error: MINA_NODE_ENDPOINT environment variable is not set" + echo "Please set it to a GraphQL endpoint URL, e.g.:" + echo " export MINA_NODE_ENDPOINT=http://mina-rust-plain-1.gcp.o1test.net/graphql" + exit 1 +fi + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$REPO_ROOT" + +# Define test parameters +KEY_FILE="tests/files/accounts/test-block-producer" +PASSWORD="test-password" + +echo "Test: Verify --from option works with key file (text format)" +export MINA_PRIVKEY_PASS="$PASSWORD" +BALANCE_OUTPUT=$(./target/release/mina wallet balance --from "$KEY_FILE" --endpoint "$MINA_NODE_ENDPOINT" --format text 2>&1 || true) + +echo "Balance command output:" +echo "$BALANCE_OUTPUT" +echo "" + +# Command should execute and produce text format output +if echo "$BALANCE_OUTPUT" | grep -q "Account:"; then + echo "✓ Command executed successfully with text format" + # Verify text format structure + if echo "$BALANCE_OUTPUT" | grep -q "Balance:" && \ + echo "$BALANCE_OUTPUT" | grep -q "Total:" && \ + echo "$BALANCE_OUTPUT" | grep -q "Nonce:"; then + echo "✓ Text output has expected structure" + exit 0 + else + echo "✗ Test failed: Text output missing expected fields" + exit 1 + fi +elif echo "$BALANCE_OUTPUT" | grep -qE "(Error:|\\[ERROR\\])"; then + # Command executed but got an error (e.g., account doesn't exist) + echo "✓ Command executed (account may not exist on network)" + exit 0 +else + echo "✗ Test failed: Unexpected output" + exit 1 +fi diff --git a/.github/scripts/wallet/test-balance-json-format.sh b/.github/scripts/wallet/test-balance-json-format.sh new file mode 100755 index 0000000000..ea3101c85e --- /dev/null +++ b/.github/scripts/wallet/test-balance-json-format.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +# Test the wallet balance command JSON format output structure + +# Check for required environment variables before enabling strict mode +if [ -z "${MINA_NODE_ENDPOINT:-}" ]; then + echo "Error: MINA_NODE_ENDPOINT environment variable is not set" + echo "Please set it to a GraphQL endpoint URL, e.g.:" + echo " export MINA_NODE_ENDPOINT=http://mina-rust-plain-1.gcp.o1test.net/graphql" + exit 1 +fi + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$REPO_ROOT" + +# Check for jq +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed" + echo "Install with: apt-get install jq (Ubuntu/Debian) or brew install jq (macOS)" + exit 1 +fi + +echo "Test: Verify JSON format output structure" +JSON_OUTPUT=$(./target/release/mina wallet balance \ + --address "B62qkiqPXFDayJV8JutYvjerERZ35EKrdmdcXh3j1rDUHRs1bJkFFcX" \ + --endpoint "$MINA_NODE_ENDPOINT" \ + --format json 2>&1 || true) + +echo "JSON format output:" +echo "$JSON_OUTPUT" +echo "" + +# Use jq to validate JSON structure +if echo "$JSON_OUTPUT" | jq empty 2>/dev/null; then + # Check for proper JSON structure using jq + if echo "$JSON_OUTPUT" | jq -e '.account and .balance.total and .balance.total_mina and .nonce' > /dev/null 2>&1; then + echo "✓ Test passed: JSON format has proper structure" + exit 0 + else + echo "✗ Test failed: JSON structure is incomplete" + echo "Expected fields: account, balance.total, balance.total_mina, nonce" + exit 1 + fi +elif echo "$JSON_OUTPUT" | grep -qE "(Error:|\\[ERROR\\])"; then + echo "✗ Test failed: Could not retrieve account balance" + echo "The test account may not exist on the network" + exit 1 +else + echo "✗ Test failed: Output is not valid JSON" + exit 1 +fi diff --git a/.github/scripts/wallet/test-balance-json.sh b/.github/scripts/wallet/test-balance-json.sh new file mode 100755 index 0000000000..4377f12a0d --- /dev/null +++ b/.github/scripts/wallet/test-balance-json.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Test the wallet balance command with --address (JSON format) + +# Check for required environment variables before enabling strict mode +if [ -z "${MINA_NODE_ENDPOINT:-}" ]; then + echo "Error: MINA_NODE_ENDPOINT environment variable is not set" + echo "Please set it to a GraphQL endpoint URL, e.g.:" + echo " export MINA_NODE_ENDPOINT=http://mina-rust-plain-1.gcp.o1test.net/graphql" + exit 1 +fi + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$REPO_ROOT" + +# Check for jq +if ! command -v jq &> /dev/null; then + echo "Error: jq is required but not installed" + echo "Install with: apt-get install jq (Ubuntu/Debian) or brew install jq (macOS)" + exit 1 +fi + +# Define test parameters +PUBLIC_KEY=$(cat "tests/files/accounts/test-block-producer.pub") + +echo "Test: Verify --address option works with public key (JSON format)" +BALANCE_JSON=$(./target/release/mina wallet balance --address "$PUBLIC_KEY" --endpoint "$MINA_NODE_ENDPOINT" --format json 2>&1 || true) + +echo "Balance command output:" +echo "$BALANCE_JSON" +echo "" + +# Command should execute and produce JSON output +# Try to parse as JSON using jq +if echo "$BALANCE_JSON" | jq empty 2>/dev/null; then + echo "✓ Command executed successfully with JSON format" + # Verify JSON format structure using jq + if echo "$BALANCE_JSON" | jq -e '.account and .balance and .nonce' > /dev/null 2>&1; then + echo "✓ JSON output has expected structure" + exit 0 + else + echo "✗ Test failed: JSON output missing expected fields" + exit 1 + fi +elif echo "$BALANCE_JSON" | grep -qE "(Error:|\\[ERROR\\])"; then + # Command executed but got an error (e.g., account doesn't exist) + echo "✓ Command executed (account may not exist on network)" + exit 0 +else + echo "✗ Test failed: Unexpected output" + exit 1 +fi diff --git a/.github/scripts/wallet/test-balance-no-account.sh b/.github/scripts/wallet/test-balance-no-account.sh new file mode 100755 index 0000000000..224b01b498 --- /dev/null +++ b/.github/scripts/wallet/test-balance-no-account.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Test the wallet balance command error when no account specified + +# Check for required environment variables before enabling strict mode +if [ -z "${MINA_NODE_ENDPOINT:-}" ]; then + echo "Error: MINA_NODE_ENDPOINT environment variable is not set" + echo "Please set it to a GraphQL endpoint URL, e.g.:" + echo " export MINA_NODE_ENDPOINT=http://mina-rust-plain-1.gcp.o1test.net/graphql" + exit 1 +fi + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$REPO_ROOT" + +echo "Test: Verify error when no account specified" +ERROR_OUTPUT=$(./target/release/mina wallet balance --endpoint "$MINA_NODE_ENDPOINT" 2>&1 || true) + +echo "Command output:" +echo "$ERROR_OUTPUT" +echo "" + +if echo "$ERROR_OUTPUT" | grep -qE "(\\[ERROR\\].*Either --address or --from must be provided|Either --address or --from must be provided)"; then + echo "✓ Test passed: Proper error when no account specified" + exit 0 +else + echo "✗ Test failed: Expected error message not found" + exit 1 +fi diff --git a/.github/scripts/wallet/test-balance-text-format.sh b/.github/scripts/wallet/test-balance-text-format.sh new file mode 100755 index 0000000000..8060457994 --- /dev/null +++ b/.github/scripts/wallet/test-balance-text-format.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Test the wallet balance command text format output structure + +# Check for required environment variables before enabling strict mode +if [ -z "${MINA_NODE_ENDPOINT:-}" ]; then + echo "Error: MINA_NODE_ENDPOINT environment variable is not set" + echo "Please set it to a GraphQL endpoint URL, e.g.:" + echo " export MINA_NODE_ENDPOINT=http://mina-rust-plain-1.gcp.o1test.net/graphql" + exit 1 +fi + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$REPO_ROOT" + +echo "Test: Verify text format output structure" +TEXT_OUTPUT=$(./target/release/mina wallet balance \ + --address "B62qkiqPXFDayJV8JutYvjerERZ35EKrdmdcXh3j1rDUHRs1bJkFFcX" \ + --endpoint "$MINA_NODE_ENDPOINT" \ + --format text 2>&1 || true) + +echo "Text format output:" +echo "$TEXT_OUTPUT" +echo "" + +if echo "$TEXT_OUTPUT" | grep -q "Account:"; then + # Check for proper text format structure + if echo "$TEXT_OUTPUT" | grep -qE "Balance:" && \ + echo "$TEXT_OUTPUT" | grep -qE "Total:.*MINA" && \ + echo "$TEXT_OUTPUT" | grep -qE "Nonce:"; then + echo "✓ Test passed: Text format has proper structure" + exit 0 + else + echo "✗ Test failed: Text output structure is incomplete" + echo "Expected fields: Balance:, Total: X MINA, Nonce:" + exit 1 + fi +elif echo "$TEXT_OUTPUT" | grep -qE "(Error:|\\[ERROR\\])"; then + echo "✗ Test failed: Could not retrieve account balance" + echo "The test account may not exist on the network" + exit 1 +else + echo "✗ Test failed: Unexpected output format" + exit 1 +fi diff --git a/.github/scripts/wallet/test-generate.sh b/.github/scripts/wallet/test-generate.sh new file mode 100755 index 0000000000..6ef8738a71 --- /dev/null +++ b/.github/scripts/wallet/test-generate.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +set -euo pipefail + +# Test the wallet generate command + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$REPO_ROOT" + +# Define test parameters +TEST_KEY="/tmp/mina-test-generated-key-$$" +TEST_PUBKEY="${TEST_KEY}.pub" +PASSWORD="test-password-e2e" + +# Cleanup function +cleanup() { + # shellcheck disable=SC2317 + rm -f "$TEST_KEY" "$TEST_PUBKEY" +} + +# Set trap to cleanup on exit +trap cleanup EXIT + +echo "Testing: mina wallet generate" +echo "Output file: $TEST_KEY" +echo "" + +# Generate a new key +export MINA_PRIVKEY_PASS="$PASSWORD" +GENERATE_OUTPUT=$(./target/release/mina wallet generate --output "$TEST_KEY") + +echo "Generate command output:" +echo "$GENERATE_OUTPUT" +echo "" + +# Verify the private key file was created +if [ ! -f "$TEST_KEY" ]; then + echo "✗ Test failed: Private key file was not created" + exit 1 +fi +echo "✓ Private key file created" + +# Verify the public key file was created +if [ ! -f "$TEST_PUBKEY" ]; then + echo "✗ Test failed: Public key file was not created" + exit 1 +fi +echo "✓ Public key file created" + +# Extract the public key from the generate output +EXPECTED_PUBKEY=$(cat "$TEST_PUBKEY") +echo "Expected public key: $EXPECTED_PUBKEY" + +# Verify the key can be read back with wallet address command +ACTUAL_PUBKEY=$(./target/release/mina wallet address --from "$TEST_KEY") +echo "Actual public key: $ACTUAL_PUBKEY" +echo "" + +# Compare the public keys +if [ "$ACTUAL_PUBKEY" = "$EXPECTED_PUBKEY" ]; then + echo "✓ Test passed: Generated key can be read back successfully" + exit 0 +else + echo "✗ Test failed: Public key mismatch" + echo " Expected: $EXPECTED_PUBKEY" + echo " Got: $ACTUAL_PUBKEY" + exit 1 +fi diff --git a/.github/scripts/wallet/test-send.sh b/.github/scripts/wallet/test-send.sh new file mode 100755 index 0000000000..726d089a9c --- /dev/null +++ b/.github/scripts/wallet/test-send.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# Test the wallet send command by sending a transaction to the same account +# Uses a very small fee to avoid draining the account on each PR + +# Check for required environment variables before enabling strict mode +if [ -z "${MINA_NODE_ENDPOINT:-}" ]; then + echo "Error: MINA_NODE_ENDPOINT environment variable is not set" + echo "Please set it to a GraphQL endpoint URL, e.g.:" + echo " export MINA_NODE_ENDPOINT=http://mina-rust-plain-1.gcp.o1test.net/graphql" + exit 1 +fi + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +cd "$REPO_ROOT" + +# Define test parameters +KEY_FILE="tests/files/accounts/test-wallet" +PUBKEY_FILE="tests/files/accounts/test-wallet.pub" +NODE_ENDPOINT="$MINA_NODE_ENDPOINT" + +# Password from environment variable (set in GitHub secrets) +# Default to "test" for local testing +PASSWORD="${MINA_PRIVKEY_PASS:-test}" + +# Read the public key (we'll send to ourselves) +RECEIVER=$(cat "$PUBKEY_FILE") + +# Use minimal amounts to avoid draining the account +# 1 nanomina = smallest unit +AMOUNT="1" +# 1000000 nanomina = 0.1 MINA (small but acceptable fee) +FEE="100000000" + +# Optional memo from environment variable (empty by default) +MEMO="${MINA_E2E_TEST_MEMO:-}" + +echo "Test: Send transaction to same account (e2e test)" +echo "Key file: $KEY_FILE" +echo "Receiver: $RECEIVER" +echo "Amount: $AMOUNT nanomina" +echo "Fee: $FEE nanomina" +echo "Node endpoint: $NODE_ENDPOINT" +if [ -n "$MEMO" ]; then + echo "Memo: $MEMO" +fi +echo "" + +# Export password for the CLI +export MINA_PRIVKEY_PASS="$PASSWORD" + +# Build send command arguments +SEND_ARGS=( + --from "$KEY_FILE" + --to "$RECEIVER" + --amount "$AMOUNT" + --fee "$FEE" + --node "$NODE_ENDPOINT" + --network devnet +) + +# Add memo if running in CI +if [ -n "$MEMO" ]; then + SEND_ARGS+=(--memo "$MEMO") +fi + +# Run the wallet send command +echo "Sending transaction..." +SEND_OUTPUT=$(./target/release/mina wallet send "${SEND_ARGS[@]}" 2>&1 || true) + +echo "Send command output:" +echo "$SEND_OUTPUT" +echo "" + +# Check if transaction was submitted successfully +if echo "$SEND_OUTPUT" | grep -q "Transaction submitted successfully!"; then + echo "✓ Transaction submitted successfully" + + # Extract transaction hash + TX_HASH=$(echo "$SEND_OUTPUT" | grep "Transaction hash:" | awk '{print $3}') + + if [ -n "$TX_HASH" ]; then + echo "✓ Transaction hash returned: $TX_HASH" + + # Test the status command with the returned hash + echo "" + echo "Testing status command with transaction hash..." + STATUS_OUTPUT=$(./target/release/mina wallet status \ + --hash "$TX_HASH" \ + --node "$NODE_ENDPOINT" 2>&1 || true) + + echo "Status command output:" + echo "$STATUS_OUTPUT" + echo "" + + # Check if status command worked (either found in mempool or blockchain) + if echo "$STATUS_OUTPUT" | grep -qE "(Transaction found in mempool|Transaction Status:)"; then + echo "✓ Status command successfully checked transaction" + exit 0 + else + echo "✓ Status command executed (transaction may have been processed)" + exit 0 + fi + else + echo "✗ Test failed: No transaction hash returned" + exit 1 + fi +elif echo "$SEND_OUTPUT" | grep -qE "(Error:|\\[ERROR\\])"; then + # Check if it's a known acceptable error + if echo "$SEND_OUTPUT" | grep -q "Node is not synced"; then + echo "⚠ Node is not synced, skipping test" + exit 0 + elif echo "$SEND_OUTPUT" | grep -q "Failed to connect to node"; then + echo "⚠ Could not connect to node, skipping test" + exit 0 + else + echo "✗ Test failed with error:" + echo "$SEND_OUTPUT" + exit 1 + fi +else + echo "✗ Test failed: Unexpected output" + exit 1 +fi diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d3d10ccb9e..06d9efbc4f 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -239,6 +239,35 @@ jobs: - name: Run account tests run: make test-account + wallet-tests: + needs: [build] + timeout-minutes: 10 + runs-on: ubuntu-24.04 + steps: + - name: Git checkout + uses: actions/checkout@v5 + + - name: Download mina binary + uses: actions/download-artifact@v5 + with: + name: bin-${{ github.sha }} + + - name: Make binary executable + run: chmod +x mina + + - name: Create target/release directory + run: mkdir -p target/release + + - name: Move binary to expected location + run: mv mina target/release/ + + - name: Run wallet tests + env: + MINA_PRIVKEY_PASS: ${{ secrets.MINA_PRIVKEY_PASS }} + MINA_NODE_ENDPOINT: http://mina-rust-plain-1.gcp.o1test.net/graphql + MINA_E2E_TEST_MEMO: MINA_RUST PR#${{ github.event.pull_request.number }} + run: make test-wallet + build-tests: timeout-minutes: 60 runs-on: ubuntu-22.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4095eb1ce5..7e711f3a61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 should have on the ledger. Also, document the different types of transactions that can be used to modify the ledger ([#1541](https://github.com/o1-labs/mina-rust/pull/1541)) +- **CLI**: introduce a subcommand `wallet` to be able to send transactions, get + the public key and address from the secret key, get balance and generate + wallets from the CLI. End-to-end tests are added in the CI and a new target + `make test-wallet` has been added. This focuses on basic payments + ([#1543](https://github.com/o1-labs/mina-rust/pull/1543)) ### Changed diff --git a/Cargo.lock b/Cargo.lock index cabc797f32..db58a2c60b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1752,6 +1752,7 @@ dependencies = [ "mina-node-account", "mina-node-native", "mina-p2p-messages", + "mina-signer", "mina-tree", "nix 0.27.1", "node", diff --git a/Makefile b/Makefile index 0c19b43472..ae4cac1f4f 100644 --- a/Makefile +++ b/Makefile @@ -309,6 +309,15 @@ test-vrf: ## Run VRF tests, requires nightly Rust test-account: ## Run account tests @cargo test -p mina-node-account +.PHONY: test-wallet +test-wallet: ## Run wallet CLI end-to-end tests + @echo "Running wallet CLI tests..." + @for script in .github/scripts/wallet/*.sh; do \ + echo "Running $$script..."; \ + $$script || exit 1; \ + done + @echo "All wallet tests passed!" + .PHONY: test-p2p-messages test-p2p-messages: cargo test -p mina-p2p-messages --tests --release diff --git a/cli/Cargo.toml b/cli/Cargo.toml index cfa97b13a3..e3d80bacd0 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -34,6 +34,7 @@ dialoguer = { workspace = true } mina-core = { path = "../core" } mina-node-account = { workspace = true } mina-node-native = { path = "../node/native" } +mina-signer = { workspace = true } nix = { workspace = true, features = ["signal"] } node = { path = "../node", features = ["replay"] } serde_json = { workspace = true } diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 563cbf98c2..d3e980cc36 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod misc; pub mod node; pub mod replay; pub mod snark; +pub mod wallet; #[derive(Debug, clap::Parser)] #[command(name = "mina", about = "Mina Cli")] @@ -37,16 +38,19 @@ pub enum Command { Misc(misc::Misc), Replay(replay::Replay), BuildInfo(build_info::Command), + /// Wallet operations for managing accounts and sending transactions. + Wallet(wallet::Wallet), } impl Command { - pub fn run(self) -> anyhow::Result<()> { + pub fn run(self, network: Network) -> anyhow::Result<()> { match self { Self::Snark(v) => v.run(), Self::Node(v) => v.run(), Self::Misc(v) => v.run(), Self::Replay(v) => v.run(), Self::BuildInfo(v) => v.run(), + Self::Wallet(v) => v.run(network), } } } diff --git a/cli/src/commands/wallet/address.rs b/cli/src/commands/wallet/address.rs new file mode 100644 index 0000000000..0861cc682d --- /dev/null +++ b/cli/src/commands/wallet/address.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use mina_node_account::AccountSecretKey; + +#[derive(Debug, clap::Args)] +pub struct Address { + /// Path to encrypted key file + #[arg(long, env)] + pub from: PathBuf, + + /// Password to decrypt the key + #[arg( + env = "MINA_PRIVKEY_PASS", + default_value = "", + help = "Password to decrypt the key (env: MINA_PRIVKEY_PASS)" + )] + pub password: String, +} + +impl Address { + pub fn run(self) -> Result<()> { + // Load and decrypt the key + let secret_key = AccountSecretKey::from_encrypted_file(&self.from, &self.password) + .with_context(|| format!("Failed to decrypt key file: {}", self.from.display()))?; + + // Get the public key + let public_key = secret_key.public_key(); + + // Display the address + println!("{}", public_key); + + Ok(()) + } +} diff --git a/cli/src/commands/wallet/balance.rs b/cli/src/commands/wallet/balance.rs new file mode 100644 index 0000000000..b3f53f4b45 --- /dev/null +++ b/cli/src/commands/wallet/balance.rs @@ -0,0 +1,234 @@ +use anyhow::Context; +use mina_node_account::AccountSecretKey; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, clap::Args)] +pub struct Balance { + /// Public key to query the balance for + #[arg(long, conflicts_with = "from")] + pub address: Option, + + /// Path to encrypted key file + #[arg(long, conflicts_with = "address")] + pub from: Option, + + /// Password to decrypt the key + #[arg( + env = "MINA_PRIVKEY_PASS", + default_value = "", + help = "Password to decrypt the key (env: MINA_PRIVKEY_PASS)" + )] + pub password: String, + + /// GraphQL endpoint URL + #[arg( + long, + default_value = "http://localhost:3000/graphql", + help = "GraphQL endpoint URL" + )] + pub endpoint: String, + + /// Output format (text or json) + #[arg(long, default_value = "text")] + pub format: OutputFormat, +} + +#[derive(Debug, Clone, clap::ValueEnum)] +pub enum OutputFormat { + Text, + Json, +} + +#[derive(Serialize)] +struct GraphQLRequest { + query: String, + variables: serde_json::Value, +} + +#[derive(Deserialize, Debug)] +struct GraphQLResponse { + data: Option, + errors: Option>, +} + +#[derive(Deserialize, Debug)] +struct DataResponse { + account: Option, +} + +#[derive(Deserialize, Debug)] +struct AccountResponse { + balance: BalanceResponse, + nonce: String, + #[serde(rename = "delegateAccount")] + delegate_account: Option, +} + +#[derive(Deserialize, Debug)] +struct BalanceResponse { + total: String, + liquid: Option, + locked: Option, +} + +#[derive(Deserialize, Debug)] +struct DelegateAccount { + #[serde(rename = "publicKey")] + public_key: String, +} + +#[derive(Deserialize, Debug)] +struct GraphQLError { + message: String, +} + +#[derive(Serialize, Debug)] +struct BalanceOutput { + account: String, + balance: BalanceOutputData, + nonce: String, + delegate: Option, +} + +#[derive(Serialize, Debug)] +struct BalanceOutputData { + total: String, + total_mina: String, + liquid: Option, + liquid_mina: Option, + locked: Option, + locked_mina: Option, +} + +impl Balance { + pub fn run(self) -> anyhow::Result<()> { + // Get the public key either from address or from key file + let public_key = if let Some(address) = self.address { + address + } else if let Some(from) = self.from { + if self.password.is_empty() { + anyhow::bail!( + "Password is required when using --from. Provide it via --password argument or MINA_PRIVKEY_PASS environment variable" + ); + } + let secret_key = AccountSecretKey::from_encrypted_file(&from, &self.password) + .with_context(|| format!("Failed to decrypt key file: {}", from.display()))?; + secret_key.public_key().to_string() + } else { + anyhow::bail!("Either --address or --from must be provided to specify the account"); + }; + + // GraphQL query + let query = r#" + query GetBalance($publicKey: String!) { + account(publicKey: $publicKey) { + balance { + total + liquid + locked + } + nonce + delegateAccount { + publicKey + } + } + } + "#; + + let variables = serde_json::json!({ + "publicKey": public_key + }); + + let request = GraphQLRequest { + query: query.to_string(), + variables, + }; + + // Make the GraphQL request + let client = reqwest::blocking::Client::new(); + let response = client + .post(&self.endpoint) + .json(&request) + .send() + .with_context(|| format!("Failed to connect to GraphQL endpoint: {}", self.endpoint))?; + + if !response.status().is_success() { + anyhow::bail!("GraphQL request failed with status: {}", response.status()); + } + + let graphql_response: GraphQLResponse = response + .json() + .context("Failed to parse GraphQL response")?; + + // Check for GraphQL errors + if let Some(errors) = graphql_response.errors { + let error_messages: Vec = errors.iter().map(|e| e.message.clone()).collect(); + anyhow::bail!("GraphQL errors: {}", error_messages.join(", ")); + } + + // Extract account data + let account = graphql_response + .data + .and_then(|d| d.account) + .with_context(|| format!("Account not found: {}", public_key))?; + + // Create output structure + let output = BalanceOutput { + account: public_key.clone(), + balance: BalanceOutputData { + total: account.balance.total.clone(), + total_mina: format_balance(&account.balance.total), + liquid: account.balance.liquid.clone(), + liquid_mina: account.balance.liquid.as_ref().map(|l| format_balance(l)), + locked: account.balance.locked.clone(), + locked_mina: account.balance.locked.as_ref().map(|l| format_balance(l)), + }, + nonce: account.nonce.clone(), + delegate: account + .delegate_account + .as_ref() + .map(|d| d.public_key.clone()), + }; + + // Display the balance information based on format + match self.format { + OutputFormat::Json => { + let json = serde_json::to_string_pretty(&output) + .context("Failed to serialize output to JSON")?; + println!("{}", json); + } + OutputFormat::Text => { + println!("Account: {}", output.account); + println!(); + println!("Balance:"); + println!(" Total: {} MINA", output.balance.total_mina); + + if let Some(liquid_mina) = &output.balance.liquid_mina { + println!(" Liquid: {} MINA", liquid_mina); + } + + if let Some(locked_mina) = &output.balance.locked_mina { + println!(" Locked: {} MINA", locked_mina); + } + + println!(); + println!("Nonce: {}", output.nonce); + + if let Some(delegate) = &output.delegate { + println!(); + println!("Delegate: {}", delegate); + } + } + } + + Ok(()) + } +} + +fn format_balance(nanomina: &str) -> String { + // Convert nanomina to MINA (1 MINA = 1,000,000,000 nanomina) + let nano = nanomina.parse::().unwrap_or(0); + let mina = nano as f64 / 1_000_000_000.0; + format!("{:.9}", mina) +} diff --git a/cli/src/commands/wallet/generate.rs b/cli/src/commands/wallet/generate.rs new file mode 100644 index 0000000000..5dba8ab596 --- /dev/null +++ b/cli/src/commands/wallet/generate.rs @@ -0,0 +1,53 @@ +use mina_node_account::AccountSecretKey; +use std::path::PathBuf; + +#[derive(Debug, clap::Args)] +pub struct Generate { + /// Path where the encrypted key file will be saved + #[arg(long)] + pub output: PathBuf, + + /// Password to encrypt the key + #[arg( + env = "MINA_PRIVKEY_PASS", + default_value = "", + help = "Password to encrypt the key (env: MINA_PRIVKEY_PASS)" + )] + pub password: String, +} + +impl Generate { + pub fn run(self) -> anyhow::Result<()> { + // Check if password is provided + if self.password.is_empty() { + anyhow::bail!( + "Password is required. Provide it via --password argument or MINA_PRIVKEY_PASS environment variable" + ); + } + + // Check if output file already exists + if self.output.exists() { + anyhow::bail!("File already exists: {}", self.output.display()); + } + + // Generate a new random keypair + let secret_key = AccountSecretKey::rand(); + let public_key = secret_key.public_key(); + + // Save the encrypted key to file + secret_key.to_encrypted_file(&self.output, &self.password)?; + + // Save the public key to a separate file + let pubkey_path = format!("{}.pub", self.output.display()); + std::fs::write(&pubkey_path, public_key.to_string())?; + + println!("Generated new encrypted key:"); + println!(" Private key: {}", self.output.display()); + println!(" Public key: {}", pubkey_path); + println!(" Address: {}", public_key); + println!(); + println!("Keep your encrypted key file and password secure!"); + + Ok(()) + } +} diff --git a/cli/src/commands/wallet/mod.rs b/cli/src/commands/wallet/mod.rs new file mode 100644 index 0000000000..8ac17d47ff --- /dev/null +++ b/cli/src/commands/wallet/mod.rs @@ -0,0 +1,47 @@ +pub mod address; +pub mod balance; +pub mod generate; +pub mod send; +pub mod status; + +use super::Network; +use crate::exit_with_error; + +#[derive(Debug, clap::Args)] +pub struct Wallet { + #[command(subcommand)] + pub command: WalletCommand, +} + +#[derive(Debug, clap::Subcommand)] +pub enum WalletCommand { + /// Get the address from an encrypted key file + Address(address::Address), + /// Get account balance via GraphQL + Balance(balance::Balance), + /// Generate a new encrypted key pair + Generate(generate::Generate), + /// Send a payment transaction + Send(send::Send), + /// Check transaction status + Status(status::Status), +} + +impl Wallet { + pub fn run(self, network: Network) -> anyhow::Result<()> { + let result = match self.command { + WalletCommand::Address(cmd) => cmd.run(), + WalletCommand::Balance(cmd) => cmd.run(), + WalletCommand::Generate(cmd) => cmd.run(), + WalletCommand::Send(cmd) => cmd.run(network), + WalletCommand::Status(cmd) => cmd.run(), + }; + + // Handle errors without backtraces for wallet commands + if let Err(err) = result { + exit_with_error(err); + } + + Ok(()) + } +} diff --git a/cli/src/commands/wallet/send.rs b/cli/src/commands/wallet/send.rs new file mode 100644 index 0000000000..af8f8f87a1 --- /dev/null +++ b/cli/src/commands/wallet/send.rs @@ -0,0 +1,405 @@ +use std::{path::PathBuf, str::FromStr}; + +use anyhow::{Context, Result}; +use ledger::scan_state::{ + currency::{Amount, Fee, Nonce, Slot}, + transaction_logic::{ + signed_command::{Body, Common, PaymentPayload, SignedCommand, SignedCommandPayload}, + transaction_union_payload::TransactionUnionPayload, + Memo, + }, +}; +use mina_node_account::{AccountPublicKey, AccountSecretKey}; +use mina_p2p_messages::v2::MinaBaseSignedCommandStableV2; +use mina_signer::{CompressedPubKey, Keypair, Signer}; + +use super::super::Network; + +fn network_to_network_id(network: &Network) -> mina_signer::NetworkId { + match network { + Network::Mainnet => mina_signer::NetworkId::MAINNET, + Network::Devnet => mina_signer::NetworkId::TESTNET, + } +} + +#[derive(Debug, clap::Args)] +pub struct Send { + /// Path to encrypted sender key file + #[arg(long, env)] + pub from: PathBuf, + + /// Password to decrypt the sender key + #[arg( + env = "MINA_PRIVKEY_PASS", + default_value = "", + help = "Password to decrypt the sender key (env: MINA_PRIVKEY_PASS)" + )] + pub password: String, + + /// Receiver's public key + #[arg(long)] + pub to: AccountPublicKey, + + /// Amount in nanomina (1 MINA = 1,000,000,000 nanomina) + #[arg(long)] + pub amount: u64, + + /// Transaction fee in nanomina + #[arg(long)] + pub fee: u64, + + /// Optional memo (max 32 bytes) + #[arg(long, default_value = "")] + pub memo: String, + + /// Transaction nonce (if not provided, will be fetched from node) + #[arg(long)] + pub nonce: Option, + + /// Slot number until which transaction is valid + /// If not provided, defaults to maximum slot (transaction never expires) + #[arg(long)] + pub valid_until: Option, + + /// Optional fee payer public key (if different from sender) + /// If not provided, the sender will pay the fee + #[arg(long)] + pub fee_payer: Option, + + /// Node RPC endpoint + #[arg(long, default_value = "http://localhost:3000")] + pub node: String, +} + +impl Send { + pub fn run(self, network: Network) -> Result<()> { + // Check node is synced and on the correct network + println!("Checking node status..."); + self.check_node_status(&network)?; + + // Load the sender's secret key + let sender_key = AccountSecretKey::from_encrypted_file(&self.from, &self.password) + .with_context(|| { + format!("Failed to decrypt sender key file: {}", self.from.display()) + })?; + + let sender_pk = sender_key.public_key_compressed(); + println!("Sender: {}", AccountPublicKey::from(sender_pk.clone())); + + // Determine the fee payer (use fee_payer if provided, otherwise use sender) + let fee_payer_pk: CompressedPubKey = if let Some(ref fee_payer) = self.fee_payer { + println!("Fee payer: {}", fee_payer); + fee_payer + .clone() + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid fee payer public key"))? + } else { + sender_pk.clone() + }; + + // Convert receiver public key to CompressedPubKey + let receiver_pk: CompressedPubKey = self + .to + .clone() + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid receiver public key"))?; + + // Fetch nonce from node if not provided + // Note: GraphQL API expects nonce to be account_nonce, but we need to sign + // with account_nonce for the first transaction from a new account + let nonce = if let Some(nonce) = self.nonce { + nonce + } else { + println!("Fetching nonce from node..."); + self.fetch_nonce(&fee_payer_pk)? + }; + + println!("Using nonce: {}", nonce); + + // Create the payment payload + let payload = SignedCommandPayload { + common: Common { + fee: Fee::from_u64(self.fee), + fee_payer_pk, + nonce: Nonce::from_u32(nonce), + valid_until: self + .valid_until + .map(Slot::from_u32) + .unwrap_or_else(Slot::max), + memo: Memo::from_str(&self.memo).unwrap_or_else(|_| Memo::empty()), + }, + body: Body::Payment(PaymentPayload { + receiver_pk, + amount: Amount::from_u64(self.amount), + }), + }; + + // Sign the transaction + println!("Signing transaction..."); + let network_id = network_to_network_id(&network); + let signed_command = self.sign_transaction(payload, &sender_key, network_id)?; + + // Submit to node + println!("Submitting transaction to node..."); + let tx_hash = self.submit_transaction(signed_command)?; + + println!("\nTransaction submitted successfully!"); + println!("Transaction hash: {}", tx_hash); + println!("Status: Pending"); + println!("\nYou can check the transaction status with:"); + println!(" mina wallet status --hash {}", tx_hash); + + Ok(()) + } + + fn check_node_status(&self, network: &Network) -> Result<()> { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + let url = format!("{}/graphql", self.node); + + // GraphQL query to check sync status and network ID + let query = serde_json::json!({ + "query": r#"query { + syncStatus + networkID + }"# + }); + + let response = client + .post(&url) + .json(&query) + .send() + .context("Failed to query node status")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to connect to node: HTTP {}", response.status()); + } + + let response_json: serde_json::Value = response + .json() + .context("Failed to parse GraphQL response")?; + + // Check for GraphQL errors + if let Some(errors) = response_json.get("errors") { + let error_msg = serde_json::to_string_pretty(errors) + .unwrap_or_else(|_| "Unknown GraphQL error".to_string()); + anyhow::bail!("GraphQL error: {}", error_msg); + } + + // Check sync status + let sync_status = response_json["data"]["syncStatus"] + .as_str() + .context("Sync status not found in GraphQL response")?; + + if sync_status != "SYNCED" { + anyhow::bail!( + "Node is not synced (status: {}). Please wait for the node to sync before sending transactions.", + sync_status + ); + } + + println!("Node is synced: {}", sync_status); + + // Check network ID + let network_id = response_json["data"]["networkID"] + .as_str() + .context("Network ID not found in GraphQL response")?; + + // Expected network ID based on selected network + let expected_network = match network { + Network::Mainnet => "mina:mainnet", + Network::Devnet => "mina:devnet", + }; + + if !network_id.contains(expected_network) { + anyhow::bail!( + "Network mismatch: node is on '{}' but you selected {:?}. Use --network to specify the correct network.", + network_id, + network + ); + } + + println!("Network verified: {}", network_id); + + Ok(()) + } + + fn fetch_nonce(&self, sender_pk: &CompressedPubKey) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + let url = format!("{}/graphql", self.node); + + // GraphQL query to fetch account information + let query = serde_json::json!({ + "query": format!( + r#"query {{ + account(publicKey: "{}") {{ + nonce + }} + }}"#, + AccountPublicKey::from(sender_pk.clone()).to_string() + ) + }); + + let response = client + .post(&url) + .json(&query) + .send() + .context("Failed to query account from node")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch account: HTTP {}", response.status()); + } + + let response_json: serde_json::Value = response + .json() + .context("Failed to parse GraphQL response")?; + + // Extract nonce from GraphQL response + let nonce_str = response_json["data"]["account"]["nonce"] + .as_str() + .context("Nonce not found in GraphQL response")?; + + let nonce = nonce_str + .parse::() + .context("Failed to parse nonce as u32")?; + + Ok(nonce) + } + + fn sign_transaction( + &self, + payload: SignedCommandPayload, + sender_key: &AccountSecretKey, + network_id: mina_signer::NetworkId, + ) -> Result { + // Create the transaction union payload for signing + let payload_to_sign = TransactionUnionPayload::of_user_command_payload(&payload); + + // Create signer and sign the transaction + let mut signer = mina_signer::create_legacy(network_id); + let kp: Keypair = sender_key.clone().into(); + // Use packed=true for OCaml/TypeScript compatibility (required by Mina protocol) + let signature = signer.sign(&kp, &payload_to_sign, true); + + Ok(SignedCommand { + payload, + signer: sender_key.public_key_compressed(), + signature, + }) + } + + fn submit_transaction(&self, signed_command: SignedCommand) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .context("Failed to create HTTP client")?; + let url = format!("{}/graphql", self.node); + + // Convert to v2 types for easier field extraction + let signed_cmd_v2: MinaBaseSignedCommandStableV2 = (&signed_command).into(); + + // Convert signature to GraphQL format (field and scalar as decimal strings) + let sig_field = + mina_p2p_messages::bigint::BigInt::from(signed_command.signature.rx).to_decimal(); + let sig_scalar = + mina_p2p_messages::bigint::BigInt::from(signed_command.signature.s).to_decimal(); + + // Extract payment details from signed command + let (receiver_pk, amount) = match &signed_cmd_v2.payload.body { + mina_p2p_messages::v2::MinaBaseSignedCommandPayloadBodyStableV2::Payment(payment) => { + (payment.receiver_pk.to_string(), payment.amount.to_string()) + } + _ => anyhow::bail!("Expected payment body in signed command"), + }; + + let fee_payer_pk = signed_cmd_v2.payload.common.fee_payer_pk.to_string(); + + // Build memo field - omit if empty + let memo_field = if self.memo.is_empty() { + String::new() + } else { + format!(r#"memo: "{}""#, self.memo) + }; + + // Build GraphQL mutation + let mutation = format!( + r#"mutation {{ + sendPayment( + input: {{ + from: "{}" + to: "{}" + amount: "{}" + fee: "{}" + {} + nonce: "{}" + validUntil: "{}" + }} + signature: {{ + field: "{}" + scalar: "{}" + }} + ) {{ + payment {{ + hash + id + }} + }} + }}"#, + fee_payer_pk, + receiver_pk, + amount, + ***signed_cmd_v2.payload.common.fee, + memo_field, + **signed_cmd_v2.payload.common.nonce, + signed_cmd_v2.payload.common.valid_until.as_u32(), + sig_field, + sig_scalar, + ); + + let query = serde_json::json!({ + "query": mutation + }); + + let response = client + .post(&url) + .json(&query) + .send() + .context("Failed to submit transaction to node")?; + + let status = response.status(); + if !status.is_success() { + let error_text = response + .text() + .unwrap_or_else(|_| "Unknown error".to_string()); + anyhow::bail!( + "Failed to submit transaction: HTTP {} - {}", + status, + error_text + ); + } + + let response_json: serde_json::Value = response + .json() + .context("Failed to parse GraphQL response")?; + + // Check for GraphQL errors + if let Some(errors) = response_json.get("errors") { + let error_msg = serde_json::to_string_pretty(errors) + .unwrap_or_else(|_| "Unknown GraphQL error".to_string()); + anyhow::bail!("GraphQL error: {}", error_msg); + } + + // Extract transaction hash from response + let hash = response_json["data"]["sendPayment"]["payment"]["hash"] + .as_str() + .context("Transaction hash not found in GraphQL response")? + .to_string(); + + Ok(hash) + } +} diff --git a/cli/src/commands/wallet/status.rs b/cli/src/commands/wallet/status.rs new file mode 100644 index 0000000000..b8be07c486 --- /dev/null +++ b/cli/src/commands/wallet/status.rs @@ -0,0 +1,167 @@ +use anyhow::{Context, Result}; +use serde_json::Value; + +#[derive(Debug, clap::Args)] +pub struct Status { + /// Transaction hash to check + #[arg(long)] + pub hash: String, + + /// Node RPC endpoint + #[arg(long, default_value = "http://localhost:3000")] + pub node: String, + + /// Check if transaction is in mempool (pooled transactions) + #[arg(long)] + pub check_mempool: bool, +} + +impl Status { + pub fn run(self) -> Result<()> { + println!("Checking transaction status..."); + println!("Transaction hash: {}", self.hash); + + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("Failed to create HTTP client")?; + + let url = format!("{}/graphql", self.node); + + // First, try to find the transaction in the best chain + let query = serde_json::json!({ + "query": format!( + r#"query {{ + transactionStatus(payment: "{}") + }}"#, + self.hash + ) + }); + + let response = client + .post(&url) + .json(&query) + .send() + .context("Failed to query transaction status")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to query node: HTTP {}", response.status()); + } + + let response_json: Value = response + .json() + .context("Failed to parse GraphQL response")?; + + // Check for GraphQL errors + if let Some(_errors) = response_json.get("errors") { + // Transaction might not be found in the chain yet + // Automatically check mempool as fallback + println!("\nTransaction not found in blockchain, checking mempool..."); + return self.check_pooled_transactions(&client, &url); + } + + // Parse transaction status + if let Some(status) = response_json["data"]["transactionStatus"].as_str() { + println!("\nTransaction Status: {}", status); + + match status { + "INCLUDED" => { + println!("✓ Transaction has been included in a block"); + } + "PENDING" => { + println!("⏳ Transaction is pending inclusion"); + } + "UNKNOWN" => { + println!("? Transaction status is unknown"); + if !self.check_mempool { + println!( + "\nTry using --check-mempool to check if it's in the transaction pool" + ); + } + } + _ => { + println!("Status: {}", status); + } + } + } else { + println!("Unable to determine transaction status"); + } + + Ok(()) + } + + fn check_pooled_transactions( + &self, + client: &reqwest::blocking::Client, + url: &str, + ) -> Result<()> { + let query = serde_json::json!({ + "query": r#"query { + pooledUserCommands { + hash + from + to + amount + fee + nonce + } + }"# + }); + + let response = client + .post(url) + .json(&query) + .send() + .context("Failed to query pooled transactions")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to query mempool: HTTP {}", response.status()); + } + + let response_json: Value = response + .json() + .context("Failed to parse GraphQL response")?; + + if let Some(pooled) = response_json["data"]["pooledUserCommands"].as_array() { + // Look for our transaction in the pool + for tx in pooled { + if let Some(hash) = tx["hash"].as_str() { + if hash == self.hash { + println!("\n✓ Transaction found in mempool!"); + println!("\nTransaction Details:"); + println!(" Hash: {}", hash); + if let Some(from) = tx["from"].as_str() { + println!(" From: {}", from); + } + if let Some(to) = tx["to"].as_str() { + println!(" To: {}", to); + } + if let Some(amount) = tx["amount"].as_str() { + println!(" Amount: {} nanomina", amount); + } + if let Some(fee) = tx["fee"].as_str() { + println!(" Fee: {} nanomina", fee); + } + // Nonce can be either string or number depending on the node + if let Some(nonce) = tx["nonce"].as_u64() { + println!(" Nonce: {}", nonce); + } else if let Some(nonce) = tx["nonce"].as_str() { + println!(" Nonce: {}", nonce); + } + + println!("\nStatus: PENDING (waiting to be included in a block)"); + return Ok(()); + } + } + } + + println!("\n✗ Transaction not found in mempool"); + println!("\nThe transaction may have:"); + println!(" - Already been included in a block"); + println!(" - Been rejected by the network"); + println!(" - Not yet propagated to this node"); + } + + Ok(()) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 406e31bf8e..3fc90cedd5 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -136,5 +136,5 @@ fn main() -> anyhow::Result<()> { network_init_result.expect("Failed to initialize network configuration"); - app.command.run() + app.command.run(app.network) } diff --git a/cli/tests/bootstrap.rs b/cli/tests/bootstrap.rs index c2d5281ecb..b026740221 100644 --- a/cli/tests/bootstrap.rs +++ b/cli/tests/bootstrap.rs @@ -1,3 +1,8 @@ +use clap::Parser; +use cli::commands::Network; +use mina_core::log::system_time; +use node::stats::sync::{SyncSnarkedLedger, SyncStagedLedger, SyncStatsSnapshot}; +use redux::Timestamp; use std::{ fs::File, io::{Read, Write}, @@ -7,11 +12,6 @@ use std::{ time::{Duration, Instant}, }; -use clap::Parser; -use mina_core::log::system_time; -use node::stats::sync::{SyncSnarkedLedger, SyncStagedLedger, SyncStatsSnapshot}; -use redux::Timestamp; - #[test] fn bootstrap() -> anyhow::Result<()> { let child = spawn_node()?; @@ -37,7 +37,7 @@ fn run_node() -> anyhow::Result<()> { if let Err(e) = cli::commands::MinaCli::parse_from([std::env::args().next().unwrap(), String::from("node")]) .command - .run() + .run(Network::Devnet) { anyhow::bail!(format!("{e:#}")); } diff --git a/node/account/tests/test_secret_key.rs b/node/account/tests/test_secret_key.rs index 63d4ed5dee..3a751f4b86 100644 --- a/node/account/tests/test_secret_key.rs +++ b/node/account/tests/test_secret_key.rs @@ -1,4 +1,4 @@ -use std::env; +use std::{env, fs}; use mina_node_account::AccountSecretKey; @@ -48,3 +48,44 @@ fn test_encrypt_decrypt() { "Encrypted and decrypted public keys do not match" ); } + +#[test] +fn test_block_producer_key_decrypt() { + // Get the workspace root directory + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace_root = format!("{}/../..", manifest_dir); + let key_path = format!( + "{}/tests/files/accounts/test-block-producer", + workspace_root + ); + let pubkey_path = format!( + "{}/tests/files/accounts/test-block-producer.pub", + workspace_root + ); + let password = "test-password"; + + // Load and decrypt the key + let secret_key = + AccountSecretKey::from_encrypted_file(&key_path, password).unwrap_or_else(|e| { + panic!( + "Failed to decrypt secret key file: {} - Error: {}", + key_path, e + ) + }); + + // Get the public key from the decrypted secret key + let public_key_from_secret = secret_key.public_key(); + + // Load the expected public key from file + let expected_public_key = fs::read_to_string(&pubkey_path) + .unwrap_or_else(|_| panic!("Failed to read public key file: {}", pubkey_path)) + .trim() + .to_string(); + + // Verify they match + assert_eq!( + public_key_from_secret.to_string(), + expected_public_key, + "Public key from decrypted secret key does not match expected public key" + ); +} diff --git a/tests/files/accounts/test-block-producer b/tests/files/accounts/test-block-producer new file mode 100644 index 0000000000..a858af5aaa --- /dev/null +++ b/tests/files/accounts/test-block-producer @@ -0,0 +1 @@ +{"box_primitive":"xsalsa20poly1305","pw_primitive":"argon2i","nonce":"7JvdGX6pQdCXZMoW9xWjPmAxYYdXBHCQHTDGv1Q","pwsalt":"A4eT1QF8C9daJSSFGwXVv45i4dwC","pwdiff":[134217728,6],"ciphertext":"DRf49D9p418QD6xdZ5WTZhdTZGd4qQL9979oDNkAacEpgX6iTjxYT2SEdR52HjgtRbWyUcGWR"} \ No newline at end of file diff --git a/tests/files/accounts/test-block-producer.pub b/tests/files/accounts/test-block-producer.pub new file mode 100644 index 0000000000..f97722cfdc --- /dev/null +++ b/tests/files/accounts/test-block-producer.pub @@ -0,0 +1 @@ +B62qoXaU7b4mjbSFtSVzZxWRPn4Po3Dcstt8hxxwSnfYL9CwtZz4TR1 \ No newline at end of file diff --git a/tests/files/accounts/test-wallet b/tests/files/accounts/test-wallet new file mode 100644 index 0000000000..95fe94d217 --- /dev/null +++ b/tests/files/accounts/test-wallet @@ -0,0 +1 @@ +{"box_primitive":"xsalsa20poly1305","pw_primitive":"argon2i","nonce":"6a5Zujm1bg6YUJZJQjfH5wkfV36UVjGujUY6V6c","pwsalt":"8Jr23F9eEqfJSfUz4pJkySW9J5M7","pwdiff":[134217728,6],"ciphertext":"BMhTr1s13H4v93wAyFW1DbjiSicUgiGuu4C7UfDfW8Xr8XXmQ4CeZBs5XDGM9YQDuH3TeaD1q"} \ No newline at end of file diff --git a/tests/files/accounts/test-wallet.pub b/tests/files/accounts/test-wallet.pub new file mode 100644 index 0000000000..a1615406cf --- /dev/null +++ b/tests/files/accounts/test-wallet.pub @@ -0,0 +1 @@ +B62qjtpVAMr7knjLxRLU887QgT7GPk3JYCg8NGdZsfMuaykAJ9C2Rem \ No newline at end of file diff --git a/website/docs/developers/wallet.md b/website/docs/developers/wallet.md new file mode 100644 index 0000000000..424fea83be --- /dev/null +++ b/website/docs/developers/wallet.md @@ -0,0 +1,463 @@ +--- +title: Wallet operations +description: CLI wallet commands for managing accounts and sending transactions +sidebar_position: 15 +--- + +# Wallet operations + +The Mina CLI provides wallet functionality for sending transactions and managing +accounts. All wallet operations use encrypted key files and interact with a +running Mina node via GraphQL. + +## Prerequisites + +Before using wallet commands, you need: + +- An encrypted private key file +- The password to decrypt the key +- A running Mina node (local or remote) - only required for sending transactions + and checking balances + +## Generate key pair + +Generate a new encrypted key pair for use with Mina. + +### Basic usage + +```bash +mina wallet generate --output /path/to/key +``` + +### Arguments + +**Required:** + +- `--output ` - Path where the encrypted key file will be saved + +**Optional:** + +- `[PASSWORD]` - Password to encrypt the key. Can be provided as an argument or + via the `MINA_PRIVKEY_PASS` environment variable (recommended for security) + +### Example + +```bash +# Generate new key with environment variable for password +export MINA_PRIVKEY_PASS="my-secret-password" +mina wallet generate --output ./keys/my-new-wallet +``` + +This command generates a new random keypair, encrypts the private key with the +provided password, and saves it to the specified path. It also creates a `.pub` +file containing the public key. + +## Get address from key file + +Extract the public address from an encrypted key file. + +### Basic usage + +```bash +mina wallet address --from /path/to/encrypted/key +``` + +### Arguments + +**Required:** + +- `--from ` - Path to encrypted key file + +**Optional:** + +- `[PASSWORD]` - Password to decrypt the key. Can be provided as an argument or + via the `MINA_PRIVKEY_PASS` environment variable (recommended for security) + +### Example + +```bash +# Get address from encrypted key +mina wallet address --from ./keys/my-wallet + +# Using environment variable for password +export MINA_PRIVKEY_PASS="my-secret-password" +mina wallet address --from ./keys/my-wallet +``` + +This command simply decrypts the key file and displays the associated public +address. It does not require a connection to a node. + +## Check account balance + +Query the balance of an account using GraphQL. + +### Basic usage + +```bash +# Check balance using key file +mina wallet balance --from /path/to/encrypted/key + +# Check balance using public address +mina wallet balance --address +``` + +### Arguments + +**Required (one of):** + +- `--from ` - Path to encrypted key file +- `--address ` - Public key to query directly + +**Optional:** + +- `[PASSWORD]` - Password to decrypt the key (only required when using + `--from`). Can be provided as an argument or via the `MINA_PRIVKEY_PASS` + environment variable (recommended for security) +- `--endpoint ` - GraphQL endpoint URL (default: + `http://localhost:3000/graphql`) +- `--format ` - Output format: `text` (default) or `json` + +### Examples + +#### Check balance using key file + +```bash +export MINA_PRIVKEY_PASS="my-secret-password" +mina wallet balance --from ./keys/my-wallet +``` + +#### Check balance using public address + +```bash +mina wallet balance \ + --address B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy +``` + +#### Check balance on remote node + +```bash +mina wallet balance \ + --address B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy \ + --endpoint https://node.example.com:3000/graphql +``` + +#### Get balance in JSON format + +```bash +mina wallet balance \ + --address B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy \ + --format json +``` + +### Output + +The balance command displays: + +- **Total balance** - Total amount of MINA in the account (both nanomina and + MINA) +- **Liquid balance** - Amount available for spending +- **Locked balance** - Amount locked due to vesting schedule +- **Nonce** - Current account nonce +- **Delegate** - Public key of the delegate (if set) + +#### Text format (default) + +``` +Account: B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy + +Balance: + Total: 1000.000000000 MINA + Liquid: 800.000000000 MINA + Locked: 200.000000000 MINA + +Nonce: 5 + +Delegate: B62qkfHpLpELqpMK6ZvUTJ5wRqKDRF3UHyJ4Kv3FU79Sgs4qpBnx5RG +``` + +#### JSON format + +```json +{ + "account": "B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy", + "balance": { + "total": "1000000000000", + "total_mina": "1000.000000000", + "liquid": "800000000000", + "liquid_mina": "800.000000000", + "locked": "200000000000", + "locked_mina": "200.000000000" + }, + "nonce": "5", + "delegate": "B62qkfHpLpELqpMK6ZvUTJ5wRqKDRF3UHyJ4Kv3FU79Sgs4qpBnx5RG" +} +``` + +The JSON format includes both nanomina (raw values) and formatted MINA values +for convenience. + +## Send payment + +Send a payment transaction to the network. + +### Basic usage + +```bash +mina wallet send \ + --from /path/to/encrypted/key \ + --to \ + --amount \ + --fee +``` + +### Arguments + +**Required:** + +- `--from ` - Path to encrypted sender key file +- `--to ` - Receiver's public key (Base58Check encoded) +- `--amount ` - Amount in nanomina (1 MINA = 1,000,000,000 nanomina) +- `--fee ` - Transaction fee in nanomina + +**Optional:** + +- `[PASSWORD]` - Password to decrypt the sender key. Can be provided as an + argument or via the `MINA_PRIVKEY_PASS` environment variable (recommended for + security) +- `--memo ` - Transaction memo (max 32 bytes, default: empty) +- `--nonce ` - Transaction nonce (default: fetched from node) +- `--valid-until ` - Slot until which transaction is valid (default: never + expires) +- `--fee-payer ` - Optional fee payer public key (default: sender + pays) +- `--network ` - Network for signing: `mainnet` or `devnet` (default: + `devnet`) +- `--node ` - Node GraphQL endpoint (default: `http://localhost:3000`) + +### Environment variables + +You can set the following environment variables: + +```bash +export MINA_PRIVKEY_PASS="your-password" +export MINA_NETWORK="mainnet" +``` + +### Examples + +#### Send payment on devnet + +```bash +mina wallet send \ + --from ./keys/my-wallet \ + --to B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy \ + --amount 1000000000 \ + --fee 10000000 \ + --network devnet +``` + +#### Send payment on mainnet with memo + +```bash +mina wallet send \ + --from ./keys/my-wallet \ + --to B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy \ + --amount 5000000000 \ + --fee 10000000 \ + --memo "Payment for services" \ + --network mainnet +``` + +#### Send payment with separate fee payer + +```bash +mina wallet send \ + --from ./keys/sender-wallet \ + --to B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy \ + --amount 1000000000 \ + --fee 10000000 \ + --fee-payer B62qkfHpLpELqpMK6ZvUTJ5wRqKDRF3UHyJ4Kv3FU79Sgs4qpBnx5RG +``` + +#### Send payment to remote node + +```bash +mina wallet send \ + --from ./keys/my-wallet \ + --to B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy \ + --amount 1000000000 \ + --fee 10000000 \ + --node https://node.example.com:3000 +``` + +#### Use environment variable for password + +```bash +export MINA_PRIVKEY_PASS="my-secret-password" + +mina wallet send \ + --from ./keys/my-wallet \ + --to B62qre3erTHfzQckNuibViWQGyyKwZseztqrjPZBv6SQF384Rg6ESAy \ + --amount 1000000000 \ + --fee 10000000 +``` + +## Understanding amounts + +All amounts in the CLI are specified in **nanomina**: + +- 1 MINA = 1,000,000,000 nanomina +- 0.1 MINA = 100,000,000 nanomina +- 0.01 MINA = 10,000,000 nanomina (common minimum fee) + +## How it works + +When you send a payment, the CLI: + +1. **Decrypts your key** - Uses the provided password to decrypt your private + key +2. **Fetches nonce** - Queries the node via GraphQL to get your current account + nonce (if not specified) +3. **Creates payload** - Builds the payment transaction payload with all details +4. **Signs transaction** - Signs the transaction using your private key and the + correct network ID +5. **Submits to node** - Sends the signed transaction to the node via GraphQL + `sendPayment` mutation +6. **Returns hash** - Displays the transaction hash, which can be used with + `mina wallet status` to track the transaction + +## Network selection + +The `--network` flag controls which network the transaction is signed for: + +- `devnet` - For development and testing (default) +- `mainnet` - For production transactions + +**Important:** Make sure to use the correct network flag. A transaction signed +for devnet will not be valid on mainnet and vice versa. + +## Fee payer + +By default, the sender pays the transaction fee. However, you can specify a +different fee payer using the `--fee-payer` option: + +- The sender's key is used to sign the payment +- The fee payer's public key is included in the transaction +- The fee payer must also sign the transaction (currently requires manual + coordination) + +This is useful for sponsored transactions where another party pays the fees. + +## Check transaction status + +Check the status of a submitted transaction using its hash. + +### Basic usage + +```bash +mina wallet status --hash +``` + +### Arguments + +**Required:** + +- `--hash ` - Transaction hash to check + +**Optional:** + +- `--node ` - Node GraphQL endpoint (default: `http://localhost:3000`) +- `--check-mempool` - Force checking the mempool even if transaction is found in + blockchain + +### Examples + +#### Check transaction on local node + +```bash +mina wallet status \ + --hash 5Ju4H4DTE1zkwrnLrQ8vb2sZR19b7eSMiAVbb4wQh4bfhh4aQNew +``` + +#### Check transaction on remote node + +```bash +mina wallet status \ + --hash 5Ju4H4DTE1zkwrnLrQ8vb2sZR19b7eSMiAVbb4wQh4bfhh4aQNew \ + --node https://devnet-plain-1.gcp.o1test.net +``` + +### Output + +The status command will: + +1. First attempt to query the blockchain for the transaction status +2. If not found in the blockchain, automatically check the mempool (pending + transactions) +3. Display transaction details if found in the mempool + +#### Transaction found in mempool + +``` +Checking transaction status... +Transaction hash: 5Ju4H4DTE1zkwrnLrQ8vb2sZR19b7eSMiAVbb4wQh4bfhh4aQNew + +Transaction not found in blockchain, checking mempool... + +✓ Transaction found in mempool! + +Transaction Details: + Hash: 5Ju4H4DTE1zkwrnLrQ8vb2sZR19b7eSMiAVbb4wQh4bfhh4aQNew + From: B62qjtpVAMr7knjLxRLU887QgT7GPk3JYCg8NGdZsfMuaykAJ9C2Rem + To: B62qjtpVAMr7knjLxRLU887QgT7GPk3JYCg8NGdZsfMuaykAJ9C2Rem + Amount: 1000000000 nanomina + Fee: 1000000 nanomina + Nonce: 0 + +Status: PENDING (waiting to be included in a block) +``` + +#### Transaction not found + +``` +Checking transaction status... +Transaction hash: 5Ju6ku4DY5McpfqPvduQyQASjv1iAF12Xn75W3f3kGL1wsgSRKBA + +Transaction not found in blockchain, checking mempool... + +✗ Transaction not found in mempool + +The transaction may have: + - Already been included in a block + - Been rejected by the network + - Not yet propagated to this node +``` + +### How it works + +The status command automatically: + +1. **Queries blockchain** - Attempts to query `transactionStatus` via GraphQL +2. **Falls back to mempool** - If not found or if the query fails, checks + `pooledUserCommands` for pending transactions +3. **Displays results** - Shows transaction details if found, or helpful + messages if not found + +This is particularly useful immediately after sending a transaction to verify it +has been accepted into the mempool. + +## GraphQL integration + +The wallet commands use the node's GraphQL API: + +- **Account query** - Fetches current nonce and account information (`balance` + command) +- **sendPayment mutation** - Submits signed transactions to the network (`send` + command) +- **transactionStatus query** - Checks if a transaction is included in the + blockchain (`status` command) +- **pooledUserCommands query** - Lists pending transactions in the mempool + (`status` command) + +For more details on the GraphQL API, see the [GraphQL API](./graphql-api.md) +documentation. diff --git a/website/sidebars.ts b/website/sidebars.ts index db04a8c567..38d56200f7 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -90,6 +90,7 @@ const sidebars: SidebarsConfig = { label: 'APIs and Data', items: [ 'developers/graphql-api', + 'developers/wallet', 'developers/archive-database-queries', ], },