diff --git a/src/Stratis.FederatedPeg.Features.FederationGateway/Controllers/FederationGatewayController.cs b/src/Stratis.FederatedPeg.Features.FederationGateway/Controllers/FederationGatewayController.cs index b6f50e36..883ab1c4 100644 --- a/src/Stratis.FederatedPeg.Features.FederationGateway/Controllers/FederationGatewayController.cs +++ b/src/Stratis.FederatedPeg.Features.FederationGateway/Controllers/FederationGatewayController.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; -using NBitcoin; using Stratis.Bitcoin.Utilities; using Stratis.Bitcoin.Utilities.JsonErrors; using Stratis.FederatedPeg.Features.FederationGateway.Interfaces; @@ -19,6 +18,7 @@ public static class FederationGatewayRouteEndPoint public const string PushMaturedBlocks = "push_matured_blocks"; public const string PushCurrentBlockTip = "push_current_block_tip"; public const string GetMaturedBlockDeposits = "get_matured_block_deposits"; + public const string AuthorizeWithdrawals = "authorize_withdrawals"; // TODO commented out since those constants are unused. Remove them later or start using. //public const string CreateSessionOnCounterChain = "create-session-oncounterchain"; @@ -42,18 +42,22 @@ public class FederationGatewayController : Controller private readonly ILeaderReceiver leaderReceiver; + private readonly ISignatureProvider signatureProvider; + public FederationGatewayController( ILoggerFactory loggerFactory, IMaturedBlockReceiver maturedBlockReceiver, ILeaderProvider leaderProvider, IMaturedBlocksProvider maturedBlocksProvider, - ILeaderReceiver leaderReceiver) + ILeaderReceiver leaderReceiver, + ISignatureProvider signatureProvider) { this.logger = loggerFactory.CreateLogger(this.GetType().FullName); this.maturedBlockReceiver = maturedBlockReceiver; this.leaderProvider = leaderProvider; this.maturedBlocksProvider = maturedBlocksProvider; this.leaderReceiver = leaderReceiver; + this.signatureProvider = signatureProvider; } [Route(FederationGatewayRouteEndPoint.PushMaturedBlocks)] @@ -92,6 +96,36 @@ public IActionResult PushCurrentBlockTip([FromBody] BlockTipModel blockTip) } } + /// + /// Authorizes withdrawals. + /// + /// A structure containing one or more transactions to authorize. + /// An array containing one or more signed transaction or null for transaction that could not be authorized. + [Route(FederationGatewayRouteEndPoint.AuthorizeWithdrawals)] + [HttpPost] + public IActionResult AuthorizeWithdrawals([FromBody] AuthorizeWithdrawalsModel authRequest) + { + Guard.NotNull(authRequest, nameof(authRequest)); + + if (!this.ModelState.IsValid) + { + IEnumerable errors = this.ModelState.Values.SelectMany(e => e.Errors.Select(m => m.ErrorMessage)); + return ErrorHelpers.BuildErrorResponse(HttpStatusCode.BadRequest, "Formatting error", string.Join(Environment.NewLine, errors)); + } + + try + { + string result = this.signatureProvider.SignTransaction(authRequest.TransactionHex); + + return this.Json(result); + } + catch (Exception e) + { + this.logger.LogTrace("Exception thrown calling /api/FederationGateway/{0}: {1}.", FederationGatewayRouteEndPoint.AuthorizeWithdrawals, e.Message); + return ErrorHelpers.BuildErrorResponse(HttpStatusCode.BadRequest, $"Could not authorize withdrawals: {e.Message}", e.ToString()); + } + } + /// /// Retrieves blocks deposits. /// diff --git a/src/Stratis.FederatedPeg.Features.FederationGateway/FederationGatewayFeature.cs b/src/Stratis.FederatedPeg.Features.FederationGateway/FederationGatewayFeature.cs index e7f5748a..0686c86e 100644 --- a/src/Stratis.FederatedPeg.Features.FederationGateway/FederationGatewayFeature.cs +++ b/src/Stratis.FederatedPeg.Features.FederationGateway/FederationGatewayFeature.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -267,6 +266,7 @@ public static IFullNodeBuilder AddFederationGateway(this IFullNodeBuilder fullNo services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/IAuthorizeWithdrawalsModel.cs b/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/IAuthorizeWithdrawalsModel.cs new file mode 100644 index 00000000..5a62758c --- /dev/null +++ b/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/IAuthorizeWithdrawalsModel.cs @@ -0,0 +1,7 @@ +namespace Stratis.FederatedPeg.Features.FederationGateway.Interfaces +{ + public interface IAuthorizeWithdrawalsModel + { + string TransactionHex { get; } + } +} diff --git a/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/IFederationWalletManager.cs b/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/IFederationWalletManager.cs index 613aa7a6..be2c7b44 100644 --- a/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/IFederationWalletManager.cs +++ b/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/IFederationWalletManager.cs @@ -131,6 +131,15 @@ public interface IFederationWalletManager /// -1 if the occurs first and 1 otherwise. int CompareOutpoints(OutPoint outPoint1, OutPoint outPoint2); + /// + /// Signs a transaction if it is valid. + /// + /// The transaction. + /// A function that determines the validity of the withdrawal. + /// The key to use. + /// True if the withdrawal is valid and false otherwise. + Transaction SignTransaction(Transaction transaction, Func isValid, Key key); + /// /// Determines if federation has been activated. /// diff --git a/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/ISignatureProvider.cs b/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/ISignatureProvider.cs new file mode 100644 index 00000000..448a0115 --- /dev/null +++ b/src/Stratis.FederatedPeg.Features.FederationGateway/Interfaces/ISignatureProvider.cs @@ -0,0 +1,19 @@ +namespace Stratis.FederatedPeg.Features.FederationGateway.Interfaces +{ + public interface ISignatureProvider + { + /// + /// Signs an externally provided withdrawal transaction if it is deemed valid. This method + /// is used to sign a transaction in response to signature requests from the federation + /// leader. + /// + /// + /// This method requires federation to be active as the wallet password is supplied during + /// activation. Transaction's are validated to ensure that they are expected as per + /// the deposits received on the source chain. + /// + /// The hexadecimal representations of transactions to sign. + /// An array of signed transactions (in hex) or null for transactions that can't be signed. + string SignTransaction(string transactionHex); + } +} diff --git a/src/Stratis.FederatedPeg.Features.FederationGateway/Models/AuthorizeWithdrawalsModel.cs b/src/Stratis.FederatedPeg.Features.FederationGateway/Models/AuthorizeWithdrawalsModel.cs new file mode 100644 index 00000000..94dcd485 --- /dev/null +++ b/src/Stratis.FederatedPeg.Features.FederationGateway/Models/AuthorizeWithdrawalsModel.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Stratis.Bitcoin.Features.Wallet.Models; +using Stratis.FederatedPeg.Features.FederationGateway.Interfaces; + +namespace Stratis.FederatedPeg.Features.FederationGateway.Models +{ + /// + /// An instance of this class represents a particular block hash and associated height on the source chain. + /// + public class AuthorizeWithdrawalsModel : RequestModel, IAuthorizeWithdrawalsModel + { + [Required(ErrorMessage = "The transaction to authorize")] + public string TransactionHex { get; set; } + } +} diff --git a/src/Stratis.FederatedPeg.Features.FederationGateway/TargetChain/SignatureProvider.cs b/src/Stratis.FederatedPeg.Features.FederationGateway/TargetChain/SignatureProvider.cs new file mode 100644 index 00000000..91817fdf --- /dev/null +++ b/src/Stratis.FederatedPeg.Features.FederationGateway/TargetChain/SignatureProvider.cs @@ -0,0 +1,105 @@ +using System.Linq; +using Microsoft.Extensions.Logging; +using NBitcoin; +using Stratis.Bitcoin.Utilities; +using Stratis.FederatedPeg.Features.FederationGateway.Interfaces; +using Stratis.FederatedPeg.Features.FederationGateway.Wallet; + +namespace Stratis.FederatedPeg.Features.FederationGateway.TargetChain +{ + /// + /// The purpose of this class is to sign externally provided withdrawal transactions if they are + /// deemed valid. Transactions would typically be signed in response to signature requests from + /// the federation leader. The federation is required to be active as the wallet password is + /// supplied during activation. Transaction's are validated to ensure that they are expected as + /// per the deposits received on the source chain. Out-of-sequence transactions or transactions + /// that are not utilising the expected UTXOs will not be signed. + /// + public class SignatureProvider: ISignatureProvider + { + private readonly IFederationWalletManager federationWalletManager; + private readonly ICrossChainTransferStore crossChainTransferStore; + private readonly IFederationGatewaySettings federationGatewaySettings; + private readonly Network network; + private readonly ILogger logger; + + public SignatureProvider( + IFederationWalletManager federationWalletManager, + ICrossChainTransferStore crossChainTransferStore, + IFederationGatewaySettings federationGatewaySettings, + Network network, + ILoggerFactory loggerFactory) + { + Guard.NotNull(federationWalletManager, nameof(federationWalletManager)); + Guard.NotNull(crossChainTransferStore, nameof(crossChainTransferStore)); + Guard.NotNull(federationGatewaySettings, nameof(federationGatewaySettings)); + Guard.NotNull(network, nameof(network)); + Guard.NotNull(loggerFactory, nameof(loggerFactory)); + + this.logger = loggerFactory.CreateLogger(this.GetType().FullName); + this.federationWalletManager = federationWalletManager; + this.federationGatewaySettings = federationGatewaySettings; + this.crossChainTransferStore = crossChainTransferStore; + this.network = network; + } + + /// + /// Determines if a withdrawal transaction can be authorized. + /// + /// The transaction to authorize. + /// The withdrawal transaction already extracted from the transaction. + /// True if the withdrawal is valid and false otherwise. + private bool IsAuthorized(Transaction transaction, IWithdrawal withdrawal) + { + // It must be a transfer that we know about. + ICrossChainTransfer crossChainTransfer = this.crossChainTransferStore.GetAsync(new[] { withdrawal.DepositId }).GetAwaiter().GetResult().FirstOrDefault(); + if (crossChainTransfer == null) + return false; + + // If its already been seen in a block then we probably should not authorize it. + if (crossChainTransfer.Status == CrossChainTransferStatus.SeenInBlock) + return false; + + // The templates must match what we expect to see. + if (!CrossChainTransfer.TemplatesMatch(crossChainTransfer.PartialTransaction, transaction)) + return false; + + return true; + } + + private Transaction SignTransaction(Transaction transaction, Key key) + { + return this.federationWalletManager.SignTransaction(transaction, IsAuthorized, key); + } + + /// + public string SignTransaction(string transactionHex) + { + Guard.NotNull(transactionHex, nameof(transactionHex)); + + this.logger.LogTrace("():{0}", transactionHex); + + FederationWallet wallet = this.federationWalletManager.GetWallet(); + if (wallet == null || this.federationWalletManager.Secret == null) + { + this.logger.LogTrace("(-)[FEDERATION_INACTIVE]"); + return null; + } + + Key key = wallet.MultiSigAddress.GetPrivateKey(wallet.EncryptedSeed, this.federationWalletManager.Secret.WalletPassword, this.network); + if (key.PubKey.ToHex() != this.federationGatewaySettings.PublicKey) + { + this.logger.LogTrace("(-)[FEDERATION_KEY_INVALID]"); + return null; + } + + Transaction transaction = this.network.CreateTransaction(transactionHex); + + transactionHex = SignTransaction(transaction, key)?.ToHex(this.network); + + this.logger.LogTrace("(-):{0}", transactionHex); + + return transactionHex; + } + } +} diff --git a/src/Stratis.FederatedPeg.Features.FederationGateway/Wallet/FederationWalletManager.cs b/src/Stratis.FederatedPeg.Features.FederationGateway/Wallet/FederationWalletManager.cs index b6502d6d..91b40eef 100644 --- a/src/Stratis.FederatedPeg.Features.FederationGateway/Wallet/FederationWalletManager.cs +++ b/src/Stratis.FederatedPeg.Features.FederationGateway/Wallet/FederationWalletManager.cs @@ -910,6 +910,64 @@ public bool ValidateTransaction(Transaction transaction, bool checkSignature = f } } + /// + public Transaction SignTransaction(Transaction externalTransaction, Func isValid, Key key) + { + // TODO: Check that the transaction is spending exactly the expected UTXO(s). + // TODO: Check that the transaction is serving the next expected UTXO(s). + + Guard.NotNull(externalTransaction, nameof(externalTransaction)); + Guard.NotNull(isValid, nameof(isValid)); + + this.logger.LogTrace("({0}:'{1}')", nameof(externalTransaction), externalTransaction.ToHex(this.network)); + + IWithdrawal withdrawal = this.withdrawalExtractor.ExtractWithdrawalFromTransaction(externalTransaction, 0, 0); + if (withdrawal == null) + { + this.logger.LogTrace("(-)[NOT_WITHDRAWAL]"); + return null; + } + + // Checks that the deposit id in the transaction is associated with a valid transfer. + if (!isValid(externalTransaction, withdrawal)) + { + this.logger.LogTrace("(-)[INVALID_WITHDRAWAL]"); + return null; + } + + var coins = new List(); + foreach (TxIn input in externalTransaction.Inputs) + { + TransactionData transactionData = this.outpointLookup[input.PrevOut]; + if (transactionData == null) + { + this.logger.LogTrace("(-)[INVALID_UTXOS]"); + return null; + } + coins.Add(new Coin(transactionData.Id, (uint)transactionData.Index, transactionData.Amount, transactionData.ScriptPubKey)); + } + + Transaction signedTransaction = null; + try + { + var builder = new TransactionBuilder(this.network); + signedTransaction = builder + .AddKeys(key) + .AddCoins(coins) + .SignTransactionInPlace(externalTransaction); + } + catch (Exception ex) + { + this.logger.LogTrace("Exception occurred: {0}", ex.ToString()); + this.logger.LogTrace("(-)[COULD_NOT_SIGN]"); + return null; + } + + this.logger.LogTrace("(-):{0}", signedTransaction?.ToHex(this.network)); + + return signedTransaction; + } + /// public bool IsFederationActive() { diff --git a/src/Stratis.FederatedPeg.Tests/CrossChainTestBase.cs b/src/Stratis.FederatedPeg.Tests/CrossChainTestBase.cs index 6508f023..993514c9 100644 --- a/src/Stratis.FederatedPeg.Tests/CrossChainTestBase.cs +++ b/src/Stratis.FederatedPeg.Tests/CrossChainTestBase.cs @@ -62,9 +62,13 @@ protected Script redeemScript /// /// Initializes the cross-chain transfer tests. /// - public CrossChainTestBase() + public CrossChainTestBase() : this(FederatedPegNetwork.NetworksSelector.Regtest()) { - this.network = FederatedPegNetwork.NetworksSelector.Regtest(); + } + + public CrossChainTestBase(Network network) + { + this.network = network; NetworkRegistration.Register(this.network); var serializer = new DBreezeSerializer(); @@ -222,9 +226,6 @@ protected void AppendBlocks(int blocks) /// The last chained header. protected ChainedHeader AppendBlock(params Transaction[] transactions) { - ChainedHeader last = null; - uint nonce = RandomUtils.GetUInt32(); - Block block = this.network.CreateBlock(); // Create coinbase. @@ -238,9 +239,16 @@ protected ChainedHeader AppendBlock(params Transaction[] transactions) block.UpdateMerkleRoot(); block.Header.HashPrevBlock = this.chain.Tip.HashBlock; - block.Header.Nonce = nonce; - if (!this.chain.TrySetTip(block.Header, out last)) + block.Header.Nonce = RandomUtils.GetUInt32(); + + return AppendBlock(block); + } + + protected ChainedHeader AppendBlock(Block block) + { + if (!this.chain.TrySetTip(block.Header, out ChainedHeader last)) throw new InvalidOperationException("Previous not existing"); + this.blockDict[block.GetHash()] = block; this.federationWalletSyncManager.ProcessBlock(block); diff --git a/src/Stratis.FederatedPeg.Tests/FederationGatewayControllerTests.cs b/src/Stratis.FederatedPeg.Tests/FederationGatewayControllerTests.cs index 9324cabf..4a253b4c 100644 --- a/src/Stratis.FederatedPeg.Tests/FederationGatewayControllerTests.cs +++ b/src/Stratis.FederatedPeg.Tests/FederationGatewayControllerTests.cs @@ -38,6 +38,8 @@ public class FederationGatewayControllerTests private readonly ILeaderReceiver leaderReceiver; + private readonly ISignatureProvider signatureProvider; + public FederationGatewayControllerTests() { this.network = FederatedPegNetwork.NetworksSelector.Regtest(); @@ -49,6 +51,7 @@ public FederationGatewayControllerTests() this.leaderProvider = Substitute.For(); this.depositExtractor = Substitute.For(); this.leaderReceiver = Substitute.For(); + this.signatureProvider = Substitute.For(); } private FederationGatewayController CreateController() @@ -58,7 +61,8 @@ private FederationGatewayController CreateController() this.maturedBlockReceiver, this.leaderProvider, this.GetMaturedBlocksProvider(), - this.leaderReceiver); + this.leaderReceiver, + this.signatureProvider); return controller; } diff --git a/src/Stratis.FederatedPeg.Tests/SignatureProviderTests.cs b/src/Stratis.FederatedPeg.Tests/SignatureProviderTests.cs new file mode 100644 index 00000000..6a22b6e1 --- /dev/null +++ b/src/Stratis.FederatedPeg.Tests/SignatureProviderTests.cs @@ -0,0 +1,122 @@ +using System.Linq; +using System.Reactive.Linq; +using NBitcoin; +using NBitcoin.Policy; +using Stratis.Bitcoin.Configuration; +using Stratis.Bitcoin.Networks; +using Stratis.FederatedPeg.Features.FederationGateway.Interfaces; +using Stratis.FederatedPeg.Features.FederationGateway.Models; +using Stratis.FederatedPeg.Features.FederationGateway.SourceChain; +using Stratis.FederatedPeg.Features.FederationGateway.TargetChain; +using Xunit; + +namespace Stratis.FederatedPeg.Tests +{ + public class SignatureProviderTests : CrossChainTestBase + { + public SignatureProviderTests() : base(Networks.Stratis.Testnet()) + { + } + + [Fact] + public void OtherMembersCanAddSignaturesToMyTransaction() + { + var dataFolder = new DataFolder(CreateTestDir(this)); + + this.Init(dataFolder); + this.AddFunding(); + this.AppendBlocks(this.federationGatewaySettings.MinCoinMaturity); + + using (ICrossChainTransferStore crossChainTransferStore = this.CreateStore()) + { + crossChainTransferStore.Initialize(); + crossChainTransferStore.Start(); + + Assert.Equal(this.chain.Tip.HashBlock, crossChainTransferStore.TipHashAndHeight.HashBlock); + Assert.Equal(this.chain.Tip.Height, crossChainTransferStore.TipHashAndHeight.Height); + + BitcoinAddress address = (new Key()).PubKey.Hash.GetAddress(this.network); + + var deposit = new Deposit(0, new Money(160m, MoneyUnit.BTC), address.ToString(), crossChainTransferStore.NextMatureDepositHeight, 1); + + IMaturedBlockDeposits[] blockDeposits = new[] { new MaturedBlockDepositsModel( + new MaturedBlockModel() { + BlockHash = 1, + BlockHeight = crossChainTransferStore.NextMatureDepositHeight }, + new[] { deposit }) + }; + + crossChainTransferStore.RecordLatestMatureDepositsAsync(blockDeposits).GetAwaiter().GetResult(); + + ICrossChainTransfer crossChainTransfer = crossChainTransferStore.GetAsync(new[] { deposit.Id }).GetAwaiter().GetResult().SingleOrDefault(); + + Assert.NotNull(crossChainTransfer); + + Transaction transaction = crossChainTransfer.PartialTransaction; + + Assert.True(crossChainTransferStore.ValidateTransaction(transaction)); + + // Create a separate instance to generate another transaction. + Transaction transaction2; + var newTest = new SignatureProviderTests(); + var dataFolder2 = new DataFolder(CreateTestDir(this)); + + newTest.federationKeys = this.federationKeys; + newTest.SetExtendedKey(1); + newTest.Init(dataFolder2); + + // Clone chain + for (int i = 1; i <= this.chain.Height; i++) + { + ChainedHeader header = this.chain.GetBlock(i); + Block block = this.blockDict[header.HashBlock]; + newTest.AppendBlock(block); + } + + using (ICrossChainTransferStore crossChainTransferStore2 = newTest.CreateStore()) + { + crossChainTransferStore2.Initialize(); + crossChainTransferStore2.Start(); + + Assert.Equal(newTest.chain.Tip.HashBlock, crossChainTransferStore2.TipHashAndHeight.HashBlock); + Assert.Equal(newTest.chain.Tip.Height, crossChainTransferStore2.TipHashAndHeight.Height); + + crossChainTransferStore2.RecordLatestMatureDepositsAsync(blockDeposits).GetAwaiter().GetResult(); + + ICrossChainTransfer crossChainTransfer2 = crossChainTransferStore2.GetAsync(new[] { deposit.Id }).GetAwaiter().GetResult().SingleOrDefault(); + + Assert.NotNull(crossChainTransfer2); + + transaction2 = crossChainTransfer2.PartialTransaction; + + Assert.True(crossChainTransferStore2.ValidateTransaction(transaction2)); + + // The first instance acts as signatory for the transaction coming from the second instance. + ISignatureProvider signatureProvider = new SignatureProvider( + this.federationWalletManager, + crossChainTransferStore, + this.federationGatewaySettings, + this.network, + this.loggerFactory); + + string signedTransactionHex = signatureProvider.SignTransaction(transaction2.ToHex(this.network)); + Assert.NotNull(signedTransactionHex); + + // The second instance parses the hex. + Transaction signedTransaction = newTest.network.CreateTransaction(signedTransactionHex); + Assert.NotNull(signedTransaction); + + // The second instance validates the transaction and signature. + var outpointLookup = newTest.wallet.MultiSigAddress.Transactions.ToDictionary(t => new OutPoint(t.Id, t.Index)); + Coin[] coins = signedTransaction.Inputs + .Select(input => outpointLookup[input.PrevOut]) + .Select(td => new Coin(td.Id, (uint)td.Index, td.Amount, td.ScriptPubKey)) + .ToArray(); + + TransactionBuilder builder = new TransactionBuilder(newTest.wallet.Network).AddCoins(coins); + Assert.True(builder.Verify(signedTransaction, this.federationGatewaySettings.TransactionFee, out TransactionPolicyError[] errors)); + } + } + } + } +}