diff --git a/src/Stratis.Feature.PoA.Tokenless.Tests/EndorsedTransactionBuilderTests.cs b/src/Stratis.Feature.PoA.Tokenless.Tests/EndorsedTransactionBuilderTests.cs new file mode 100644 index 00000000000..ab91f14f976 --- /dev/null +++ b/src/Stratis.Feature.PoA.Tokenless.Tests/EndorsedTransactionBuilderTests.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Moq; +using NBitcoin; +using Newtonsoft.Json; +using Stratis.Feature.PoA.Tokenless.Consensus; +using Stratis.Feature.PoA.Tokenless.Endorsement; +using Stratis.SmartContracts.CLR; +using Stratis.SmartContracts.Core; +using Stratis.SmartContracts.Core.ReadWrite; +using Stratis.SmartContracts.Core.Util; +using Xunit; + +namespace Stratis.Feature.PoA.Tokenless.Tests +{ + public class EndorsedTransactionBuilderTests + { + private readonly List proposalResponses; + private readonly Key key; + private readonly ProposalResponse response; + private readonly TokenlessNetwork network; + private readonly TokenlessSigner signer; + private readonly Key transactionSigningKey; + + public EndorsedTransactionBuilderTests() + { + this.network = new TokenlessNetwork(); + this.signer = new TokenlessSigner(this.network, new SenderRetriever()); + + this.key = new Key(); + this.transactionSigningKey = new Key(); + + this.response = new ProposalResponse + { + ReadWriteSet = new ReadWriteSet + { + Reads = new List + { + new ReadItem {ContractAddress = uint160.One, Key = new byte[] {0xAA}, Version = "1"} + }, + Writes = new List + { + new WriteItem + { + ContractAddress = uint160.One, IsPrivateData = false, Key = new byte[] {0xBB}, + Value = new byte[] {0xCC} + } + } + } + }; + + this.proposalResponses = new List + { + new SignedProposalResponse + { + ProposalResponse = response, + Endorsement = new Endorsement.Endorsement(this.key.Sign(response.GetHash()).ToDER(), this.key.PubKey.ToBytes()) + } + }; + } + + [Fact] + public void Proposer_Signs_Transaction() + { + var endorsementSigner = new Mock(); + + var builder = new EndorsedTransactionBuilder(endorsementSigner.Object); + + Transaction tx = builder.Build(this.proposalResponses); + + // We don't need to verify the tx here, the endorsement signer tests take care of that already. + endorsementSigner.Verify(s => s.Sign(tx), Times.Once); + } + + [Fact] + public void First_Output_Uses_OpReadWrite() + { + var endorsementSigner = new Mock(); + + var builder = new EndorsedTransactionBuilder(endorsementSigner.Object); + + Transaction tx = builder.Build(this.proposalResponses); + + Assert.True(tx.Outputs[0].ScriptPubKey.IsReadWriteSet()); + } + + [Fact] + public void Second_Output_Is_Endorsement() + { + var endorsementSigner = new Mock(); + + var builder = new EndorsedTransactionBuilder(endorsementSigner.Object); + + Transaction tx = builder.Build(this.proposalResponses); + + Assert.True(tx.Outputs.Count > 1); + + var endorsementData = tx.Outputs[1].ScriptPubKey.ToBytes(); + + var endorsement = Endorsement.Endorsement.FromBytes(endorsementData); + + Assert.NotNull(endorsement); + Assert.True(endorsement.Signature.SequenceEqual(this.proposalResponses[0].Endorsement.Signature)); + Assert.True(endorsement.PubKey.SequenceEqual(this.proposalResponses[0].Endorsement.PubKey)); + } + + [Fact] + public void First_Output_Contains_Correct_Data() + { + var endorsementSigner = new Mock(); + + var builder = new EndorsedTransactionBuilder(endorsementSigner.Object); + + Transaction tx = builder.Build(this.proposalResponses); + + // Expect the data to include the generated RWS, and endorsements + // First op should be OP_READWRITE, second op should be raw data + var rwsData = tx.Outputs[0].ScriptPubKey.ToBytes().Skip(1).ToArray(); + + var rws = ReadWriteSet.FromJsonEncodedBytes(rwsData); + + Assert.NotNull(rws); + Assert.True(rwsData.SequenceEqual(this.proposalResponses[0].ProposalResponse.ReadWriteSet.ToJsonEncodedBytes())); + } + } +} diff --git a/src/Stratis.Feature.PoA.Tokenless.Tests/EndorsementInfoTests.cs b/src/Stratis.Feature.PoA.Tokenless.Tests/EndorsementInfoTests.cs index 3735294d07c..8fe2af6c2f6 100644 --- a/src/Stratis.Feature.PoA.Tokenless.Tests/EndorsementInfoTests.cs +++ b/src/Stratis.Feature.PoA.Tokenless.Tests/EndorsementInfoTests.cs @@ -164,6 +164,7 @@ public void MofNPolicyValidator_Validates_One_Signature_One_Organisation_Correct validator.AddSignature(org, "test"); Assert.True(validator.Valid); + Assert.Single(validator.GetValidAddresses()); } [Fact] @@ -184,6 +185,8 @@ public void MofNPolicyValidator_Validates_Multiple_Signatures_One_Organisation_C validator.AddSignature(org, "test2"); Assert.True(validator.Valid); + Assert.Equal("test", validator.GetValidAddresses()[0]); + Assert.Equal("test2", validator.GetValidAddresses()[1]); } [Fact] @@ -214,6 +217,35 @@ public void MofNPolicyValidator_Validates_Multiple_Signatures_Multiple_Organisat Assert.True(validator.Valid); } + [Fact] + public void MofNPolicyValidator_Returns_Valid_Addresses_Correctly() + { + var org = (Organisation)"Test"; + var org2 = (Organisation)"Test2"; + var policy = new Dictionary + { + { org, 2 }, + }; + + var validator = new MofNPolicyValidator(policy); + + // Add org 2 signatures - they don't contribute to the policy being valid + validator.AddSignature(org2, "test2 2"); + validator.AddSignature(org2, "test2 3"); + validator.AddSignature(org2, "test2 4"); + Assert.False(validator.Valid); + + // Add org 1 signatures + validator.AddSignature(org, "test"); + validator.AddSignature(org, "test 2"); + + Assert.True(validator.Valid); + + // Ensure only org 1 addresses are returned + Assert.Equal("test", validator.GetValidAddresses()[0]); + Assert.Equal("test 2", validator.GetValidAddresses()[1]); + } + [Fact] public void Signed_Proposal_Response_Roundtrip() diff --git a/src/Stratis.Feature.PoA.Tokenless/Consensus/Rules/IsSmartContractWellFormedPartialValidationRule.cs b/src/Stratis.Feature.PoA.Tokenless/Consensus/Rules/IsSmartContractWellFormedPartialValidationRule.cs index 80ab238898c..29a5d9f1f8e 100644 --- a/src/Stratis.Feature.PoA.Tokenless/Consensus/Rules/IsSmartContractWellFormedPartialValidationRule.cs +++ b/src/Stratis.Feature.PoA.Tokenless/Consensus/Rules/IsSmartContractWellFormedPartialValidationRule.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Linq; +using System.Threading.Tasks; using NBitcoin; using Stratis.Bitcoin.Consensus; using Stratis.Bitcoin.Consensus.Rules; @@ -15,23 +16,45 @@ public override Task RunAsync(RuleContext context) { foreach (Transaction transaction in context.ValidationContext.BlockToValidate.Transactions) { - for(var i = 0; i < transaction.Outputs.Count; i++) - { - var output = transaction.Outputs[i]; - if (!output.ScriptPubKey.IsReadWriteSet()) - continue; + if (transaction.Outputs.Count == 0) + continue; + + var output = transaction.Outputs[0]; + if (!output.ScriptPubKey.IsReadWriteSet()) + continue; - // No more outputs to check. - if (i + 1 == transaction.Outputs.Count) - new ConsensusError("badly-formed-rws", "An OP_READWRITE must be followed by an endorsement").Throw(); + // Must have 2 or more outputs if OP_RWS + if(transaction.Outputs.Count < 2) + new ConsensusError("badly-formed-rws", "An OP_READWRITE must be followed by an endorsement").Throw(); - // TODO check that i + 1 is a signature format + for (var i = 1; i < transaction.Outputs.Count; i++) + { + // Validate endorsement format + if(!ValidateEndorsement(transaction.Outputs[i].ScriptPubKey.ToBytes())) + { + new ConsensusError("badly-formed-endorsement", "Endorsement was not in the correct format.").Throw(); + } } } return Task.CompletedTask; } + + private static bool ValidateEndorsement(byte[] toBytes) + { + try + { + Endorsement.Endorsement.FromBytes(toBytes); + + return true; + } + catch + { + return false; + } + } } + /// /// Checks that smart contract transactions are in a valid format and the data is serialized correctly. /// diff --git a/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsedTransactionBuilder.cs b/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsedTransactionBuilder.cs new file mode 100644 index 00000000000..c5fe199b3a1 --- /dev/null +++ b/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsedTransactionBuilder.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NBitcoin; +using Stratis.Feature.PoA.Tokenless.Consensus; +using Stratis.SmartContracts.Core.ReadWrite; + +namespace Stratis.Feature.PoA.Tokenless.Endorsement +{ + public interface IEndorsedTransactionBuilder + { + Transaction Build(IReadOnlyList proposalResponses); + } + + /// + /// Builds a transaction that has satisfied an endorsement policy. + /// + public class EndorsedTransactionBuilder : IEndorsedTransactionBuilder + { + private readonly IEndorsementSigner endorsementSigner; + + public EndorsedTransactionBuilder(IEndorsementSigner endorsementSigner) + { + this.endorsementSigner = endorsementSigner; + } + + public Transaction Build(IReadOnlyList proposalResponses) + { + if (!ValidateProposalResponses(proposalResponses)) + return null; + + var transaction = new Transaction(); + + // Endorsement signer uses the same logic to sign the first input with the transaction signing key. + this.endorsementSigner.Sign(transaction); + + // TODO at the moment this is the full RWS. We should check that only the public RWS is signed and returned by the endorser. + AddReadWriteSet(transaction, proposalResponses); + AddEndorsements(transaction, proposalResponses.Select(p => p.Endorsement)); + + return transaction; + } + + private static void AddEndorsements(Transaction transaction, IEnumerable endorsements) + { + foreach(Endorsement endorsement in endorsements) + { + transaction.Outputs.Add(new TxOut(Money.Zero, new Script(endorsement.ToJson()))); + } + } + + private static void AddReadWriteSet(Transaction transaction, IEnumerable proposalResponses) + { + // We can pick any RWS here as they should all be the same + ReadWriteSet rws = GetReadWriteSet(proposalResponses); + + Script rwsScriptPubKey = TxReadWriteDataTemplate.Instance.GenerateScriptPubKey(rws.ToJsonEncodedBytes()); + + transaction.Outputs.Add(new TxOut(Money.Zero, rwsScriptPubKey)); + } + + private static bool ValidateProposalResponses(IReadOnlyList proposalResponses) + { + // Nothing to compare against. + if (proposalResponses.Count < 2) return true; + + var serializedProposalResponses = proposalResponses + .Select(r => r.ProposalResponse.ToBytes()) + .ToList(); + + // All elements should be the same. + return serializedProposalResponses.All(b => b.SequenceEqual(serializedProposalResponses[0])); + } + + private static ReadWriteSet GetReadWriteSet(IEnumerable proposalResponses) + { + return proposalResponses.First().ProposalResponse.ReadWriteSet; + } + } +} diff --git a/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsementInfo.cs b/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsementInfo.cs index 11fd81d8f13..f0aa1e23346 100644 --- a/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsementInfo.cs +++ b/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsementInfo.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using CertificateAuthority; using MembershipServices; using NBitcoin; @@ -14,6 +15,7 @@ public class EndorsementInfo private readonly ICertificatePermissionsChecker permissionsChecker; private readonly Network network; private readonly MofNPolicyValidator validator; + private readonly Dictionary signedProposals; /// /// A basic policy definining a minimum number of endorsement signatures required for an organisation. @@ -29,6 +31,11 @@ public EndorsementInfo(Dictionary policy, IOrganisationLookup this.network = network; this.Policy = policy; this.validator = new MofNPolicyValidator(this.Policy); + + // To prevent returning proposals that were signed correctly but do not match the policy, + // we should keep track of signed proposals from all addresses and filter them by the + // valid addresses in the policy. + this.signedProposals = new Dictionary(); } public bool AddSignature(X509Certificate certificate, SignedProposalResponse signedProposalResponse) @@ -49,9 +56,20 @@ public bool AddSignature(X509Certificate certificate, SignedProposalResponse sig AddSignature(org, sender); + this.signedProposals[sender] = signedProposalResponse; + return true; } + public IReadOnlyList GetValidProposalResponses() + { + // Returns signed proposal responses that match current proposals returned by addresses that meet the policy, + return this.validator.GetValidAddresses() + .Where(a => this.signedProposals.ContainsKey(a)) + .Select(a => this.signedProposals[a]) + .ToList(); + } + public void AddSignature(Organisation org, string address) { this.validator.AddSignature(org, address); diff --git a/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsementSuccessHandler.cs b/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsementSuccessHandler.cs index 6ad96e1fc16..6e982536591 100644 --- a/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsementSuccessHandler.cs +++ b/src/Stratis.Feature.PoA.Tokenless/Endorsement/EndorsementSuccessHandler.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; using NBitcoin; using Org.BouncyCastle.X509; using Stratis.Bitcoin.P2P.Peer; @@ -18,11 +19,13 @@ public class EndorsementSuccessHandler : IEndorsementSuccessHandler { private readonly IBroadcasterManager broadcasterManager; private readonly IEndorsements endorsements; + private readonly IEndorsedTransactionBuilder endorsedTransactionBuilder; - public EndorsementSuccessHandler(IBroadcasterManager broadcasterManager, IEndorsements endorsements) + public EndorsementSuccessHandler(IBroadcasterManager broadcasterManager, IEndorsements endorsements, IEndorsedTransactionBuilder endorsedTransactionBuilder) { this.broadcasterManager = broadcasterManager; this.endorsements = endorsements; + this.endorsedTransactionBuilder = endorsedTransactionBuilder; } public async Task ProcessEndorsementAsync(uint256 proposalId, SignedProposalResponse signedProposalResponse, INetworkPeer peer) @@ -37,12 +40,14 @@ public async Task ProcessEndorsementAsync(uint256 proposalId, SignedPropos if (!info.AddSignature(certificate, signedProposalResponse)) return false; - // TODO: Recruit multiple endorsements before broadcasting the transactions. // If the policy has been satisfied, this will return true and we can broadcast the signed transaction. if (info.Validate()) { - // TODO build the endorsed transaction with the txins of all the endorsers. - //await this.broadcasterManager.BroadcastTransactionAsync(finalTransactionWithEndorsements); + IReadOnlyList validProposalResponses = info.GetValidProposalResponses(); + + Transaction endorsedTx = this.endorsedTransactionBuilder.Build(validProposalResponses); + + await this.broadcasterManager.BroadcastTransactionAsync(endorsedTx); return true; } diff --git a/src/Stratis.Feature.PoA.Tokenless/Endorsement/MofNPolicyValidator.cs b/src/Stratis.Feature.PoA.Tokenless/Endorsement/MofNPolicyValidator.cs index 474ce853670..a9264c32135 100644 --- a/src/Stratis.Feature.PoA.Tokenless/Endorsement/MofNPolicyValidator.cs +++ b/src/Stratis.Feature.PoA.Tokenless/Endorsement/MofNPolicyValidator.cs @@ -51,6 +51,23 @@ private int GetUniqueSignatureCount(Organisation org) return this.policyValidationState[org].Count; } + /// + /// Returns addresses that match the validation policy. + /// + /// + public IReadOnlyList GetValidAddresses() + { + var result = new List(); + + foreach ((Organisation org, int _) in this.policy) + { + if(this.policyValidationState.ContainsKey(org)) + result.AddRange(this.policyValidationState[org]); + } + + return result; + } + public bool Valid { get diff --git a/src/Stratis.Feature.PoA.Tokenless/Endorsement/ProposalResponse.cs b/src/Stratis.Feature.PoA.Tokenless/Endorsement/ProposalResponse.cs index 4c351860058..ed1502e9ab5 100644 --- a/src/Stratis.Feature.PoA.Tokenless/Endorsement/ProposalResponse.cs +++ b/src/Stratis.Feature.PoA.Tokenless/Endorsement/ProposalResponse.cs @@ -42,6 +42,16 @@ public Endorsement(byte[] signature, byte[] pubKey) public byte[] Signature { get; } public byte[] PubKey { get; } + + public byte[] ToJson() + { + return Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(this)); + } + + public static Endorsement FromBytes(byte[] data) + { + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(data)); + } } public class ProposalResponse diff --git a/src/Stratis.Feature.PoA.Tokenless/TokenlessFeatureRegistration.cs b/src/Stratis.Feature.PoA.Tokenless/TokenlessFeatureRegistration.cs index 571c30b3757..f30ede464e5 100644 --- a/src/Stratis.Feature.PoA.Tokenless/TokenlessFeatureRegistration.cs +++ b/src/Stratis.Feature.PoA.Tokenless/TokenlessFeatureRegistration.cs @@ -62,6 +62,7 @@ public static IFullNodeBuilder AsTokenlessNetwork(this IFullNodeBuilder fullNode services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton();