diff --git a/src/Neo.CLI/CLI/MainService.Wallet.cs b/src/Neo.CLI/CLI/MainService.Wallet.cs
index 7c0c197b3..71df6c3ef 100644
--- a/src/Neo.CLI/CLI/MainService.Wallet.cs
+++ b/src/Neo.CLI/CLI/MainService.Wallet.cs
@@ -11,8 +11,10 @@
using Akka.Actor;
using Neo.ConsoleService;
+using Neo.Cryptography;
using Neo.Extensions;
using Neo.Json;
+using Neo.Network.P2P;
using Neo.Network.P2P.Payloads;
using Neo.Persistence;
using Neo.Sign;
@@ -23,7 +25,9 @@
using Neo.Wallets.NEP6;
using System.Numerics;
using System.Security.Cryptography;
+using System.Text;
using static Neo.SmartContract.Helper;
+using ECCurve = Neo.Cryptography.ECC.ECCurve;
using ECPoint = Neo.Cryptography.ECC.ECPoint;
namespace Neo.CLI;
@@ -470,8 +474,8 @@ private void OnListKeyCommand()
///
/// Process "sign" command
///
- /// Json object to sign
- [ConsoleCommand("sign", Category = "Wallet Commands")]
+ /// The json string that records the transaction information
+ [ConsoleCommand("sign transaction", Category = "Wallet Commands")]
private void OnSignCommand(JObject jsonObjectToSign)
{
if (NoWallet()) return;
@@ -503,6 +507,77 @@ private void OnSignCommand(JObject jsonObjectToSign)
}
}
+ ///
+ /// Process "sign message" command
+ ///
+ /// Message to sign
+ [ConsoleCommand("sign message", Category = "Wallet Commands")]
+ private void OnSignMessageCommand(string message)
+ {
+ if (NoWallet()) return;
+
+ string password = ReadUserInput("password", true);
+ if (password.Length == 0)
+ {
+ ConsoleHelper.Info("Cancelled");
+ return;
+ }
+ if (!CurrentWallet!.VerifyPassword(password))
+ {
+ ConsoleHelper.Error("Incorrect password");
+ return;
+ }
+
+ var saltBytes = new byte[16];
+ RandomNumberGenerator.Fill(saltBytes);
+ var saltHex = saltBytes.ToHexString().ToLowerInvariant();
+
+ var paramBytes = Encoding.UTF8.GetBytes(saltHex + message);
+
+ byte[] payload;
+ using (var ms = new MemoryStream())
+ using (var w = new BinaryWriter(ms, Encoding.UTF8, true))
+ {
+ // We add these 4 bytes to prevent the signature from being a valid transaction
+ w.Write((byte)0x01);
+ w.Write((byte)0x00);
+ w.Write((byte)0x01);
+ w.Write((byte)0xF0);
+ // Write the actual message to sign
+ w.WriteVarBytes(paramBytes);
+ // We add these 2 bytes to prevent the signature from being a valid transaction
+ w.Write((ushort)0);
+ w.Flush();
+ payload = ms.ToArray();
+ }
+
+ ConsoleHelper.Info("Signed Payload: ", $"{Environment.NewLine}{payload.ToHexString()}");
+ Console.WriteLine();
+ ConsoleHelper.Info(" Curve: ", "secp256r1");
+ ConsoleHelper.Info("Algorithm: ", "payload = 010001f0 + VarBytes(Salt + Message) + 0000");
+ ConsoleHelper.Info("Algorithm: ", "Sign(SHA256(network || Hash256(payload)))");
+ ConsoleHelper.Info(" ", "See the online documentation for details on how to verify this signature.");
+ ConsoleHelper.Info(" ", "https://developers.neo.org/docs/n3/node/cli/cli#sign_message");
+ Console.WriteLine();
+ ConsoleHelper.Info("Generated signatures:");
+ Console.WriteLine();
+
+ var hash = new UInt256(Crypto.Hash256(payload));
+ var signData = hash.GetSignData(NeoSystem.Settings.Network);
+
+ foreach (WalletAccount account in CurrentWallet.GetAccounts().Where(p => p.HasKey))
+ {
+ var key = account.GetKey();
+ var signature = Crypto.Sign(signData, key!.PrivateKey, ECCurve.Secp256r1);
+
+ ConsoleHelper.Info(" Address: ", account.Address);
+ ConsoleHelper.Info(" PublicKey: ", key.PublicKey.EncodePoint(true).ToHexString());
+ ConsoleHelper.Info(" Signature: ", signature.ToHexString());
+ ConsoleHelper.Info(" Salt: ", saltHex);
+ Console.WriteLine();
+ }
+ }
+
///
/// Process "send" command
///
@@ -761,4 +836,5 @@ private void SignAndSendTx(DataCache snapshot, Transaction tx)
ConsoleHelper.Info("Incomplete signature:\n", $"{context}");
}
}
+ internal Func ReadUserInput { get; set; } = ConsoleHelper.ReadUserInput;
}
diff --git a/src/Neo.ConsoleService/ConsoleServiceBase.cs b/src/Neo.ConsoleService/ConsoleServiceBase.cs
index a329d1cc0..eb0b46e20 100644
--- a/src/Neo.ConsoleService/ConsoleServiceBase.cs
+++ b/src/Neo.ConsoleService/ConsoleServiceBase.cs
@@ -125,6 +125,7 @@ internal bool OnCommand(string commandLine)
var possibleHelp = "";
var tokens = commandLine.Tokenize();
var availableCommands = new List<(ConsoleCommandMethod Command, object?[] Arguments)>();
+
foreach (var entries in _verbs.Values)
{
foreach (var command in entries)
@@ -133,6 +134,7 @@ internal bool OnCommand(string commandLine)
if (consumed <= 0) continue;
var args = tokens.Skip(consumed).ToList().Trim();
+
try
{
if (args.Any(u => u.IsIndicator))
@@ -170,7 +172,8 @@ internal bool OnCommand(string commandLine)
// Show Ambiguous call
var ambiguousCommands = availableCommands.Select(u => u.Command.Key).Distinct().ToList();
- throw new ArgumentException($"Ambiguous calls for: {string.Join(',', ambiguousCommands)}");
+ var ambiguousCommandsQuoted = ambiguousCommands.Select(u => $"'{u}'").ToList();
+ throw new ArgumentException($"Ambiguous calls for: {string.Join(',', ambiguousCommandsQuoted)}");
}
private bool TryProcessValue(Type parameterType, IList args, bool consumeAll, out object? value)
diff --git a/tests/Neo.CLI.Tests/TestUtils.cs b/tests/Neo.CLI.Tests/TestUtils.cs
new file mode 100644
index 000000000..1036a5627
--- /dev/null
+++ b/tests/Neo.CLI.Tests/TestUtils.cs
@@ -0,0 +1,32 @@
+// Copyright (C) 2015-2025 The Neo Project.
+//
+// TestUtils.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+using Neo.Json;
+using Neo.Wallets.NEP6;
+
+namespace Neo.CLI.Tests;
+
+public static partial class TestUtils
+{
+ public static NEP6Wallet GenerateTestWallet(string password)
+ {
+ var wallet = new JObject()
+ {
+ ["name"] = "noname",
+ ["version"] = new Version("1.0").ToString(),
+ ["scrypt"] = new ScryptParameters(2, 1, 1).ToJson(),
+ ["accounts"] = new JArray(),
+ ["extra"] = null
+ };
+ Assert.AreEqual("{\"name\":\"noname\",\"version\":\"1.0\",\"scrypt\":{\"n\":2,\"r\":1,\"p\":1},\"accounts\":[],\"extra\":null}", wallet.ToString());
+ return new NEP6Wallet(null, password, TestProtocolSettings.Default, wallet);
+ }
+}
diff --git a/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs
new file mode 100644
index 000000000..a81b6c55f
--- /dev/null
+++ b/tests/Neo.CLI.Tests/UT_MainService_Wallet.cs
@@ -0,0 +1,189 @@
+// Copyright (C) 2015-2025 The Neo Project.
+//
+// UT_MainService_Wallet.cs file belongs to the neo project and is free
+// software distributed under the MIT software license, see the
+// accompanying file LICENSE in the main directory of the
+// repository or http://www.opensource.org/licenses/mit-license.php
+// for more details.
+//
+// Redistribution and use in source and binary forms with or without
+// modifications are permitted.
+
+using System.Reflection;
+
+namespace Neo.CLI.Tests;
+
+[TestClass]
+public class UT_MainService_Wallet
+{
+ private NeoSystem _neoSystem;
+
+ [TestInitialize]
+ public void TestSetup()
+ {
+ _neoSystem = TestBlockchain.GetSystem();
+ }
+
+ [TestMethod]
+ public void TestOnSignMessageCommand()
+ {
+ var walletPassword = "test_pwd";
+ var output = CreateWalletAngSignMessage(walletPassword);
+
+ // Basic headers
+ Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output from sign message command should not be empty");
+ Assert.Contains("Signed Payload", output, "Output should containt the signed payload");
+ Assert.Contains("Algorithm", output, "Output should describe the algorithm used");
+ Assert.Contains("Generated signatures", output, "Output should contain signatures header");
+
+ // Sign block
+ Assert.Contains("Address", output, "Output should contain at least one address");
+ Assert.Contains("PublicKey", output, "Output should contain the public key");
+ Assert.Contains("Signature", output, "Output should contain the signature");
+ Assert.Contains("Salt", output, "Output should contain the salt used");
+
+ // Check Salt
+ var salt = ExtractHexValue(output, "Salt:");
+ Assert.IsNotNull(salt, "Salt hex should be present in the output");
+ Assert.AreEqual(32, salt!.Length, "Salt should be 16 bytes (32 hex chars)");
+ Assert.IsTrue(IsHexString(salt!), "Salt should be valid hex");
+ }
+ [TestMethod]
+ public void TestOnSignMessageCommandWithoutPassword()
+ {
+ var output = CreateWalletAngSignMessage(string.Empty);
+
+ Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output should not be empty");
+ Assert.Contains("Cancelled", output, "Output should contain cancellation message");
+ }
+ [TestMethod]
+ public void TestOnSignMessageCommandWrongPassword()
+ {
+ var walletPassword = "invalid_pwd";
+ var output = CreateWalletAngSignMessage(walletPassword);
+
+ Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output should not be empty");
+ Assert.Contains("Incorrect password", output, "Output should contain incorrect password");
+ Assert.DoesNotContain("Signed Payload", output, "Output should not containt signed payload");
+ Assert.DoesNotContain("Generated signatures", output, "Output should not containt signatures");
+ }
+ [TestMethod]
+ public void TestOnSignMessageCommandWithoutAccount()
+ {
+ var walletPassword = "test_pwd";
+ var output = CreateWalletAngSignMessage(walletPassword, false);
+
+ Assert.IsFalse(string.IsNullOrWhiteSpace(output), "Output should not be empty");
+ Assert.Contains("Signed Payload", output, "Output should containt signed payload");
+ Assert.Contains("Generated signatures", output, "Output should containt signatures");
+ Assert.DoesNotContain("Address:", output, "Output should not containt Address");
+ Assert.DoesNotContain("PublicKey:", output, "Output should not containt PublicKey");
+ Assert.DoesNotContain("Signature:", output, "Output should not containt Signature");
+ Assert.DoesNotContain("Salt:", output, "Output should not containt Salt");
+ }
+ private string CreateWalletAngSignMessage(string userPassword, bool withAccount = true)
+ {
+ var walletPassword = "test_pwd";
+ var message = "this is a test to sign";
+
+ var wallet = TestUtils.GenerateTestWallet(walletPassword);
+ if (withAccount)
+ {
+ var account = wallet.CreateAccount();
+ Assert.IsNotNull(account, "Wallet.CreateAccount() should create an account");
+ }
+
+ var service = new MainService();
+
+ TrySet(service, "NeoSystem", _neoSystem);
+ TrySetField(service, "_neoSystem", _neoSystem);
+ TrySet(service, "CurrentWallet", wallet);
+ TrySetField(service, "_currentWallet", wallet);
+
+ var readInputProp = service.GetType().GetProperty(
+ "ReadUserInput",
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+
+ Assert.IsNotNull(readInputProp, "ReadUserInput property not found on MainService");
+
+ Func fakeReadInput = (label, isPassword) =>
+ {
+ Assert.AreEqual("password", label);
+ Assert.IsTrue(isPassword);
+ return userPassword;
+ };
+
+ readInputProp!.SetValue(service, fakeReadInput);
+
+ var originalOut = Console.Out;
+ using var outputWriter = new StringWriter();
+ Console.SetOut(outputWriter);
+
+ try
+ {
+ InvokeNonPublic(service, "OnSignMessageCommand", message);
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ }
+
+ return outputWriter.ToString();
+ }
+ private static string ExtractHexValue(string output, string label)
+ {
+ var index = output.IndexOf(label, StringComparison.OrdinalIgnoreCase);
+ if (index < 0)
+ return null;
+
+ var start = index + label.Length;
+ var endOfLine = output.IndexOfAny(new[] { '\r', '\n' }, start);
+ if (endOfLine < 0)
+ endOfLine = output.Length;
+
+ var value = output[start..endOfLine].Trim();
+ return string.IsNullOrEmpty(value) ? null : value;
+ }
+ private static bool IsHexString(string value)
+ {
+ foreach (var c in value)
+ {
+ var isHex =
+ (c >= '0' && c <= '9') ||
+ (c >= 'a' && c <= 'f') ||
+ (c >= 'A' && c <= 'F');
+
+ if (!isHex) return false;
+ }
+
+ return true;
+ }
+ private static void TrySet(object target, string propertyName, object value)
+ {
+ var prop = target.GetType().GetProperty(
+ propertyName,
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
+ );
+
+ prop?.SetValue(target, value);
+ }
+ private static void TrySetField(object target, string fieldName, object value)
+ {
+ var field = target.GetType().GetField(
+ fieldName,
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
+ );
+
+ field?.SetValue(target, value);
+ }
+ private static void InvokeNonPublic(object target, string methodName, params object[] args)
+ {
+ var method = target.GetType().GetMethod(
+ methodName,
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic
+ );
+
+ Assert.IsNotNull(method, $"Method '{methodName}' not found on type '{target.GetType().FullName}'.");
+ method.Invoke(target, args);
+ }
+}