From a414f48bb5d3bf80ddf91f89f53a9e49f9d583dd Mon Sep 17 00:00:00 2001 From: Rowan de Haas Date: Wed, 5 May 2021 15:55:18 +1000 Subject: [PATCH] Contract dynamic swagger endpoint (#527) --- src/Stratis.Bitcoin.Features.Api/Startup.cs | 14 +- .../ContractSchemaFactoryTests.cs | 131 +++++++ .../ParameterInfoMapperExtensionsTests.cs | 59 ++++ .../ContractAssemblyExtensions.cs | 39 +++ .../ContractSchemaFactory.cs | 81 +++++ .../Controllers/ContractSwaggerController.cs | 118 +++++++ .../Controllers/DynamicContractController.cs | 155 +++++++++ .../ParameterInfoMapperExtensions.cs | 34 ++ .../Swagger/ContractSwaggerDocGenerator.cs | 329 ++++++++++++++++++ .../SmartContractFeature.cs | 7 + ...tis.Bitcoin.Features.SmartContracts.csproj | 3 + .../Wallet/SmartContractWalletController.cs | 11 +- .../WalletExtensions.cs | 10 + .../Loader/ContractAssemblyTests.cs | 24 ++ .../Serialization/Prefix.cs | 34 +- 15 files changed, 1024 insertions(+), 25 deletions(-) create mode 100644 src/Stratis.Bitcoin.Features.SmartContracts.Tests/ContractSchemaFactoryTests.cs create mode 100644 src/Stratis.Bitcoin.Features.SmartContracts.Tests/ParameterInfoMapperExtensionsTests.cs create mode 100644 src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ContractAssemblyExtensions.cs create mode 100644 src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ContractSchemaFactory.cs create mode 100644 src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Controllers/ContractSwaggerController.cs create mode 100644 src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Controllers/DynamicContractController.cs create mode 100644 src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ParameterInfoMapperExtensions.cs create mode 100644 src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Swagger/ContractSwaggerDocGenerator.cs diff --git a/src/Stratis.Bitcoin.Features.Api/Startup.cs b/src/Stratis.Bitcoin.Features.Api/Startup.cs index 277c23cf68..2148ad6127 100644 --- a/src/Stratis.Bitcoin.Features.Api/Startup.cs +++ b/src/Stratis.Bitcoin.Features.Api/Startup.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; using Newtonsoft.Json.Converters; using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerUI; @@ -28,6 +29,7 @@ public Startup(IWebHostEnvironment env, IFullNode fullNode) } private IFullNode fullNode; + private SwaggerUIOptions uiOptions; public IConfigurationRoot Configuration { get; } @@ -109,8 +111,15 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient, ConfigureSwaggerOptions>(); // Register the Swagger generator. This will use the options we injected just above. - services.AddSwaggerGen(); + services.AddSwaggerGen(c => + { + c.SwaggerDoc("contracts", new OpenApiInfo { Title = "Contract API", Version = "1" }); + + }); services.AddSwaggerGenNewtonsoftSupport(); // Use Newtonsoft JSON serializer with swagger. Needs to be placed after AddSwaggerGen() + + // Hack to be able to access and modify the options object + services.AddSingleton(_ => this.uiOptions); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -141,6 +150,9 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerF { c.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant()); } + + // Hack to be able to access and modify the options object configured here + this.uiOptions = c; }); } } diff --git a/src/Stratis.Bitcoin.Features.SmartContracts.Tests/ContractSchemaFactoryTests.cs b/src/Stratis.Bitcoin.Features.SmartContracts.Tests/ContractSchemaFactoryTests.cs new file mode 100644 index 0000000000..83105ed0a9 --- /dev/null +++ b/src/Stratis.Bitcoin.Features.SmartContracts.Tests/ContractSchemaFactoryTests.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.OpenApi.Models; +using Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor; +using Stratis.SmartContracts; +using Stratis.SmartContracts.CLR.Compilation; +using Stratis.SmartContracts.CLR.Loader; +using Xunit; + +namespace Stratis.Bitcoin.Features.SmartContracts.Tests +{ + public class ContractSchemaFactoryTests + { + private const string Code = @" +using Stratis.SmartContracts; +[Deploy] +public class PrimitiveParams : SmartContract +{ + public PrimitiveParams(ISmartContractState state): base(state) {} + public void AcceptsBool(bool b) {} + public void AcceptsByte(byte bb) {} + public void AcceptsByteArray(byte[] ba) {} + public void AcceptsChar(char c) {} + public void AcceptsString(string s) {} + public void AcceptsUint(uint ui) {} + public void AcceptsUlong(ulong ul) {} + public void AcceptsInt(int i) {} + public void AcceptsLong(long l) {} + public void AcceptsAddress(Address a) {} + public bool SomeProperty {get; set;} + public void AcceptsAllParams(bool b, byte bb, byte[] ba, char c, string s, uint ui, ulong ul, int i, long l, Address a) {} +} +public class DontDeploy : SmartContract +{ + public DontDeploy(ISmartContractState state): base(state) {} + public void SomeMethod(string i) {} +} +"; + [Fact] + public void Map_Parameter_Type_Success() + { + var compilationResult = ContractCompiler.Compile(Code); + + var assembly = Assembly.Load(compilationResult.Compilation); + + var mapper = new ContractSchemaFactory(); + + MethodInfo methodInfo = assembly.ExportedTypes.First(t => t.Name == "PrimitiveParams").GetMethod("AcceptsAllParams"); + var schema = mapper.Map(methodInfo); + var properties = schema.Properties; + + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(bool)]().Type, properties["b"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(byte)]().Type, properties["bb"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(byte[])]().Type, properties["ba"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(char)]().Type, properties["c"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(string)]().Type, properties["s"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(uint)]().Type, properties["ui"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(ulong)]().Type, properties["ul"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(int)]().Type, properties["i"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(long)]().Type, properties["l"].Type); + Assert.Equal(ContractSchemaFactory.PrimitiveTypeMap[typeof(string)]().Type, properties["a"].Type); + } + + [Fact] + public void Map_Type_Success() + { + var compilationResult = ContractCompiler.Compile(Code); + + var assembly = Assembly.Load(compilationResult.Compilation); + + var mapper = new ContractSchemaFactory(); + + // Maps the methods in a type to schemas. + IDictionary mapped = mapper.Map(new ContractAssembly(assembly).GetPublicMethods()); + + Assert.Equal("AcceptsBool", mapped["AcceptsBool"].Title); + Assert.Equal("AcceptsByte", mapped["AcceptsByte"].Title); + Assert.Equal("AcceptsByteArray", mapped["AcceptsByteArray"].Title); + Assert.Equal("AcceptsChar", mapped["AcceptsChar"].Title); + Assert.Equal("AcceptsString", mapped["AcceptsString"].Title); + Assert.Equal("AcceptsUint", mapped["AcceptsUint"].Title); + Assert.Equal("AcceptsUlong", mapped["AcceptsUlong"].Title); + Assert.Equal("AcceptsInt", mapped["AcceptsInt"].Title); + Assert.Equal("AcceptsLong", mapped["AcceptsLong"].Title); + Assert.Equal("AcceptsAddress", mapped["AcceptsAddress"].Title); + + Assert.Equal(11, mapped.Count); + } + + [Fact] + public void Only_Map_Deployed_Type_Success() + { + var compilationResult = ContractCompiler.Compile(Code); + + var assembly = Assembly.Load(compilationResult.Compilation); + + var mapper = new ContractSchemaFactory(); + + IDictionary mapped = mapper.Map(new ContractAssembly(assembly)); + + Assert.Equal(11, mapped.Count); + Assert.False(mapped.ContainsKey("SomeMethod")); + } + + [Fact] + public void Only_Map_Deployed_Type_Single_Contract_Success() + { + string code = @" +using Stratis.SmartContracts; +public class PrimitiveParams : SmartContract +{ + public PrimitiveParams(ISmartContractState state): base(state) {} + public void SomeMethod(string i) {} +} +"; + var compilationResult = ContractCompiler.Compile(code); + + var assembly = Assembly.Load(compilationResult.Compilation); + + var contractAssembly = new ContractAssembly(assembly); + + var mapper = new ContractSchemaFactory(); + + IDictionary mapped = mapper.Map(contractAssembly); + + Assert.Equal(1, mapped.Count); + Assert.True(mapped.ContainsKey("SomeMethod")); + } + } +} \ No newline at end of file diff --git a/src/Stratis.Bitcoin.Features.SmartContracts.Tests/ParameterInfoMapperExtensionsTests.cs b/src/Stratis.Bitcoin.Features.SmartContracts.Tests/ParameterInfoMapperExtensionsTests.cs new file mode 100644 index 0000000000..d0e74e3ea3 --- /dev/null +++ b/src/Stratis.Bitcoin.Features.SmartContracts.Tests/ParameterInfoMapperExtensionsTests.cs @@ -0,0 +1,59 @@ +using System.Linq; +using System.Reflection; +using Newtonsoft.Json.Linq; +using Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor; +using Stratis.SmartContracts.CLR.Compilation; +using Stratis.SmartContracts.CLR.Serialization; +using Xunit; + +namespace Stratis.Bitcoin.Features.SmartContracts.Tests +{ + public class ParameterInfoMapperExtensionsTests + { + [Fact] + public void Map_Method_Params_Success() + { + var code = @" +using Stratis.SmartContracts; +public class Test +{ + public void AcceptsAllParams(bool b, byte bb, byte[] ba, char c, string s, uint ui, ulong ul, int i, long l, Address a) {} +} +"; + var compiled = ContractCompiler.Compile(code).Compilation; + var assembly = Assembly.Load(compiled); + var method = assembly.ExportedTypes.First().GetMethod("AcceptsAllParams"); + + // The jObject as we expect it to come from swagger. + var jObject = JObject.FromObject(new + { + b = "true", + bb = "DD", + ba = "AABB", + c = 'a', + s = "Test", + ui = 12, + ul = 123123128823, + i = 257, + l = 1238457438573495346, + a = "address" + }); + + var mapped = method.GetParameters().Map(jObject); + + // Check the order and type of each param is correct. + Assert.Equal(10, mapped.Length); + Assert.Equal($"{(int)MethodParameterDataType.Bool}#true", mapped[0]); + Assert.Equal($"{(int)MethodParameterDataType.Byte}#DD", mapped[1]); + Assert.Equal($"{(int)MethodParameterDataType.ByteArray}#AABB", mapped[2]); + Assert.Equal($"{(int)MethodParameterDataType.Char}#a", mapped[3]); + Assert.Equal($"{(int)MethodParameterDataType.String}#Test", mapped[4]); + Assert.Equal($"{(int)MethodParameterDataType.UInt}#12", mapped[5]); + Assert.Equal($"{(int)MethodParameterDataType.ULong}#123123128823", mapped[6]); + Assert.Equal($"{(int)MethodParameterDataType.Int}#257", mapped[7]); + Assert.Equal($"{(int)MethodParameterDataType.Long}#1238457438573495346", mapped[8]); + Assert.Equal($"{(int)MethodParameterDataType.Address}#address", mapped[9]); + + } + } +} diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ContractAssemblyExtensions.cs b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ContractAssemblyExtensions.cs new file mode 100644 index 0000000000..872ce25dbd --- /dev/null +++ b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ContractAssemblyExtensions.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Stratis.SmartContracts.CLR.Loader; + +namespace Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor +{ + public static class ContractAssemblyExtensions + { + /// + /// Gets the public methods defined by the contract, ignoring property getters/setters. + /// + /// + public static IEnumerable GetPublicMethods(this IContractAssembly contractAssembly) + { + Type deployedType = contractAssembly.DeployedType; + + if (deployedType == null) + return new List(); + + return deployedType + .GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) // Get only the methods declared on the contract type + .Where(m => !m.IsSpecialName); // Ignore property setters/getters + } + + public static IEnumerable GetPublicGetterProperties(this IContractAssembly contractAssembly) + { + Type deployedType = contractAssembly.DeployedType; + + if (deployedType == null) + return new List(); + + return deployedType + .GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.GetGetMethod() != null); + } + } +} diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ContractSchemaFactory.cs b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ContractSchemaFactory.cs new file mode 100644 index 0000000000..39f28a3fee --- /dev/null +++ b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ContractSchemaFactory.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.OpenApi.Models; +using Stratis.SmartContracts; +using Stratis.SmartContracts.CLR.Loader; + +namespace Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor +{ + /// + /// Factory for generating swagger schema for smart contract primitives. + /// + public class ContractSchemaFactory + { + public static readonly Dictionary> PrimitiveTypeMap = new Dictionary> + { + { typeof(short), () => new OpenApiSchema { Type = "integer", Format = "int32" } }, + { typeof(ushort), () => new OpenApiSchema { Type = "integer", Format = "int32" } }, + { typeof(int), () => new OpenApiSchema { Type = "integer", Format = "int32" } }, + { typeof(uint), () => new OpenApiSchema { Type = "integer", Format = "int32" } }, + { typeof(long), () => new OpenApiSchema { Type = "integer", Format = "int64" } }, + { typeof(ulong), () => new OpenApiSchema { Type = "integer", Format = "int64" } }, + { typeof(float), () => new OpenApiSchema { Type = "number", Format = "float" } }, + { typeof(double), () => new OpenApiSchema { Type = "number", Format = "double" } }, + { typeof(decimal), () => new OpenApiSchema { Type = "number", Format = "double" } }, + { typeof(byte), () => new OpenApiSchema { Type = "integer", Format = "int32" } }, + { typeof(sbyte), () => new OpenApiSchema { Type = "integer", Format = "int32" } }, + { typeof(byte[]), () => new OpenApiSchema { Type = "string", Format = "byte" } }, + { typeof(sbyte[]), () => new OpenApiSchema { Type = "string", Format = "byte" } }, + { typeof(char), () => new OpenApiSchema { Type = "string", Format = "char" } }, + { typeof(string), () => new OpenApiSchema { Type = "string" } }, + { typeof(bool), () => new OpenApiSchema { Type = "boolean" } }, + { typeof(Address), () => new OpenApiSchema { Type = "string" } } + }; + + /// + /// Maps a contract assembly to its schemas. + /// + /// + /// + public IDictionary Map(IContractAssembly assembly) + { + return this.Map(assembly.GetPublicMethods()); + } + + /// + /// Maps a type to its schemas. + /// + /// + /// + public IDictionary Map(IEnumerable methods) + { + return methods.Select(this.Map).ToDictionary(k => k.Title, v => v); + } + + /// + /// Maps a single method to a schema. + /// + /// + /// + public OpenApiSchema Map(MethodInfo method) + { + var schema = new OpenApiSchema(); + schema.Properties = new Dictionary(); + schema.Title = method.Name; + + foreach (var parameter in method.GetParameters()) + { + // Default to string. + OpenApiSchema paramSchema = PrimitiveTypeMap.ContainsKey(parameter.ParameterType) + ? PrimitiveTypeMap[parameter.ParameterType]() + : PrimitiveTypeMap[typeof(string)](); + + schema.Properties.Add(parameter.Name, paramSchema); + } + + return schema; + } + } +} \ No newline at end of file diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Controllers/ContractSwaggerController.cs b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Controllers/ContractSwaggerController.cs new file mode 100644 index 0000000000..0d239fa3c8 --- /dev/null +++ b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Controllers/ContractSwaggerController.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CSharpFunctionalExtensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; +using Microsoft.OpenApi.Extensions; +using Microsoft.OpenApi.Models; +using NBitcoin; +using Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor.Swagger; +using Stratis.Bitcoin.Features.Wallet.Interfaces; +using Stratis.SmartContracts.CLR; +using Stratis.SmartContracts.CLR.Loader; +using Stratis.SmartContracts.Core.State; +using Swashbuckle.AspNetCore.SwaggerGen; +using Swashbuckle.AspNetCore.SwaggerUI; + +namespace Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor.Controllers +{ + /// + /// Controller for dynamically generating swagger documents for smart contract assemblies. + /// + [Route("swagger/contracts")] + public class ContractSwaggerController : Controller + { + private readonly SwaggerGeneratorOptions options; + private readonly ILoader loader; + private readonly IWalletManager walletmanager; + private readonly IStateRepositoryRoot stateRepository; + private readonly Network network; + private SwaggerUIOptions uiOptions; + + public ContractSwaggerController( + SwaggerGeneratorOptions options, + SwaggerUIOptions uiOptions, + ILoader loader, + IWalletManager walletmanager, + IStateRepositoryRoot stateRepository, + Network network) + { + this.options = options; + this.uiOptions = uiOptions; + this.loader = loader; + this.walletmanager = walletmanager; + this.stateRepository = stateRepository; + this.network = network; + } + + /// + /// Dynamically generates a swagger document for the contract at the given address. + /// + /// The contract's address. + /// A model. + /// + [Route("{address}")] + [HttpGet] + public async Task ContractSwaggerDoc(string address) + { + var code = this.stateRepository.GetCode(address.ToUint160(this.network)); + + if (code == null) + throw new Exception("Contract does not exist"); + + Result assemblyLoadResult = this.loader.Load((ContractByteCode)code); + + if (assemblyLoadResult.IsFailure) + throw new Exception("Error loading assembly"); + + IContractAssembly assembly = assemblyLoadResult.Value; + + // Default wallet is the first wallet as ordered by name. + string defaultWalletName = this.walletmanager.GetWalletsNames().OrderBy(n => n).First(); + + // Default address is the first address with a balance, or string.Empty if no addresses have been created. + // Ordering this way is consistent with the wallet UI, ie. whatever appears first in the wallet will appear first here. + string defaultAddress = this.walletmanager.GetAccountAddressesWithBalance(defaultWalletName).FirstOrDefault()?.Address ?? string.Empty; + + var swaggerGen = new ContractSwaggerDocGenerator(this.options, address, assembly, defaultWalletName, defaultAddress); + + OpenApiDocument doc = swaggerGen.GetSwagger("contracts"); + + // TODO confirm v2/v3 + var outputString = doc.Serialize(OpenApiSpecVersion.OpenApi3_0, OpenApiFormat.Json); + + return Ok(outputString); + } + + /// + /// Add the contract address to the Swagger dropdown + /// + /// The contract's address. + /// A success response. + [HttpPost] + public async Task AddContractToSwagger([FromBody] string address) + { + // Check that the contract exists + var code = this.stateRepository.GetCode(address.ToUint160(this.network)); + + if (code == null) + throw new Exception("Contract does not exist"); + + var newUrls = new List(this.uiOptions.ConfigObject.Urls); + + newUrls.Add(new UrlDescriptor + { + Name = $"Contract {address}", + Url = $"/swagger/contracts/{address}" + }); + + this.uiOptions.ConfigObject.Urls = newUrls; + + return Ok(); + } + } +} diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Controllers/DynamicContractController.cs b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Controllers/DynamicContractController.cs new file mode 100644 index 0000000000..baafbda91b --- /dev/null +++ b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Controllers/DynamicContractController.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using CSharpFunctionalExtensions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Stratis.Bitcoin.Features.SmartContracts.Models; +using Stratis.Bitcoin.Features.SmartContracts.Wallet; +using Stratis.Bitcoin.Features.Wallet.Models; +using Stratis.SmartContracts.CLR; +using Stratis.SmartContracts.CLR.Loader; +using Stratis.SmartContracts.Core.State; + +namespace Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor.Controllers +{ + /// + /// Controller for receiving dynamically generated contract calls. + /// Maps calls from a json object to a request model and proxies this to the correct controller method. + /// + public class DynamicContractController : Controller + { + private readonly SmartContractWalletController smartContractWalletController; + private readonly SmartContractsController localCallController; + private readonly IStateRepositoryRoot stateRoot; + private readonly ILoader loader; + private readonly Network network; + + /// + /// Creates a new DynamicContractController instance. + /// + /// + /// + /// + /// + /// + public DynamicContractController( + SmartContractWalletController smartContractWalletController, + SmartContractsController localCallController, + IStateRepositoryRoot stateRoot, + ILoader loader, + Network network) + { + this.smartContractWalletController = smartContractWalletController; + this.localCallController = localCallController; + this.stateRoot = stateRoot; + this.loader = loader; + this.network = network; + } + + /// + /// Call a method on the contract by broadcasting a call transaction to the network. + /// + /// The address of the contract to call. + /// The name of the method on the contract being called. + /// A model of the transaction data, if created and broadcast successfully. + /// + [Route("api/contract/{address}/method/{method}")] + [HttpPost] + public async Task CallMethod([FromRoute] string address, [FromRoute] string method, [FromBody] JObject requestData) + { + var contractCode = this.stateRoot.GetCode(address.ToUint160(this.network)); + + Result loadResult = this.loader.Load((ContractByteCode)contractCode); + + IContractAssembly assembly = loadResult.Value; + + Type type = assembly.DeployedType; + + MethodInfo methodInfo = type.GetMethod(method); + + if (methodInfo == null) + throw new Exception("Method does not exist on contract."); + + ParameterInfo[] parameters = methodInfo.GetParameters(); + + if (!this.ValidateParams(requestData, parameters)) + throw new Exception("Parameters don't match method signature."); + + // Map the JObject to the parameter + types expected by the call. + string[] methodParams = parameters.Map(requestData); + + BuildCallContractTransactionRequest request = this.MapCallRequest(address, method, methodParams, this.Request.Headers); + + // Proxy to the actual SC controller. + return this.smartContractWalletController.Call(request); + } + + /// + /// Query the value of a property on the contract using a local call. + /// + /// The address of the contract to query. + /// The name of the property to query. + /// A model of the query result. + [Route("api/contract/{address}/property/{property}")] + [HttpGet] + public IActionResult LocalCallProperty([FromRoute] string address, [FromRoute] string property) + { + LocalCallContractRequest request = this.MapLocalCallRequest(address, property, this.Request.Headers); + + // Proxy to the actual SC controller. + return this.localCallController.LocalCallSmartContractTransaction(request); + } + + private bool ValidateParams(JObject requestData, ParameterInfo[] parameters) + { + foreach (ParameterInfo param in parameters) + { + if (requestData[param.Name] == null) + return false; + } + + return true; + } + + private BuildCallContractTransactionRequest MapCallRequest(string address, string method, string[] parameters, IHeaderDictionary headers) + { + var call = new BuildCallContractTransactionRequest + { + GasPrice = ulong.Parse(headers["GasPrice"]), + GasLimit = ulong.Parse(headers["GasLimit"]), + Amount = headers["Amount"], + FeeAmount = headers["FeeAmount"], + WalletName = headers["WalletName"], + Password = headers["WalletPassword"], + Sender = headers["Sender"], + AccountName = "account 0", + ContractAddress = address, + MethodName = method, + Parameters = parameters, + Outpoints = new List() + }; + + return call; + } + + private LocalCallContractRequest MapLocalCallRequest(string address, string property, IHeaderDictionary headers) + { + return new LocalCallContractRequest + { + GasPrice = ulong.Parse(headers["GasPrice"]), + GasLimit = ulong.Parse(headers["GasLimit"]), + Amount = headers["Amount"], + Sender = headers["Sender"], + ContractAddress = address, + MethodName = property + }; + } + } +} \ No newline at end of file diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ParameterInfoMapperExtensions.cs b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ParameterInfoMapperExtensions.cs new file mode 100644 index 0000000000..2f2376f12e --- /dev/null +++ b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/ParameterInfoMapperExtensions.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using Newtonsoft.Json.Linq; +using Stratis.SmartContracts.CLR.Serialization; + +namespace Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor +{ + public static class ParameterInfoMapperExtensions + { + /// + /// Maps a JObject of values to the parameters on a method. + /// + public static string[] Map(this ParameterInfo[] parameters, JObject obj) + { + var result = new List(); + + foreach (ParameterInfo parameter in parameters) + { + JToken jObParam = obj[parameter.Name]; + + if (jObParam == null) + throw new Exception("Couldn't map all params"); + + Prefix prefix = Prefix.ForType(parameter.ParameterType); + + result.Add($"{prefix.Value}#{jObParam}"); + } + + return result.ToArray(); + } + } +} diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Swagger/ContractSwaggerDocGenerator.cs b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Swagger/ContractSwaggerDocGenerator.cs new file mode 100644 index 0000000000..0459937117 --- /dev/null +++ b/src/Stratis.Bitcoin.Features.SmartContracts/ReflectionExecutor/Swagger/ContractSwaggerDocGenerator.cs @@ -0,0 +1,329 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor.Consensus.Rules; +using Stratis.SmartContracts.CLR.Loader; +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor.Swagger +{ + /// + /// Creates swagger documents for a contract assembly. + /// Maps the methods of a contract and its parameters to a call endpoint. + /// Maps the properties of a contract to an local call endpoint. + /// + public class ContractSwaggerDocGenerator : ISwaggerProvider + { + private readonly SwaggerGeneratorOptions options; + private readonly string address; + private readonly IContractAssembly assembly; + private readonly string defaultWalletName; + private readonly string defaultSenderAddress; + + public ContractSwaggerDocGenerator(SwaggerGeneratorOptions options, string address, IContractAssembly assembly, string defaultWalletName = "", string defaultSenderAddress = "") + { + this.options = options; + this.address = address; + this.assembly = assembly; + this.defaultWalletName = defaultWalletName; + this.defaultSenderAddress = defaultSenderAddress; + } + + private IDictionary CreateDefinitions() + { + // Creates schema for each of the methods in the contract. + var schemaFactory = new ContractSchemaFactory(); + + return schemaFactory.Map(this.assembly); + } + + private IDictionary CreatePathItems(IDictionary schema) + { + // Creates path items for each of the methods & properties in the contract + their schema.O + + IEnumerable methods = this.assembly.GetPublicMethods(); + + var methodPaths = methods + .ToDictionary(k => $"/api/contract/{this.address}/method/{k.Name}", v => this.CreatePathItem(v, schema)); + + IEnumerable properties = this.assembly.GetPublicGetterProperties(); + + var propertyPaths = properties + .ToDictionary(k => $"/api/contract/{this.address}/property/{k.Name}", v => this.CreatePathItem(v)); + + foreach (KeyValuePair item in propertyPaths) + { + methodPaths[item.Key] = item.Value; + } + + return methodPaths; + } + + private OpenApiPathItem CreatePathItem(PropertyInfo propertyInfo) + { + var operation = new OpenApiOperation + { + Tags = new List { new OpenApiTag { Name = propertyInfo.Name } }, + OperationId = propertyInfo.Name, + Parameters = this.GetLocalCallMetadataHeaderParams(), + Responses = new OpenApiResponses { { "200", new OpenApiResponse { Description = "Success" } } } + }; + + var pathItem = new OpenApiPathItem + { + Operations = new Dictionary { { OperationType.Get, operation } } + }; + + return pathItem; + } + + private OpenApiPathItem CreatePathItem(MethodInfo methodInfo, IDictionary schema) + { + var operation = new OpenApiOperation + { + Tags = new List { new OpenApiTag { Name = methodInfo.Name } }, + OperationId = methodInfo.Name, + Parameters = this.GetCallMetadataHeaderParams(), + Responses = new OpenApiResponses { { "200", new OpenApiResponse { Description = "Success" } } } + }; + + operation.RequestBody = new OpenApiRequestBody + { + Description = $"{methodInfo.Name}", + Required = true, + Content = new Dictionary + { + { "application/json", new OpenApiMediaType + { + Schema = schema[methodInfo.Name] + } + } + }, + }; + + var pathItem = new OpenApiPathItem + { + Operations = new Dictionary { { OperationType.Post, operation } } + }; + + return pathItem; + } + + private List GetLocalCallMetadataHeaderParams() + { + return new List + { + new OpenApiParameter + { + Name = "GasPrice", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "number", + Format = "int64", + Minimum = SmartContractFormatLogic.GasPriceMinimum, + Maximum = SmartContractFormatLogic.GasPriceMaximum, + Default = new OpenApiLong((long)SmartContractMempoolValidator.MinGasPrice) // Long not ideal but there's no OpenApiUlong + }, + }, + new OpenApiParameter + { + Name = "GasLimit", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "number", + Format = "int64", + Minimum = SmartContractFormatLogic.GasLimitCallMinimum, + Maximum = SmartContractFormatLogic.GasLimitMaximum, + Default = new OpenApiLong((long)SmartContractFormatLogic.GasLimitMaximum) // Long not ideal but there's no OpenApiUlong + }, + }, + new OpenApiParameter + { + Name = "Amount", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "string", + Default = new OpenApiString("0") + }, + }, + new OpenApiParameter + { + Name = "Sender", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "string", + Default = new OpenApiString(this.defaultSenderAddress) + }, + } + }; + } + + private List GetCallMetadataHeaderParams() + { + return new List + { + new OpenApiParameter + { + Name = "GasPrice", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "number", + Format = "int64", + Minimum = SmartContractFormatLogic.GasPriceMinimum, + Maximum = SmartContractFormatLogic.GasPriceMaximum, + Default = new OpenApiLong((long)SmartContractMempoolValidator.MinGasPrice) // Long not ideal but there's no OpenApiUlong + }, + }, + new OpenApiParameter + { + Name = "GasLimit", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "number", + Format = "int64", + Minimum = SmartContractFormatLogic.GasLimitCallMinimum, + Maximum = SmartContractFormatLogic.GasLimitMaximum, + Default = new OpenApiLong((long)SmartContractFormatLogic.GasLimitCallMinimum) // Long not ideal but there's no OpenApiUlong + }, + }, + new OpenApiParameter + { + Name = "Amount", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "string", + Default = new OpenApiString("0") + }, + }, + new OpenApiParameter + { + Name = "FeeAmount", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "string", + Default = new OpenApiString("0.01") + }, + }, + new OpenApiParameter + { + Name = "WalletName", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "string", + Default = new OpenApiString(this.defaultWalletName) + }, + }, + new OpenApiParameter + { + Name = "WalletPassword", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "string" + } + }, + new OpenApiParameter + { + Name = "Sender", + In = ParameterLocation.Header, + Required = true, + Schema = new OpenApiSchema + { + Type = "string", + Default = new OpenApiString(this.defaultSenderAddress) + }, + } + }; + } + + /// + /// Generates a swagger document for an assembly. Adds a path per public method, with a request body + /// that contains the parameters of the method. Transaction-related metadata is added to header fields + /// which are pre-filled with sensible defaults. + /// + /// The name of the swagger document to use. + /// + /// + /// + /// + public OpenApiDocument GetSwagger(string documentName, string host = null, string basePath = null) + { + if (!this.options.SwaggerDocs.TryGetValue(documentName, out OpenApiInfo info)) + throw new UnknownSwaggerDocument(documentName, this.options.SwaggerDocs.Select(d => d.Key)); + + SetInfo(info); + + IDictionary definitions = this.CreateDefinitions(); + + var swaggerDoc = new OpenApiDocument + { + Info = info, + Servers = GenerateServers(host, basePath), + Paths = GeneratePaths(definitions), + Components = new OpenApiComponents + { + Schemas = null, + SecuritySchemes = new Dictionary(this.options.SecuritySchemes) + }, + SecurityRequirements = new List(this.options.SecurityRequirements) + }; + + return swaggerDoc; + } + + private void SetInfo(OpenApiInfo info) + { + info.Title = $"{this.assembly.DeployedType.Name} Contract API"; + info.Description = $"{this.address}"; + } + + private IList GenerateServers(string host, string basePath) + { + if (this.options.Servers.Any()) + { + return new List(this.options.Servers); + } + + return (host == null && basePath == null) + ? new List() + : new List { new OpenApiServer { Url = $"{host}{basePath}" } }; + } + + private OpenApiPaths GeneratePaths(IDictionary definitions) + { + IDictionary paths = this.CreatePathItems(definitions); + + OpenApiPaths pathsObject = new OpenApiPaths(); + + foreach (KeyValuePair path in paths) + { + pathsObject.Add(path.Key, path.Value); + } + + return pathsObject; + } + } +} diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/SmartContractFeature.cs b/src/Stratis.Bitcoin.Features.SmartContracts/SmartContractFeature.cs index 5c39067dbe..90000154f2 100644 --- a/src/Stratis.Bitcoin.Features.SmartContracts/SmartContractFeature.cs +++ b/src/Stratis.Bitcoin.Features.SmartContracts/SmartContractFeature.cs @@ -16,6 +16,8 @@ using Stratis.Bitcoin.Features.SmartContracts.Caching; using Stratis.Bitcoin.Features.SmartContracts.PoA; using Stratis.Bitcoin.Features.SmartContracts.PoW; +using Stratis.Bitcoin.Features.SmartContracts.ReflectionExecutor.Controllers; +using Stratis.Bitcoin.Features.SmartContracts.Wallet; using Stratis.Bitcoin.Interfaces; using Stratis.Bitcoin.Utilities; using Stratis.SmartContracts; @@ -133,6 +135,11 @@ public static IFullNodeBuilder AddSmartContracts(this IFullNodeBuilder fullNodeB // After setting up, invoke any additional options which can replace services as required. options?.Invoke(new SmartContractOptions(services, fullNodeBuilder.Network)); + + // Controllers, necessary for DIing into the dynamic controller api. + // Use AddScoped for instance-per-request lifecycle, ref. https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-2.2#scoped + services.AddScoped(); + services.AddScoped(); }); }); diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/Stratis.Bitcoin.Features.SmartContracts.csproj b/src/Stratis.Bitcoin.Features.SmartContracts/Stratis.Bitcoin.Features.SmartContracts.csproj index 76c5afc27f..5df6fc35f1 100644 --- a/src/Stratis.Bitcoin.Features.SmartContracts/Stratis.Bitcoin.Features.SmartContracts.csproj +++ b/src/Stratis.Bitcoin.Features.SmartContracts/Stratis.Bitcoin.Features.SmartContracts.csproj @@ -17,6 +17,9 @@ + + + diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/Wallet/SmartContractWalletController.cs b/src/Stratis.Bitcoin.Features.SmartContracts/Wallet/SmartContractWalletController.cs index 739acafba1..26255afe8d 100644 --- a/src/Stratis.Bitcoin.Features.SmartContracts/Wallet/SmartContractWalletController.cs +++ b/src/Stratis.Bitcoin.Features.SmartContracts/Wallet/SmartContractWalletController.cs @@ -54,15 +54,6 @@ public sealed class SmartContractWalletController : Controller this.smartContractTransactionService = smartContractTransactionService; } - private IEnumerable GetAccountAddressesWithBalance(string walletName) - { - return this.walletManager - .GetSpendableTransactionsInWallet(walletName) - .GroupBy(x => x.Address) - .Where(grouping => grouping.Sum(x => x.Transaction.GetUnspentAmount(true)) > 0) - .Select(grouping => grouping.Key); - } - /// /// Gets a smart contract account address. /// This is a single address to use for all smart contract interactions. @@ -102,7 +93,7 @@ public IActionResult GetAccountAddresses(string walletName) try { - IEnumerable addresses = this.GetAccountAddressesWithBalance(walletName) + IEnumerable addresses = this.walletManager.GetAccountAddressesWithBalance(walletName) .Select(a => a.Address); if (!addresses.Any()) diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/WalletExtensions.cs b/src/Stratis.Bitcoin.Features.SmartContracts/WalletExtensions.cs index 57619a791b..024c8dd21c 100644 --- a/src/Stratis.Bitcoin.Features.SmartContracts/WalletExtensions.cs +++ b/src/Stratis.Bitcoin.Features.SmartContracts/WalletExtensions.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using NBitcoin; +using Stratis.Bitcoin.Features.Wallet; using Stratis.Bitcoin.Features.Wallet.Interfaces; namespace Stratis.Bitcoin.Features.SmartContracts @@ -13,5 +14,14 @@ public static List GetSpendableInputsForAddress(this IWalletManager wa { return walletManager.GetSpendableTransactionsInWallet(walletName, minConfirmations).Where(x => x.Address.Address == address).Select(x => x.ToOutPoint()).ToList(); } + + public static IEnumerable GetAccountAddressesWithBalance(this IWalletManager walletManager, string walletName) + { + return walletManager + .GetSpendableTransactionsInWallet(walletName) + .GroupBy(x => x.Address) + .Where(grouping => grouping.Sum(x => x.Transaction.GetUnspentAmount(true)) > 0) + .Select(grouping => grouping.Key); + } } } diff --git a/src/Stratis.SmartContracts.CLR.Tests/Loader/ContractAssemblyTests.cs b/src/Stratis.SmartContracts.CLR.Tests/Loader/ContractAssemblyTests.cs index 9520e1738e..500aac96d6 100644 --- a/src/Stratis.SmartContracts.CLR.Tests/Loader/ContractAssemblyTests.cs +++ b/src/Stratis.SmartContracts.CLR.Tests/Loader/ContractAssemblyTests.cs @@ -104,5 +104,29 @@ public class TypeTwo : SmartContract Assert.NotNull(type); Assert.Equal("TypeOne", type.Name); } + + [Fact] + public void GetDeployedType_NoAttribute_Returns_Correct_Type() + { + var code = @" +namespace Stratis.SmartContracts.CLR.Tests.Loader +{ + public class Test : SmartContract + { + public Test(ISmartContractState state) + : base(state) + { } + } +} +"; + var assemblyLoadResult = this.loader.Load((ContractByteCode)ContractCompiler.Compile(code).Compilation); + + var contractAssembly = assemblyLoadResult.Value; + + var type = contractAssembly.DeployedType; + + Assert.NotNull(type); + Assert.Equal("Test", type.Name); + } } } diff --git a/src/Stratis.SmartContracts.CLR/Serialization/Prefix.cs b/src/Stratis.SmartContracts.CLR/Serialization/Prefix.cs index 6fbfeb95b4..22b71916f9 100644 --- a/src/Stratis.SmartContracts.CLR/Serialization/Prefix.cs +++ b/src/Stratis.SmartContracts.CLR/Serialization/Prefix.cs @@ -55,46 +55,52 @@ private Type GetType(byte b) public static Prefix ForObject(object o) { - byte type = (byte) GetPrimitiveType(o); + byte type = (byte) GetPrimitiveType(o.GetType()); return new Prefix(type); } - private static MethodParameterDataType GetPrimitiveType(object o) + public static Prefix ForType(Type t) { - if (o is bool) + byte type = (byte) GetPrimitiveType(t); + return new Prefix(type); + } + + private static MethodParameterDataType GetPrimitiveType(Type o) + { + if (o == typeof(bool)) return MethodParameterDataType.Bool; - if (o is byte) + if (o == typeof(byte)) return MethodParameterDataType.Byte; - if (o is byte[]) + if (o == typeof(byte[])) return MethodParameterDataType.ByteArray; - if (o is char) + if (o == typeof(char)) return MethodParameterDataType.Char; - if (o is string) + if (o == typeof(string)) return MethodParameterDataType.String; - if (o is uint) + if (o == typeof(uint)) return MethodParameterDataType.UInt; - if (o is ulong) + if (o == typeof(ulong)) return MethodParameterDataType.ULong; - if (o is Address) + if (o == typeof(Address)) return MethodParameterDataType.Address; - if (o is long) + if (o == typeof(long)) return MethodParameterDataType.Long; - if (o is int) + if (o == typeof(int)) return MethodParameterDataType.Int; - if (o is UInt128) + if (o == typeof(UInt128)) return MethodParameterDataType.UInt128; - if (o is UInt256) + if (o == typeof(UInt256)) return MethodParameterDataType.UInt256; // Any other types are not supported.