From 12da30fc83ee7775baf4f2d23f2bec4d970099a7 Mon Sep 17 00:00:00 2001 From: jimmy Date: Mon, 22 Sep 2025 14:00:57 +0800 Subject: [PATCH] Enforce NEP-25 extended type validation NEP-25 adds ExtendedType descriptors to contract manifests, but the branch currently accepts manifests that reference undefined named types, mismatch parameter metadata, or break the base NEP-25 rules. This commit adds ExtendedType validation to ContractAbi, ContractMethodDescriptor, and ContractParameterDefinition so contracts that violate NEP-25 are rejected before deployment.\n\nThe change keeps the detailed FormatException messages recently introduced in dev, ensuring operators get actionable errors while we enforce the extended-type invariants. --- src/Neo/SmartContract/Manifest/ContractAbi.cs | 62 ++++++- .../Manifest/ContractEventDescriptor.cs | 6 +- .../Manifest/ContractMethodDescriptor.cs | 9 +- .../Manifest/ContractParameterDefinition.cs | 6 +- .../SmartContract/Manifest/ExtendedType.cs | 165 +++++++++++++++++- .../Manifest/UT_ContractManifest.cs | 79 +++++++++ 6 files changed, 317 insertions(+), 10 deletions(-) diff --git a/src/Neo/SmartContract/Manifest/ContractAbi.cs b/src/Neo/SmartContract/Manifest/ContractAbi.cs index db2ce0a9d3..aed5934edb 100644 --- a/src/Neo/SmartContract/Manifest/ContractAbi.cs +++ b/src/Neo/SmartContract/Manifest/ContractAbi.cs @@ -54,6 +54,8 @@ void IInteroperable.FromStackItem(StackItem stackItem) NamedTypes = ((Map)data[2]).ToDictionary(p => p.Key.GetString()!, p => p.Value.ToInteroperable()); else NamedTypes = null; + + ValidateExtendedTypes(); } public StackItem ToStackItem(IReferenceCounter referenceCounter) @@ -75,16 +77,70 @@ public StackItem ToStackItem(IReferenceCounter referenceCounter) /// The converted ABI. public static ContractAbi FromJson(JObject json) { + Dictionary? namedTypes = null; + var knownNamedTypes = new HashSet(StringComparer.Ordinal); + if (json!["namedtypes"] is JObject namedTypesJson) + { + foreach (var key in namedTypesJson.Properties.Keys) + { + knownNamedTypes.Add(key); + } + + namedTypes = new Dictionary(namedTypesJson.Properties.Count, StringComparer.Ordinal); + foreach (var (name, token) in namedTypesJson.Properties) + { + if (token is not JObject valueObject) + throw new FormatException("Named type definition must be a JSON object."); + namedTypes[name] = ExtendedType.FromJson(valueObject); + } + } + ContractAbi abi = new() { - Methods = ((JArray)json!["methods"]!)?.Select(u => ContractMethodDescriptor.FromJson((JObject)u!)).ToArray() ?? [], - Events = ((JArray)json!["events"]!)?.Select(u => ContractEventDescriptor.FromJson((JObject)u!)).ToArray() ?? [], - NamedTypes = ((JObject)json!["namedtypes"]!)?.Properties.ToDictionary(u => u.Key, u => ExtendedType.FromJson((JObject)u.Value!)) + Methods = ((JArray)json!["methods"]!)?.Select(u => ContractMethodDescriptor.FromJson((JObject)u!, knownNamedTypes)).ToArray() ?? [], + Events = ((JArray)json!["events"]!)?.Select(u => ContractEventDescriptor.FromJson((JObject)u!, knownNamedTypes)).ToArray() ?? [], + NamedTypes = namedTypes }; if (abi.Methods.Length == 0) throw new FormatException("Methods in ContractAbi is empty"); + + abi.ValidateExtendedTypes(); return abi; } + internal void ValidateExtendedTypes() + { + ISet knownNamedTypes = NamedTypes != null + ? new HashSet(NamedTypes.Keys, StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + + if (NamedTypes != null) + { + foreach (var (name, type) in NamedTypes) + { + ExtendedType.EnsureValidNamedTypeIdentifier(name); + type.ValidateForNamedTypeDefinition(knownNamedTypes); + } + } + + foreach (var method in Methods) + { + foreach (var parameter in method.Parameters) + { + parameter.ExtendedType?.ValidateForParameterOrReturn(parameter.Type, knownNamedTypes); + } + + method.ExtendedReturnType?.ValidateForParameterOrReturn(method.ReturnType, knownNamedTypes); + } + + foreach (var ev in Events) + { + foreach (var parameter in ev.Parameters) + { + parameter.ExtendedType?.ValidateForParameterOrReturn(parameter.Type, knownNamedTypes); + } + } + } + /// /// Gets the method with the specified name. /// diff --git a/src/Neo/SmartContract/Manifest/ContractEventDescriptor.cs b/src/Neo/SmartContract/Manifest/ContractEventDescriptor.cs index 3f1c47e00d..b679df0b38 100644 --- a/src/Neo/SmartContract/Manifest/ContractEventDescriptor.cs +++ b/src/Neo/SmartContract/Manifest/ContractEventDescriptor.cs @@ -13,6 +13,7 @@ using Neo.VM; using Neo.VM.Types; using System; +using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using Array = Neo.VM.Types.Array; @@ -54,13 +55,14 @@ public virtual StackItem ToStackItem(IReferenceCounter referenceCounter) /// Converts the event from a JSON object. /// /// The event represented by a JSON object. + /// Set of named type identifiers declared in the manifest, if any. /// The converted event. - public static ContractEventDescriptor FromJson(JObject json) + public static ContractEventDescriptor FromJson(JObject json, ISet knownNamedTypes = null) { ContractEventDescriptor descriptor = new() { Name = json["name"].GetString(), - Parameters = ((JArray)json["parameters"]).Select(u => ContractParameterDefinition.FromJson((JObject)u)).ToArray(), + Parameters = ((JArray)json["parameters"]).Select(u => ContractParameterDefinition.FromJson((JObject)u, knownNamedTypes)).ToArray(), }; if (string.IsNullOrEmpty(descriptor.Name)) throw new FormatException("Name in ContractEventDescriptor is empty"); _ = descriptor.Parameters.ToDictionary(p => p.Name); diff --git a/src/Neo/SmartContract/Manifest/ContractMethodDescriptor.cs b/src/Neo/SmartContract/Manifest/ContractMethodDescriptor.cs index 1241515dc5..f01a146243 100644 --- a/src/Neo/SmartContract/Manifest/ContractMethodDescriptor.cs +++ b/src/Neo/SmartContract/Manifest/ContractMethodDescriptor.cs @@ -13,6 +13,7 @@ using Neo.VM; using Neo.VM.Types; using System; +using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; @@ -56,6 +57,7 @@ public override void FromStackItem(StackItem stackItem) { ExtendedReturnType = new ExtendedType(); ExtendedReturnType.FromStackItem((VM.Types.Array)item[5]); + ExtendedReturnType.ValidateForParameterOrReturn(ReturnType, null); } else { @@ -80,13 +82,14 @@ public override StackItem ToStackItem(IReferenceCounter referenceCounter) /// Converts the method from a JSON object. /// /// The method represented by a JSON object. + /// Set of named type identifiers declared in the manifest, if any. /// The converted method. - public new static ContractMethodDescriptor FromJson(JObject json) + public new static ContractMethodDescriptor FromJson(JObject json, ISet knownNamedTypes = null) { ContractMethodDescriptor descriptor = new() { Name = json["name"].GetString(), - Parameters = ((JArray)json["parameters"]).Select(u => ContractParameterDefinition.FromJson((JObject)u)).ToArray(), + Parameters = ((JArray)json["parameters"]).Select(u => ContractParameterDefinition.FromJson((JObject)u, knownNamedTypes)).ToArray(), ReturnType = Enum.Parse(json["returntype"].GetString()), Offset = json["offset"].GetInt32(), Safe = json["safe"].GetBoolean(), @@ -97,11 +100,11 @@ public override StackItem ToStackItem(IReferenceCounter referenceCounter) throw new FormatException("Name in ContractMethodDescriptor is empty"); _ = descriptor.Parameters.ToDictionary(p => p.Name); - if (!Enum.IsDefined(typeof(ContractParameterType), descriptor.ReturnType)) throw new FormatException($"ReturnType({descriptor.ReturnType}) in ContractMethodDescriptor is not valid"); if (descriptor.Offset < 0) throw new FormatException($"Offset({descriptor.Offset}) in ContractMethodDescriptor is not valid"); + descriptor.ExtendedReturnType?.ValidateForParameterOrReturn(descriptor.ReturnType, knownNamedTypes); return descriptor; } diff --git a/src/Neo/SmartContract/Manifest/ContractParameterDefinition.cs b/src/Neo/SmartContract/Manifest/ContractParameterDefinition.cs index 6220abd808..556837a6d1 100644 --- a/src/Neo/SmartContract/Manifest/ContractParameterDefinition.cs +++ b/src/Neo/SmartContract/Manifest/ContractParameterDefinition.cs @@ -13,6 +13,7 @@ using Neo.VM; using Neo.VM.Types; using System; +using System.Collections.Generic; using System.Runtime.CompilerServices; namespace Neo.SmartContract.Manifest @@ -47,6 +48,7 @@ public void FromStackItem(StackItem stackItem) { ExtendedType = new ExtendedType(); ExtendedType.FromStackItem((VM.Types.Array)item[2]); + ExtendedType.ValidateForParameterOrReturn(Type, null); } else { @@ -70,8 +72,9 @@ public StackItem ToStackItem(IReferenceCounter referenceCounter) /// Converts the parameter from a JSON object. /// /// The parameter represented by a JSON object. + /// Set of named type identifiers declared in the manifest, if any. /// The converted parameter. - public static ContractParameterDefinition FromJson(JObject json) + public static ContractParameterDefinition FromJson(JObject json, ISet knownNamedTypes = null) { ContractParameterDefinition parameter = new() { @@ -83,6 +86,7 @@ public static ContractParameterDefinition FromJson(JObject json) throw new FormatException("Name in ContractParameterDefinition is empty"); if (!Enum.IsDefined(typeof(ContractParameterType), parameter.Type) || parameter.Type == ContractParameterType.Void) throw new FormatException($"Type({parameter.Type}) in ContractParameterDefinition is not valid"); + parameter.ExtendedType?.ValidateForParameterOrReturn(parameter.Type, knownNamedTypes); return parameter; } diff --git a/src/Neo/SmartContract/Manifest/ExtendedType.cs b/src/Neo/SmartContract/Manifest/ExtendedType.cs index 017a7e1f84..6e3c6c1685 100644 --- a/src/Neo/SmartContract/Manifest/ExtendedType.cs +++ b/src/Neo/SmartContract/Manifest/ExtendedType.cs @@ -13,6 +13,9 @@ using Neo.VM; using Neo.VM.Types; using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; namespace Neo.SmartContract.Manifest { @@ -20,6 +23,52 @@ namespace Neo.SmartContract.Manifest public class ExtendedType : IInteroperable, IEquatable { + private static readonly Regex NamedTypePattern = new("^[A-Za-z][A-Za-z0-9.]{0,63}$", RegexOptions.Compiled); + + private static readonly HashSet LengthAllowedTypes = new() + { + ContractParameterType.Integer, + ContractParameterType.ByteArray, + ContractParameterType.String, + ContractParameterType.Array + }; + + private static readonly HashSet ForbidNullAllowedTypes = new() + { + ContractParameterType.Hash160, + ContractParameterType.Hash256, + ContractParameterType.ByteArray, + ContractParameterType.String, + ContractParameterType.Array, + ContractParameterType.Map, + ContractParameterType.InteropInterface + }; + + private static readonly HashSet MapKeyAllowedTypes = new() + { + ContractParameterType.Signature, + ContractParameterType.Boolean, + ContractParameterType.Integer, + ContractParameterType.Hash160, + ContractParameterType.Hash256, + ContractParameterType.ByteArray, + ContractParameterType.PublicKey, + ContractParameterType.String + }; + + private static FormatException Nep25Error(string message) => new($"Invalid NEP-25 extended type: {message}"); + + internal static bool IsValidNamedTypeIdentifier(string name) + { + return !string.IsNullOrEmpty(name) && NamedTypePattern.IsMatch(name); + } + + internal static void EnsureValidNamedTypeIdentifier(string name) + { + if (!IsValidNamedTypeIdentifier(name)) + throw Nep25Error($"Named type '{name}' must start with a letter, contain only alphanumeric characters or dots, and be at most 64 characters long."); + } + /// /// The type of the parameter. It can be any value of except . /// @@ -214,6 +263,7 @@ public static ExtendedType FromJson(JObject json) NamedType = json["namedtype"]?.GetString(), }; if (!Enum.IsDefined(typeof(ContractParameterType), type.Type)) throw new FormatException(); + if (type.Type == ContractParameterType.Void) throw Nep25Error("Void type is not allowed."); if (json["length"] != null) { type.Length = json["length"]!.GetInt32(); @@ -329,7 +379,120 @@ public bool Equals(ExtendedType? other) return true; } + + internal void ValidateForParameterOrReturn(ContractParameterType expectedType, ISet? knownNamedTypes) + { + ValidateCore(expectedType, allowFields: false, knownNamedTypes, allowNamedTypeReference: true); + } + + internal void ValidateForNamedTypeDefinition(ISet? knownNamedTypes) + { + ValidateCore(expectedType: null, allowFields: true, knownNamedTypes, allowNamedTypeReference: true); + } + + private void ValidateCore(ContractParameterType? expectedType, bool allowFields, ISet? knownNamedTypes, bool allowNamedTypeReference) + { + if (expectedType.HasValue && Type != expectedType.Value) + throw Nep25Error($"Type mismatch. Expected '{expectedType.Value}', got '{Type}'."); + + if (!Enum.IsDefined(typeof(ContractParameterType), Type) || Type == ContractParameterType.Void) + throw Nep25Error($"Unsupported type '{Type}'."); + + if (Length.HasValue && !LengthAllowedTypes.Contains(Type)) + throw Nep25Error($"length cannot be specified for type '{Type}'."); + + if (ForbidNull.HasValue && !ForbidNullAllowedTypes.Contains(Type)) + throw Nep25Error($"forbidnull cannot be specified for type '{Type}'."); + + if (Interface.HasValue && Type != ContractParameterType.InteropInterface) + throw Nep25Error($"interface can only be used with InteropInterface type."); + + if (Type == ContractParameterType.InteropInterface && !Interface.HasValue) + throw Nep25Error("interface is required for InteropInterface type."); + + if (Key.HasValue && Type != ContractParameterType.Map) + throw Nep25Error($"key cannot be used with type '{Type}'."); + + if (Key.HasValue && !MapKeyAllowedTypes.Contains(Key.Value)) + throw Nep25Error($"key '{Key.Value}' is not allowed for map definitions."); + + if (Type == ContractParameterType.Map && !Key.HasValue) + throw Nep25Error("key is required for Map type."); + + if (NamedType != null) + { + if (!allowNamedTypeReference) + throw Nep25Error("namedtype is not allowed in this context."); + + if (Type != ContractParameterType.Array) + throw Nep25Error("namedtype can only be used with Array type."); + + EnsureValidNamedTypeIdentifier(NamedType); + + if (Length.HasValue || ForbidNull.HasValue || Interface.HasValue || Key.HasValue || Value is not null || (Fields is not null && Fields.Length > 0)) + throw Nep25Error("namedtype cannot be combined with other modifiers."); + + if (knownNamedTypes != null && !knownNamedTypes.Contains(NamedType)) + throw Nep25Error($"namedtype '{NamedType}' is not defined in the manifest."); + } + + if (Value is not null) + { + if (Type != ContractParameterType.Array && Type != ContractParameterType.InteropInterface && Type != ContractParameterType.Map) + throw Nep25Error("value can only be specified for Array, Map or InteropInterface types."); + + if (Fields is not null && Fields.Length > 0) + throw Nep25Error("value and fields cannot be used together."); + + if (Type == ContractParameterType.InteropInterface && !Interface.HasValue) + throw Nep25Error("interface must be provided when value is specified for InteropInterface type."); + + if (Type == ContractParameterType.Map && !Key.HasValue) + throw Nep25Error("key must be provided when value is specified for Map type."); + + Value.ValidateCore(expectedType: null, allowFields, knownNamedTypes, allowNamedTypeReference); + } + else + { + if (Type == ContractParameterType.Map) + throw Nep25Error("value is required for Map type."); + + if (Type == ContractParameterType.InteropInterface) + throw Nep25Error("value is required for InteropInterface type."); + + if (Type == ContractParameterType.Array && NamedType is null && (Fields is null || Fields.Length == 0)) + throw Nep25Error("value, namedtype or fields must be provided for Array type to describe element type."); + } + + if (Fields is not null && Fields.Length > 0) + { + if (!allowFields) + throw Nep25Error("fields cannot be used in method parameters or return values."); + + if (Type != ContractParameterType.Array) + throw Nep25Error("fields can only be used with Array type."); + + if (Value is not null) + throw Nep25Error("fields and value cannot be used together."); + + if (NamedType != null) + throw Nep25Error("fields cannot be combined with namedtype."); + + foreach (var field in Fields) + { + field.ExtendedType?.ValidateCore(field.Type, allowFields: true, knownNamedTypes, allowNamedTypeReference); + } + } + + if (!allowFields) + { + if (Fields is not null && Fields.Length > 0) + throw Nep25Error("fields cannot be used in method parameters or return values."); + + if (Value?.Fields is { Length: > 0 }) + throw Nep25Error("fields cannot be used in method parameters or return values."); + } + } } #nullable disable } - diff --git a/tests/Neo.UnitTests/SmartContract/Manifest/UT_ContractManifest.cs b/tests/Neo.UnitTests/SmartContract/Manifest/UT_ContractManifest.cs index 4482d891be..f94c3bbb86 100644 --- a/tests/Neo.UnitTests/SmartContract/Manifest/UT_ContractManifest.cs +++ b/tests/Neo.UnitTests/SmartContract/Manifest/UT_ContractManifest.cs @@ -215,6 +215,85 @@ public void ParseFromJson_Trust() Assert.AreEqual(manifest.ToJson().ToString(), check.ToJson().ToString()); } + [TestMethod] + public void ParseFromJson_ExtendedTypeMismatch_ShouldThrow() + { + var json = """ + { + "name":"testManifest", + "groups":[], + "features":{}, + "supportedstandards":[], + "abi":{ + "methods":[ + { + "name":"testMethod", + "parameters":[ + { + "name":"arg", + "type":"Integer", + "extendedtype":{ + "type":"String" + } + } + ], + "returntype":"Void", + "offset":0, + "safe":true + } + ], + "events":[] + }, + "permissions":[], + "trusts":[], + "extra":null + } + """; + + json = Regex.Replace(json, @"\s+", ""); + Assert.ThrowsExactly(() => ContractManifest.Parse(json)); + } + + [TestMethod] + public void ParseFromJson_UnknownNamedType_ShouldThrow() + { + var json = """ + { + "name":"testManifest", + "groups":[], + "features":{}, + "supportedstandards":[], + "abi":{ + "methods":[ + { + "name":"testMethod", + "parameters":[ + { + "name":"arg", + "type":"Array", + "extendedtype":{ + "type":"Array", + "namedtype":"Custom.Struct" + } + } + ], + "returntype":"Void", + "offset":0, + "safe":true + } + ], + "events":[] + }, + "permissions":[], + "trusts":[], + "extra":null + } + """; + + json = Regex.Replace(json, @"\s+", ""); + Assert.ThrowsExactly(() => ContractManifest.Parse(json)); + } + [TestMethod] public void ToInteroperable_Trust() {