diff --git a/src/Stratis.FullNode.sln b/src/Stratis.FullNode.sln index 5c5e114540..875a8d413d 100644 --- a/src/Stratis.FullNode.sln +++ b/src/Stratis.FullNode.sln @@ -189,7 +189,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stratis.Features.Unity3dApi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stratis.Bitcoin.Features.ExternalApi", "Stratis.Bitcoin.Features.ExternalAPI\Stratis.Bitcoin.Features.ExternalApi.csproj", "{21D41C53-62D8-4F68-A3D1-88BB2AB195E3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Stratis.Interop.Contracts", "Stratis.Interop.Contracts\Stratis.Interop.Contracts.csproj", "{3A82CB5E-FB80-4A19-9223-AF25123F7288}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stratis.Interop.Contracts", "Stratis.Interop.Contracts\Stratis.Interop.Contracts.csproj", "{3A82CB5E-FB80-4A19-9223-AF25123F7288}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Stratis.SCL", "Stratis.SCL\Stratis.SCL.csproj", "{B80F392A-10CD-4A19-9B55-A7FA477533FC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -505,6 +507,10 @@ Global {3A82CB5E-FB80-4A19-9223-AF25123F7288}.Debug|Any CPU.Build.0 = Debug|Any CPU {3A82CB5E-FB80-4A19-9223-AF25123F7288}.Release|Any CPU.ActiveCfg = Release|Any CPU {3A82CB5E-FB80-4A19-9223-AF25123F7288}.Release|Any CPU.Build.0 = Release|Any CPU + {B80F392A-10CD-4A19-9B55-A7FA477533FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B80F392A-10CD-4A19-9B55-A7FA477533FC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B80F392A-10CD-4A19-9B55-A7FA477533FC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B80F392A-10CD-4A19-9B55-A7FA477533FC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -577,6 +583,7 @@ Global {B08D2057-F48D-4E72-99F4-95A35E6E0DFD} = {15D29FFD-6142-4DC5-AFFD-10BA0CA55C45} {21D41C53-62D8-4F68-A3D1-88BB2AB195E3} = {15D29FFD-6142-4DC5-AFFD-10BA0CA55C45} {3A82CB5E-FB80-4A19-9223-AF25123F7288} = {1B9A916F-DDAC-4675-B424-EDEDC1A58231} + {B80F392A-10CD-4A19-9B55-A7FA477533FC} = {1B9A916F-DDAC-4675-B424-EDEDC1A58231} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6C780ABA-5872-4B83-AD3F-A5BD423AD907} diff --git a/src/Stratis.SCL/ECRecover.cs b/src/Stratis.SCL/ECRecover.cs new file mode 100644 index 0000000000..bbb74ee33b --- /dev/null +++ b/src/Stratis.SCL/ECRecover.cs @@ -0,0 +1,56 @@ +using System; +using NBitcoin; +using Stratis.SmartContracts; + +namespace Stratis.SCL.Crypto +{ + public static class ECRecover + { + /// + /// Retrieves the address of the signer of an ECDSA signature. + /// + /// + /// The ECDSA signature prepended with header information specifying the correct value of recId. + /// The Address for the signer of a signature. + /// A bool representing whether or not the signer was retrieved successfully. + public static bool TryGetSigner(byte[] message, byte[] signature, out Address address) + { + address = Address.Zero; + + if (message == null || signature == null) + return false; + + // NBitcoin is very throwy + try + { + uint256 hashedUint256 = GetUint256FromMessage(message); + + PubKey pubKey = PubKey.RecoverCompact(hashedUint256, signature); + + address = CreateAddress(pubKey.Hash.ToBytes()); + + return true; + } + catch + { + return false; + } + } + + private static uint256 GetUint256FromMessage(byte[] message) + { + return new uint256(SHA3.Keccak256(message)); + } + + private static Address CreateAddress(byte[] bytes) + { + uint pn0 = BitConverter.ToUInt32(bytes, 0); + uint pn1 = BitConverter.ToUInt32(bytes, 4); + uint pn2 = BitConverter.ToUInt32(bytes, 8); + uint pn3 = BitConverter.ToUInt32(bytes, 12); + uint pn4 = BitConverter.ToUInt32(bytes, 16); + + return new Address(pn0, pn1, pn2, pn3, pn4); + } + } +} diff --git a/src/Stratis.SCL/Operations.cs b/src/Stratis.SCL/Operations.cs new file mode 100644 index 0000000000..464fa45320 --- /dev/null +++ b/src/Stratis.SCL/Operations.cs @@ -0,0 +1,9 @@ +using System; + +namespace Stratis.SCL.Base +{ + public static class Operations + { + public static void Noop() { } + } +} diff --git a/src/Stratis.SCL/SHA3.cs b/src/Stratis.SCL/SHA3.cs new file mode 100644 index 0000000000..8e1071dc3d --- /dev/null +++ b/src/Stratis.SCL/SHA3.cs @@ -0,0 +1,17 @@ +using HashLib; + +namespace Stratis.SCL.Crypto +{ + public static class SHA3 + { + /// + /// Returns a 32-byte Keccak256 hash of the given bytes. + /// + /// + /// + public static byte[] Keccak256(byte[] input) + { + return HashFactory.Crypto.SHA3.CreateKeccak256().ComputeBytes(input).GetBytes(); + } + } +} diff --git a/src/Stratis.SCL/SSAS.cs b/src/Stratis.SCL/SSAS.cs new file mode 100644 index 0000000000..65da10ff7a --- /dev/null +++ b/src/Stratis.SCL/SSAS.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Primitives; +using NBitcoin.DataEncoders; +using Nethereum.RLP; +using Stratis.SmartContracts; + +namespace Stratis.SCL.Crypto +{ + public static class SSAS + { + public static byte[] ValidateAndParse(Address address, string url, byte[] signature, string signatureTemplateMap) + { + // First verify the signature. + if (!ECRecover.TryGetSigner(Encoding.ASCII.GetBytes(url), signature, out Address signer) || signer != address) + return null; + + try + { + // Create a mapping of available url arguments. + Dictionary argDict = QueryHelpers.ParseQuery(new Uri(url).Query); + + // The "signatureTemplateMap" takes the following form: "uid#11,symbol#4,amount#12,targetAddres#4,targetNetwork#4" + var arguments = signatureTemplateMap + .Split(",", StringSplitOptions.RemoveEmptyEntries) + .Select(argName => + { + var argNameSplit = argName.Split("#"); + var argValue = argDict[argNameSplit[1]].ToString(); + var fieldType = int.Parse(argNameSplit[0]); + var hexEncoder = new HexEncoder(); + byte[] argumentBytes; + switch (fieldType) + { + case 4: + argumentBytes = Encoders.ASCII.DecodeData(argValue); + break; + case 11: + // URL's should contain UInt128 as 16 bytes encoded in hexadecimal. + if (argValue.Length != 32) + return null; + + argumentBytes = hexEncoder.DecodeData(argValue); + break; + case 12: + // If the value contains a "." then its being passed as an amount. + // We assume 8 decimals for the conversion to UInt256. + if (argValue.Contains('.')) + { + decimal amt = decimal.Parse(argValue); + + // Convert to UInt256. + argumentBytes = ((UInt256)((ulong)(amt * 100000000 /* 8 decimals */))).ToBytes(); + } + else + { + // URL's should contain UInt256 as 32 bytes encoded in hexadecimal. + if (argValue.Length != 32) + return null; + + argumentBytes = hexEncoder.DecodeData(argValue); + } + break; + + // TODO: Handle more types as required. + + default: + return null; + } + return argumentBytes; + }) + .ToArray(); + + // Convert the list of objects to RLE. + return RLP.EncodeElementsAndList(arguments); + } + catch (Exception) + { + return null; + } + } + } +} diff --git a/src/Stratis.SCL/Stratis.SCL.csproj b/src/Stratis.SCL/Stratis.SCL.csproj new file mode 100644 index 0000000000..b17ad6f26d --- /dev/null +++ b/src/Stratis.SCL/Stratis.SCL.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + + 2.0.2.0 + 2.0.2.0 + 2.0.2.0 + Stratis Group Ltd. + Stratis.SCL + + + + + + + + + + + + + + diff --git a/src/Stratis.SmartContracts.CLR.Tests/ContractCompilerTests.cs b/src/Stratis.SmartContracts.CLR.Tests/ContractCompilerTests.cs index 5a30e83cc5..7976d4fadc 100644 --- a/src/Stratis.SmartContracts.CLR.Tests/ContractCompilerTests.cs +++ b/src/Stratis.SmartContracts.CLR.Tests/ContractCompilerTests.cs @@ -33,12 +33,13 @@ public void SmartContract_ReferenceResolver_HasCorrectAssemblies() { List allowedAssemblies = ReferencedAssemblyResolver.AllowedAssemblies.ToList(); - Assert.Equal(5, allowedAssemblies.Count); + Assert.Equal(6, allowedAssemblies.Count); Assert.Contains(allowedAssemblies, a => a.GetName().Name == "System.Runtime"); Assert.Contains(allowedAssemblies, a => a.GetName().Name == "System.Private.CoreLib"); Assert.Contains(allowedAssemblies, a => a.GetName().Name == "Stratis.SmartContracts"); Assert.Contains(allowedAssemblies, a => a.GetName().Name == "System.Linq"); Assert.Contains(allowedAssemblies, a => a.GetName().Name == "Stratis.SmartContracts.Standards"); + Assert.Contains(allowedAssemblies, a => a.GetName().Name == "Stratis.SCL"); } [Fact] diff --git a/src/Stratis.SmartContracts.CLR.Tests/ContractExecutorTests.cs b/src/Stratis.SmartContracts.CLR.Tests/ContractExecutorTests.cs index 19ae959cdd..bbcee578ac 100644 --- a/src/Stratis.SmartContracts.CLR.Tests/ContractExecutorTests.cs +++ b/src/Stratis.SmartContracts.CLR.Tests/ContractExecutorTests.cs @@ -333,6 +333,12 @@ public void Execute_MultipleIfElseBlocks_ExecutionSucceeds() AssertSuccessfulContractMethodExecution(nameof(MultipleIfElseBlocks), nameof(MultipleIfElseBlocks.PersistNormalizeValue), new object[] { "z" }); } + [Fact] + public void Execute_LibraryContract_ExecutionSucceeds() + { + AssertSuccessfulContractMethodExecution(nameof(LibraryTest), nameof(LibraryTest.Exists)); + } + private void AssertSuccessfulContractMethodExecution(string contractName, string methodName, object[] methodParameters = null, string expectedReturn = null) { var transactionValue = (Money)100; diff --git a/src/Stratis.SmartContracts.CLR.Tests/SmartContracts/LibraryTest.cs b/src/Stratis.SmartContracts.CLR.Tests/SmartContracts/LibraryTest.cs new file mode 100644 index 0000000000..9b48635de2 --- /dev/null +++ b/src/Stratis.SmartContracts.CLR.Tests/SmartContracts/LibraryTest.cs @@ -0,0 +1,16 @@ +using Stratis.SmartContracts; +using Base = Stratis.SCL.Base; + +[Deploy] +public class LibraryTest : SmartContract +{ + public LibraryTest(ISmartContractState state) : base(state) + { + Base.Operations.Noop(); + } + + public void Exists() + { + State.SetBool("Exists", true); + } +} diff --git a/src/Stratis.SmartContracts.CLR.Tests/Stratis.SmartContracts.CLR.Tests.csproj b/src/Stratis.SmartContracts.CLR.Tests/Stratis.SmartContracts.CLR.Tests.csproj index 6da53f9e67..1b6a8a40d5 100644 --- a/src/Stratis.SmartContracts.CLR.Tests/Stratis.SmartContracts.CLR.Tests.csproj +++ b/src/Stratis.SmartContracts.CLR.Tests/Stratis.SmartContracts.CLR.Tests.csproj @@ -74,6 +74,9 @@ Always + + Always + PreserveNewest diff --git a/src/Stratis.SmartContracts.CLR.Validation.Tests/SmartContractDeterminismValidatorTests.cs b/src/Stratis.SmartContracts.CLR.Validation.Tests/SmartContractDeterminismValidatorTests.cs index 5971b9029c..9ed64d361c 100644 --- a/src/Stratis.SmartContracts.CLR.Validation.Tests/SmartContractDeterminismValidatorTests.cs +++ b/src/Stratis.SmartContracts.CLR.Validation.Tests/SmartContractDeterminismValidatorTests.cs @@ -834,6 +834,44 @@ public Test(ISmartContractState state): base(state) Assert.False(result.IsValid); Assert.NotEmpty(result.Errors); Assert.True(result.Errors.All(e => e is ModuleDefinitionValidationResult)); - } + } + + [Fact] + public void SmartContractValidator_Allows_SCL() + { + var adjustedSource = @" +using Stratis.SmartContracts; +using Base = Stratis.SCL.Base; + +[Deploy] +public class LibraryTest : SmartContract +{ + public LibraryTest(ISmartContractState state) : base(state) + { + Base.Operations.Noop(); + } + + public void Exists() + { + State.SetBool(""Exists"", true); + } +}"; + ContractCompilationResult compilationResult = ContractCompiler.Compile(adjustedSource); + Assert.True(compilationResult.Success); + + byte[] assemblyBytes = compilationResult.Compilation; + IContractModuleDefinition decompilation = ContractDecompiler.GetModuleDefinition(assemblyBytes).Value; + + // Add a module reference + decompilation.ModuleDefinition.ModuleReferences.Add(new ModuleReference("Test.dll")); + + var moduleDefinition = decompilation.ModuleDefinition; + + SmartContractValidationResult result = new SmartContractValidator().Validate(moduleDefinition); + + Assert.False(result.IsValid); + Assert.NotEmpty(result.Errors); + Assert.True(result.Errors.All(e => e is ModuleDefinitionValidationResult)); + } } } \ No newline at end of file diff --git a/src/Stratis.SmartContracts.CLR/Compilation/ReferencedAssemblyResolver.cs b/src/Stratis.SmartContracts.CLR/Compilation/ReferencedAssemblyResolver.cs index 76a05075a0..ecf6621853 100644 --- a/src/Stratis.SmartContracts.CLR/Compilation/ReferencedAssemblyResolver.cs +++ b/src/Stratis.SmartContracts.CLR/Compilation/ReferencedAssemblyResolver.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using Stratis.SCL; using Stratis.SmartContracts.Standards; namespace Stratis.SmartContracts.CLR.Compilation @@ -23,7 +24,8 @@ public static class ReferencedAssemblyResolver Core, typeof(SmartContract).Assembly, typeof(Enumerable).Assembly, - typeof(IStandardToken).Assembly + typeof(IStandardToken).Assembly, + typeof(SCL.Base.Operations).Assembly }; } } \ No newline at end of file diff --git a/src/Stratis.SmartContracts.CLR/Stratis.SmartContracts.CLR.csproj b/src/Stratis.SmartContracts.CLR/Stratis.SmartContracts.CLR.csproj index cf292140e1..9e5c4e1f00 100644 --- a/src/Stratis.SmartContracts.CLR/Stratis.SmartContracts.CLR.csproj +++ b/src/Stratis.SmartContracts.CLR/Stratis.SmartContracts.CLR.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Stratis.SmartContracts.IntegrationTests/ECRecoverTests.cs b/src/Stratis.SmartContracts.IntegrationTests/ECRecoverTests.cs new file mode 100644 index 0000000000..28404a729f --- /dev/null +++ b/src/Stratis.SmartContracts.IntegrationTests/ECRecoverTests.cs @@ -0,0 +1,179 @@ +using System.Threading.Tasks; +using NBitcoin; +using Stratis.Bitcoin.Features.SmartContracts.Models; +using Stratis.SCL.Crypto; +using Stratis.SmartContracts.CLR; +using Stratis.SmartContracts.CLR.Compilation; +using Stratis.SmartContracts.CLR.Serialization; +using Stratis.SmartContracts.Core; +using Stratis.SmartContracts.Networks; +using Stratis.SmartContracts.Tests.Common.MockChain; +using Xunit; +using EcRecoverProvider = Stratis.SCL.Crypto.ECRecover; +using Key = NBitcoin.Key; + +namespace Stratis.SmartContracts.IntegrationTests +{ + public class ECRecoverTests + { + // 2 things to test: + + // 1) That we have the ECDSA code and can make it available. + + [Fact] + public void CanSignAndRetrieveSender() + { + var network = new SmartContractsRegTest(); + var privateKey = new Key(); + Address address = privateKey.PubKey.GetAddress(network).ToString().ToAddress(network); + byte[] message = new byte[] { 0x69, 0x76, 0xAA }; + + // Sign a message + byte[] offChainSignature = SignMessage(privateKey, message); + + // Get the address out of the signature + EcRecoverProvider.TryGetSigner(message, offChainSignature, out Address recoveredAddress); + + // Check that the address matches that generated from the private key. + Assert.Equal(address, recoveredAddress); + } + + [Fact] + public void GetSigner_Returns_Address_Zero_When_Message_Or_Signature_Null() + { + var network = new SmartContractsRegTest(); + var privateKey = new Key(); + Address address = privateKey.PubKey.GetAddress(network).ToString().ToAddress(network); + byte[] message = new byte[] { 0x69, 0x76, 0xAA }; + + // Sign a message + byte[] offChainSignature = SignMessage(privateKey, message); + + // Get the address out of the signature + Assert.False(EcRecoverProvider.TryGetSigner(null, offChainSignature, out Address recoveredAddress)); + + Assert.Equal(Address.Zero, recoveredAddress); + + Assert.False(EcRecoverProvider.TryGetSigner(message, null, out Address recoveredAddress2)); + + Assert.Equal(Address.Zero, recoveredAddress2); + } + + /// + /// Signs a message, returning an ECDSA signature. + /// + /// The private key used to sign the message. + /// The complete message to be signed. + /// The ECDSA signature prepended with header information specifying the correct value of recId. + private static byte[] SignMessage(Key privateKey, byte[] message) + { + uint256 hashedUint256 = new uint256(SHA3.Keccak256(message)); + + return privateKey.SignCompact(hashedUint256); + } + + [Fact] + public void CanCallEcRecoverContractWithValidSignatureAsync() + { + using (PoWMockChain chain = new PoWMockChain(2)) + { + var node1 = chain.Nodes[0]; + + node1.MineBlocks(1); + + var network = chain.Nodes[0].CoreNode.FullNode.Network; + + var privateKey = new Key(); + string address = privateKey.PubKey.GetAddress(network).ToString(); + byte[] message = new byte[] { 0x69, 0x76, 0xAA }; + byte[] signature = SignMessage(privateKey, message); + + // TODO: If the incorrect parameters are passed to the constructor, the contract does not get properly created ('Method does not exist on contract'), but a success response is still returned? + + byte[] contract = ContractCompiler.CompileFile("SmartContracts/EcRecoverContract.cs").Compilation; + string[] createParameters = new string[] { string.Format("{0}#{1}", (int)MethodParameterDataType.Address, address) }; + BuildCreateContractTransactionResponse createResult = node1.SendCreateContractTransaction(contract, 1, createParameters); + + Assert.NotNull(createResult); + Assert.True(createResult.Success); + + node1.WaitMempoolCount(1); + node1.MineBlocks(1); + + string[] callParameters = new string[] + { + string.Format("{0}#{1}", (int)MethodParameterDataType.ByteArray, message.ToHexString()), + string.Format("{0}#{1}", (int)MethodParameterDataType.ByteArray, signature.ToHexString()) + }; + + BuildCallContractTransactionResponse response = node1.SendCallContractTransaction("CheckThirdPartySignature", createResult.NewContractAddress, 1, callParameters); + Assert.NotNull(response); + Assert.True(response.Success); + + node1.WaitMempoolCount(1); + node1.MineBlocks(1); + + ReceiptResponse receipt = node1.GetReceipt(response.TransactionId.ToString()); + + Assert.NotNull(receipt); + Assert.True(receipt.Success); + Assert.Equal("True", receipt.ReturnValue); + } + } + + [Fact] + public void CanCallEcRecoverContractWithInvalidSignatureAsync() + { + using (PoWMockChain chain = new PoWMockChain(2)) + { + var node1 = chain.Nodes[0]; + + node1.MineBlocks(1); + + var network = chain.Nodes[0].CoreNode.FullNode.Network; + + var privateKey = new Key(); + string address = privateKey.PubKey.GetAddress(network).ToString(); + byte[] message = new byte[] { 0x69, 0x76, 0xAA }; + + // Make the signature with a key unrelated to the third party signer for the contract. + byte[] signature = SignMessage(new Key(), message); + + // TODO: If the incorrect parameters are passed to the constructor, the contract does not get properly created ('Method does not exist on contract'), but a success response is still returned? + + byte[] contract = ContractCompiler.CompileFile("SmartContracts/EcRecoverContract.cs").Compilation; + string[] createParameters = new string[] { string.Format("{0}#{1}", (int)MethodParameterDataType.Address, address) }; + BuildCreateContractTransactionResponse createResult = node1.SendCreateContractTransaction(contract, 1, createParameters); + + Assert.NotNull(createResult); + Assert.True(createResult.Success); + + node1.WaitMempoolCount(1); + node1.MineBlocks(1); + + string[] callParameters = new string[] + { + string.Format("{0}#{1}", (int)MethodParameterDataType.ByteArray, message.ToHexString()), + string.Format("{0}#{1}", (int)MethodParameterDataType.ByteArray, signature.ToHexString()) + }; + + BuildCallContractTransactionResponse response = node1.SendCallContractTransaction("CheckThirdPartySignature", createResult.NewContractAddress, 1, callParameters); + Assert.NotNull(response); + Assert.True(response.Success); + + node1.WaitMempoolCount(1); + node1.MineBlocks(1); + + ReceiptResponse receipt = node1.GetReceipt(response.TransactionId.ToString()); + + Assert.NotNull(receipt); + Assert.True(receipt.Success); + Assert.Equal("False", receipt.ReturnValue); + } + } + + // 2) That we can enable the method in new contracts without affecting the older contracts + + // TODO + } +} \ No newline at end of file diff --git a/src/Stratis.SmartContracts.IntegrationTests/SmartContracts/EcRecoverContract.cs b/src/Stratis.SmartContracts.IntegrationTests/SmartContracts/EcRecoverContract.cs new file mode 100644 index 0000000000..2e40d6ef2f --- /dev/null +++ b/src/Stratis.SmartContracts.IntegrationTests/SmartContracts/EcRecoverContract.cs @@ -0,0 +1,28 @@ +using Stratis.SmartContracts; +using EcRecover = Stratis.SCL.Crypto.ECRecover; + +public class EcRecoverContract : SmartContract +{ + public Address ThirdPartySigner + { + get + { + return this.State.GetAddress(nameof(this.ThirdPartySigner)); + } + set + { + this.State.SetAddress(nameof(this.ThirdPartySigner), value); + } + } + + public EcRecoverContract(ISmartContractState state, Address thirdPartySigner) : base(state) + { + this.ThirdPartySigner = thirdPartySigner; + } + + public bool CheckThirdPartySignature(byte[] message, byte[] signature) + { + EcRecover.TryGetSigner(message, signature, out Address signerOfMessage); + return (signerOfMessage == this.ThirdPartySigner); + } +} \ No newline at end of file diff --git a/src/Stratis.SmartContracts.IntegrationTests/Stratis.SmartContracts.IntegrationTests.csproj b/src/Stratis.SmartContracts.IntegrationTests/Stratis.SmartContracts.IntegrationTests.csproj index 9571e113b8..81e45e9e43 100644 --- a/src/Stratis.SmartContracts.IntegrationTests/Stratis.SmartContracts.IntegrationTests.csproj +++ b/src/Stratis.SmartContracts.IntegrationTests/Stratis.SmartContracts.IntegrationTests.csproj @@ -93,6 +93,9 @@ PreserveNewest + + Always + PreserveNewest