diff --git a/src/Neo.CLI/CLI/MainService.Tools.cs b/src/Neo.CLI/CLI/MainService.Tools.cs
index a8c998b25..675b592e5 100644
--- a/src/Neo.CLI/CLI/MainService.Tools.cs
+++ b/src/Neo.CLI/CLI/MainService.Tools.cs
@@ -409,17 +409,10 @@ private static string Base64Fixed(string str)
///
/// Base64 Smart Contract Script Analysis
/// input: DARkYXRhAgBlzR0MFPdcrAXPVptVduMEs2lf1jQjxKIKDBT3XKwFz1abVXbjBLNpX9Y0I8SiChTAHwwIdHJhbnNmZXIMFKNSbimM12LkFYX/8KGvm2ttFxulQWJ9W1I=
- /// output:
- /// PUSHDATA1 data
- /// PUSHINT32 500000000
- /// PUSHDATA1 0x0aa2c42334d65f69b304e376559b56cf05ac5cf7
- /// PUSHDATA1 0x0aa2c42334d65f69b304e376559b56cf05ac5cf7
- /// PUSH4
- /// PACK
- /// PUSH15
- /// PUSHDATA1 transfer
- /// PUSHDATA1 0xa51b176d6b9bafa1f0ff8515e462d78c296e52a3
- /// SYSCALL System.Contract.Call
+ /// output (VMInstruction.ToString, with an L-prefix padded to at least 4 digits):
+ /// L0000:0000 PUSHDATA1 64617461 // data
+ /// L0001:0006 PUSHINT32 500000000
+ /// L0002:000B PUSHDATA1 0AA2C42334D65F69B304E376559B56CF05AC5CF7
///
[ParseFunction("Base64 Smart Contract Script Analysis")]
private string? ScriptsToOpCode(string base64)
@@ -427,19 +420,7 @@ private static string Base64Fixed(string str)
try
{
var bytes = Convert.FromBase64String(base64);
- var sb = new StringBuilder();
- var line = 0;
-
- foreach (var instruct in new VMInstruction(bytes))
- {
- if (instruct.OperandSize == 0)
- sb.AppendFormat("L{0:D04}:{1:X04} {2}{3}", line, instruct.Position, instruct.OpCode, Environment.NewLine);
- else
- sb.AppendFormat("L{0:D04}:{1:X04} {2,-10}{3}{4}", line, instruct.Position, instruct.OpCode, instruct.DecodeOperand(), Environment.NewLine);
- line++;
- }
-
- return sb.ToString();
+ return VMInstruction.FormatListing(bytes);
}
catch
{
diff --git a/src/Neo.CLI/Neo.CLI.csproj b/src/Neo.CLI/Neo.CLI.csproj
index 387151cf8..66f872596 100644
--- a/src/Neo.CLI/Neo.CLI.csproj
+++ b/src/Neo.CLI/Neo.CLI.csproj
@@ -20,6 +20,10 @@
+
+
+
+
diff --git a/src/Neo.CLI/Tools/VMInstruction.cs b/src/Neo.CLI/Tools/VMInstruction.cs
index 086864156..a39b9f6b7 100644
--- a/src/Neo.CLI/Tools/VMInstruction.cs
+++ b/src/Neo.CLI/Tools/VMInstruction.cs
@@ -9,15 +9,18 @@
// Redistribution and use in source and binary forms with or without
// modifications are permitted.
+using Neo.Cryptography.ECC;
using Neo.SmartContract;
using Neo.VM;
using System.Buffers.Binary;
using System.Collections;
using System.Diagnostics;
+using System.Globalization;
using System.Numerics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
+using System.Text.Unicode;
namespace Neo.CLI;
@@ -97,11 +100,43 @@ IEnumerator IEnumerable.GetEnumerator() =>
public override string ToString()
{
+ var posWidth = HexWidth(_script.Length);
+ if (OperandSize == 0)
+ return string.Format($"{{0:X{posWidth}}} {{1}}", Position, OpCode);
+ return string.Format($"{{0:X{posWidth}}} {{1,-10}}{{2}}", Position, OpCode, DecodeOperand());
+ }
+
+ ///
+ /// Lists instructions as L0000:0000 NOP. The L-prefix width is at least 4 digits
+ /// and grows with the script size / instruction count.
+ ///
+ internal static string FormatListing(ReadOnlyMemory script)
+ {
+ var rows = new List();
+ foreach (var instruct in new VMInstruction(script))
+ rows.Add(instruct);
+
+ var lastIndex = Math.Max(0, rows.Count - 1);
+ var width = DecimalWidth(Math.Max(lastIndex, script.Length));
var sb = new StringBuilder();
- sb.AppendFormat("{0:X04} {1,-10}{2}", Position, OpCode, DecodeOperand());
+ for (var i = 0; i < rows.Count; i++)
+ {
+ sb.Append('L');
+ sb.Append(i.ToString($"D{width}", CultureInfo.InvariantCulture));
+ sb.Append(':');
+ sb.Append(rows[i]);
+ sb.AppendLine();
+ }
+
return sb.ToString();
}
+ internal static int DecimalWidth(int value)
+ => Math.Max(4, Math.Max(0, value).ToString(CultureInfo.InvariantCulture).Length);
+
+ internal static int HexWidth(int length)
+ => Math.Max(4, Math.Max(0, length == 0 ? 0 : length - 1).ToString("X").Length);
+
public T AsToken(uint index = 0)
where T : unmanaged
{
@@ -119,8 +154,6 @@ public T AsToken(uint index = 0)
public string DecodeOperand()
{
var operand = Operand[OperandPrefixSize..].ToArray();
- var asStr = Encoding.UTF8.GetString(operand);
- var readable = asStr.All(char.IsAsciiLetterOrDigit);
return OpCode switch
{
@@ -157,17 +190,109 @@ OpCode.STSFLD or
OpCode.LDARG or
OpCode.STARG or
OpCode.INITSSLOT => $"{AsToken()}",
- OpCode.PUSHINT8 => $"{AsToken()}",
- OpCode.PUSHINT16 => $"{AsToken()}",
- OpCode.PUSHINT32 => $"{AsToken()}",
- OpCode.PUSHINT64 => $"{AsToken()}",
+ OpCode.PUSHINT8 => FormatInteger(AsToken()),
+ OpCode.PUSHINT16 => FormatInteger(AsToken()),
+ OpCode.PUSHINT32 => FormatInteger(AsToken()),
+ OpCode.PUSHINT64 => FormatInteger(AsToken()),
OpCode.PUSHINT128 or
OpCode.PUSHINT256 => $"{new BigInteger(operand)}",
OpCode.SYSCALL => $"[{ApplicationEngine.Services[Unsafe.As(ref operand[0])].Name}]",
OpCode.PUSHDATA1 or
OpCode.PUSHDATA2 or
- OpCode.PUSHDATA4 => readable ? $"{Convert.ToHexString(operand)} // {asStr}" : Convert.ToHexString(operand),
- _ => readable ? $"\"{asStr}\"" : $"{Convert.ToHexString(operand)}",
+ OpCode.PUSHDATA4 => FormatPushData(operand),
+ _ => TryGetReadableText(operand, out var text)
+ ? $"\"{text}\""
+ : $"{Convert.ToHexString(operand)} // blob {operand.Length} bytes",
+ };
+ }
+
+ private static string FormatPushData(byte[] operand)
+ {
+ var hex = Convert.ToHexString(operand);
+ if (TryGetReadableText(operand, out var text))
+ return $"{hex} // {text}";
+
+ if (TryDecodeEcPoint(operand, out var point))
+ return $"{hex} // {point}";
+
+ if (operand.Length == UInt160.Length)
+ return $"{hex} // {new UInt160(operand)}";
+
+ if (operand.Length == UInt256.Length)
+ return $"{hex} // {new UInt256(operand)}";
+
+ return $"{hex} // blob {operand.Length} bytes";
+ }
+
+ private static string FormatInteger(long value)
+ => value.ToString(CultureInfo.InvariantCulture);
+
+ ///
+ /// Strict UTF-8 with at least one non-control rune. Control runes are escaped
+ /// (\n, \r, \t, \xNN) so they are not written as raw output.
+ /// Invalid sequences are rejected; replacement is not used.
+ ///
+ private static bool TryGetReadableText(ReadOnlySpan utf8, out string text)
+ {
+ text = null!;
+ if (utf8.IsEmpty || !Utf8.IsValid(utf8))
+ return false;
+
+ var decoded = Encoding.UTF8.GetString(utf8);
+ var hasGraphic = false;
+ var sb = new StringBuilder(decoded.Length);
+ foreach (var rune in decoded.EnumerateRunes())
+ {
+ if (Rune.IsControl(rune) || rune.Value == 0x7F)
+ sb.Append(EscapeControlRune(rune));
+ else
+ {
+ hasGraphic = true;
+ sb.Append(rune);
+ }
+ }
+
+ if (!hasGraphic)
+ return false;
+
+ text = sb.ToString();
+ return true;
+ }
+
+ private static string EscapeControlRune(Rune rune)
+ => rune.Value switch
+ {
+ '\n' => "\\n",
+ '\r' => "\\r",
+ '\t' => "\\t",
+ '\0' => "\\0",
+ _ => rune.Value <= 0xFF ? $"\\x{rune.Value:X2}" : $"\\u{rune.Value:X4}",
};
+
+ private static bool TryDecodeEcPoint(byte[] operand, out ECPoint point)
+ {
+ point = null!;
+ if (operand.Length is not (33 or 65))
+ return false;
+ if (operand[0] is not (0x02 or 0x03 or 0x04))
+ return false;
+ try
+ {
+ point = ECPoint.DecodePoint(operand, ECCurve.Secp256r1);
+ return true;
+ }
+ catch (FormatException)
+ {
+ try
+ {
+ point = ECPoint.DecodePoint(operand, ECCurve.Secp256k1);
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
}
+
}
diff --git a/tests/Neo.CLI.Tests/UT_VMInstruction.cs b/tests/Neo.CLI.Tests/UT_VMInstruction.cs
new file mode 100644
index 000000000..3373f3637
--- /dev/null
+++ b/tests/Neo.CLI.Tests/UT_VMInstruction.cs
@@ -0,0 +1,163 @@
+// Copyright (C) 2015-2026 The Neo Project.
+//
+// UT_VMInstruction.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 Microsoft.VisualStudio.TestTools.UnitTesting;
+using Neo.CLI;
+using Neo.Cryptography.ECC;
+using Neo.Extensions;
+using Neo.VM;
+
+namespace Neo.CLI.Tests;
+
+[TestClass]
+public class UT_VMInstruction
+{
+ [TestMethod]
+ public void ToString_OmitsOperandWhenNone()
+ {
+ var instruction = new VMInstruction(new byte[] { (byte)OpCode.NOP });
+ Assert.AreEqual("0000 NOP", instruction.ToString());
+ }
+
+ [TestMethod]
+ public void ToString_IncludesDecodedOperand()
+ {
+ var instruction = new VMInstruction(new byte[] { (byte)OpCode.PUSHINT8, 7 });
+ Assert.AreEqual("0000 PUSHINT8 7", instruction.ToString());
+ }
+
+ [TestMethod]
+ public void Enumerator_WalksFullScript()
+ {
+ var script = new byte[] { (byte)OpCode.NOP, (byte)OpCode.RET };
+ var lines = new VMInstruction(script).Select(i => i.ToString()).ToArray();
+ Assert.HasCount(2, lines);
+ Assert.AreEqual("0000 NOP", lines[0]);
+ Assert.AreEqual("0001 RET", lines[1]);
+ }
+
+ [TestMethod]
+ public void DecimalWidth_GrowsPastFourDigits()
+ {
+ Assert.AreEqual(4, VMInstruction.DecimalWidth(0));
+ Assert.AreEqual(4, VMInstruction.DecimalWidth(9999));
+ Assert.AreEqual(5, VMInstruction.DecimalWidth(10000));
+ Assert.AreEqual(6, VMInstruction.DecimalWidth(100000));
+ }
+
+ [TestMethod]
+ public void HexWidth_GrowsWithScriptLength()
+ {
+ Assert.AreEqual(4, VMInstruction.HexWidth(1));
+ Assert.AreEqual(4, VMInstruction.HexWidth(0x10000));
+ Assert.AreEqual(5, VMInstruction.HexWidth(0x10001));
+ }
+
+ [TestMethod]
+ public void FormatListing_PadsLineNumbersToAtLeastFourDigits()
+ {
+ var listing = VMInstruction.FormatListing(new byte[] { (byte)OpCode.NOP, (byte)OpCode.RET });
+ Assert.StartsWith("L0000:", listing);
+ Assert.Contains("L0001:", listing);
+ }
+
+ [TestMethod]
+ public void DecodeOperand_CommentsReadableTextIncludingColon()
+ {
+ var text = "TWELVEDATA:CNY-USD"u8.ToArray();
+ var instruction = new VMInstruction(PushData1(text));
+ Assert.Contains(" // TWELVEDATA:CNY-USD", instruction.DecodeOperand());
+ }
+
+ [TestMethod]
+ public void DecodeOperand_CommentsStrictUtf8PrintableRunes()
+ {
+ var text = "价格 café — ✓"u8.ToArray();
+ var instruction = new VMInstruction(PushData1(text));
+ Assert.Contains(" // 价格 café — ✓", instruction.DecodeOperand());
+ }
+
+ [TestMethod]
+ public void DecodeOperand_RejectsInvalidUtf8AsText()
+ {
+ var invalid = new byte[] { 0xC0, 0x80, 0xFF };
+ var instruction = new VMInstruction(PushData1(invalid));
+ Assert.AreEqual($"{Convert.ToHexString(invalid)} // blob {invalid.Length} bytes", instruction.DecodeOperand());
+ }
+
+ [TestMethod]
+ public void DecodeOperand_EscapesControlRunesInText()
+ {
+ var withNl = "ab\ncd"u8.ToArray();
+ var instruction = new VMInstruction(PushData1(withNl));
+ Assert.Contains(" // ab\\ncd", instruction.DecodeOperand());
+ Assert.DoesNotContain("ab\ncd", instruction.DecodeOperand());
+ }
+
+ [TestMethod]
+ public void DecodeOperand_FormatsUInt160()
+ {
+ var hash = new UInt160(Convert.FromHexString("ABCC7F51C334D4F958BE8B6C54142AC4493F0103"));
+ var instruction = new VMInstruction(PushData1(hash.ToArray()));
+ Assert.AreEqual($"{Convert.ToHexString(hash.ToArray())} // {hash}", instruction.DecodeOperand());
+ }
+
+ [TestMethod]
+ public void DecodeOperand_FormatsUInt256()
+ {
+ var hash = new UInt256(new byte[32]);
+ var instruction = new VMInstruction(PushData1(hash.ToArray()));
+ Assert.AreEqual($"{Convert.ToHexString(hash.ToArray())} // {hash}", instruction.DecodeOperand());
+ }
+
+ [TestMethod]
+ public void DecodeOperand_FormatsEcPoint()
+ {
+ var point = ECPoint.Parse("03b209fd4f53a7170ea4444e0cb0a6bb6a53c2bd016926989cf85f9b0fba17a70c", ECCurve.Secp256r1);
+ var encoded = point.EncodePoint(true);
+ var instruction = new VMInstruction(PushData1(encoded));
+ Assert.AreEqual($"{Convert.ToHexString(encoded)} // {point}", instruction.DecodeOperand());
+ }
+
+ [TestMethod]
+ public void DecodeOperand_DoesNotTreatIntegersAsUnixTimestamps()
+ {
+ // 2491470000 fits 2000–2100 as unix seconds (2048-12-13) but is a
+ // contract integer, not a timestamp. Same for 1787908101.
+ var pushInt64 = new byte[9];
+ pushInt64[0] = (byte)OpCode.PUSHINT64;
+ BitConverter.GetBytes(2_491_470_000L).CopyTo(pushInt64, 1);
+ Assert.AreEqual("2491470000", new VMInstruction(pushInt64).DecodeOperand());
+
+ var pushInt32 = new byte[5];
+ pushInt32[0] = (byte)OpCode.PUSHINT32;
+ BitConverter.GetBytes(1_787_908_101).CopyTo(pushInt32, 1);
+ Assert.AreEqual("1787908101", new VMInstruction(pushInt32).DecodeOperand());
+ }
+
+ [TestMethod]
+ public void DecodeOperand_LeavesNonTypedPushDataAsHex()
+ {
+ var blob = Convert.FromHexString("71BDDFD76DBDEF67BCF1C71AE77E787B973C69F79C79FF1F");
+ Assert.AreEqual(24, blob.Length);
+ var instruction = new VMInstruction(PushData1(blob));
+ Assert.AreEqual($"{Convert.ToHexString(blob)} // blob 24 bytes", instruction.DecodeOperand());
+ }
+
+ private static byte[] PushData1(byte[] data)
+ {
+ var script = new byte[2 + data.Length];
+ script[0] = (byte)OpCode.PUSHDATA1;
+ script[1] = (byte)data.Length;
+ Buffer.BlockCopy(data, 0, script, 2, data.Length);
+ return script;
+ }
+}