diff --git a/src/NBitcoin/TransactionBuilder.cs b/src/NBitcoin/TransactionBuilder.cs
index 5d1506d0cd..f7bd466f7a 100644
--- a/src/NBitcoin/TransactionBuilder.cs
+++ b/src/NBitcoin/TransactionBuilder.cs
@@ -1545,7 +1545,7 @@ public ICoin[] FindSpentCoins(Transaction tx)
/// Estimate the physical size of the transaction
///
/// The transaction to be estimated
- ///
+ /// The estimated size of the transaction in bytes.
public int EstimateSize(Transaction tx)
{
return EstimateSize(tx, false);
@@ -1617,7 +1617,7 @@ private void EstimateScriptSigSize(ICoin coin, ref int witSize, ref int baseSize
}
if (scriptSigSize == -1)
- scriptSigSize += coin.TxOut.ScriptPubKey.Length; //Using heurestic to approximate size of unknown scriptPubKey
+ scriptSigSize += coin.TxOut.ScriptPubKey.Length; //Using heuristic to approximate size of unknown scriptPubKey
if (coin.GetHashVersion(this.Network) == HashVersion.Witness)
witSize += scriptSigSize + 1; //Account for the push
diff --git a/src/Stratis.Bitcoin.Features.SmartContracts/Wallet/SmartContractWalletTransactionHandler.cs b/src/Stratis.Bitcoin.Features.SmartContracts/Wallet/SmartContractWalletTransactionHandler.cs
index fee317ffd6..414ed73b6f 100644
--- a/src/Stratis.Bitcoin.Features.SmartContracts/Wallet/SmartContractWalletTransactionHandler.cs
+++ b/src/Stratis.Bitcoin.Features.SmartContracts/Wallet/SmartContractWalletTransactionHandler.cs
@@ -57,8 +57,9 @@ protected override void AddRecipients(TransactionBuildContext context)
if (context.Recipients.Any(recipient => recipient.Amount == Money.Zero && !recipient.ScriptPubKey.IsSmartContractExec()))
throw new WalletException("No amount specified.");
+ // TODO: Port the necessary logic from the regular wallet transaction handler
if (context.Recipients.Any(a => a.SubtractFeeFromAmount))
- throw new NotImplementedException("Substracting the fee from the recipient is not supported yet.");
+ throw new NotImplementedException("Subtracting the fee from the recipient is not supported yet.");
foreach (Recipient recipient in context.Recipients)
context.TransactionBuilder.Send(recipient.ScriptPubKey, recipient.Amount);
diff --git a/src/Stratis.Bitcoin.Features.Wallet.Tests/WalletTransactionHandlerTest.cs b/src/Stratis.Bitcoin.Features.Wallet.Tests/WalletTransactionHandlerTest.cs
index 5d3e2dfd2c..112ba5a6ab 100644
--- a/src/Stratis.Bitcoin.Features.Wallet.Tests/WalletTransactionHandlerTest.cs
+++ b/src/Stratis.Bitcoin.Features.Wallet.Tests/WalletTransactionHandlerTest.cs
@@ -620,7 +620,7 @@ public void When_BuildTransactionIsCalled_Then_FeeIsDeductedFromAmountsInTransac
{
AccountReference = walletReference,
MinConfirmations = 0,
- FeeType = FeeType.Low,
+ TransactionFee = Money.Coins(0.0001m),
WalletPassword = "password",
Recipients = new[]
{
@@ -636,6 +636,139 @@ public void When_BuildTransactionIsCalled_Then_FeeIsDeductedFromAmountsInTransac
Assert.True(transaction.Outputs.Count(i => i.Value.Satoshi < 5_000_000_000) == 2); // 2 outputs should have fees taken from the amount
}
+ [Fact]
+ public void When_BuildTransactionIsCalledWithoutTransactionFee_Then_FeeIsDeductedFromSingleOutputInTransaction()
+ {
+ DataFolder dataFolder = CreateDataFolder(this);
+
+ IWalletRepository walletRepository = new SQLiteWalletRepository(this.LoggerFactory.Object, dataFolder, this.Network, DateTimeProvider.Default, new ScriptAddressReader())
+ {
+ TestMode = true
+ };
+
+ var walletFeePolicy = new Mock();
+ walletFeePolicy.Setup(w => w.GetFeeRate(FeeType.Low.ToConfirmations())).Returns(new FeeRate(20000));
+
+ var walletManager = new WalletManager(this.LoggerFactory.Object, this.Network, new ChainIndexer(this.Network), new WalletSettings(NodeSettings.Default(this.Network)),
+ dataFolder, walletFeePolicy.Object, new Mock().Object, new NodeLifetime(), DateTimeProvider.Default, this.scriptAddressReader, walletRepository);
+
+ walletManager.Start();
+
+ var reserveUtxoService = new ReserveUtxoService(this.loggerFactory, new Mock().Object);
+
+ var walletTransactionHandler = new WalletTransactionHandler(this.LoggerFactory.Object, walletManager, walletFeePolicy.Object, this.Network, this.standardTransactionPolicy, reserveUtxoService);
+
+ (Wallet wallet, ExtKey extKey) = WalletTestsHelpers.GenerateBlankWalletWithExtKey("myWallet1", "password", walletRepository);
+
+ walletManager.Wallets.Add(wallet);
+
+ int accountIndex = 0;
+ ExtKey addressExtKey = extKey.Derive(new KeyPath($"m/44'/{this.Network.Consensus.CoinType}'/{accountIndex}'"));
+ ExtPubKey extPubKey = addressExtKey.Neuter();
+
+ HdAccount account = wallet.AddNewAccount(extPubKey, accountName: "account1");
+
+ var address = account.ExternalAddresses.First();
+ var destination = account.InternalAddresses.First();
+ var destination2 = account.InternalAddresses.Skip(1).First();
+ var destination3 = account.InternalAddresses.Skip(2).First();
+
+ // Wallet with 4 coinbase outputs of 50 = 200.
+ var chain = new ChainIndexer(wallet.Network);
+ WalletTestsHelpers.AddBlocksWithCoinbaseToChain(wallet.Network, chain, address, 4);
+
+ var walletReference = new WalletAccountReference
+ {
+ AccountName = "account1",
+ WalletName = "myWallet1"
+ };
+
+ // Create a transaction with 3 outputs 50 + 50 + 50 = 150 but with fees charged to recipients.
+ var context = new TransactionBuildContext(this.Network)
+ {
+ AccountReference = walletReference,
+ MinConfirmations = 0,
+ FeeType = FeeType.Low,
+ WalletPassword = "password",
+ Recipients = new[]
+ {
+ new Recipient { Amount = new Money(50, MoneyUnit.BTC), ScriptPubKey = destination.ScriptPubKey, SubtractFeeFromAmount = true },
+ new Recipient { Amount = new Money(50, MoneyUnit.BTC), ScriptPubKey = destination2.ScriptPubKey, SubtractFeeFromAmount = false },
+ new Recipient { Amount = new Money(50, MoneyUnit.BTC), ScriptPubKey = destination3.ScriptPubKey, SubtractFeeFromAmount = false }
+ }.ToList()
+ };
+
+ Transaction transaction = walletTransactionHandler.BuildTransaction(context);
+ Assert.Equal(3, transaction.Inputs.Count); // 3 inputs
+ Assert.Equal(3, transaction.Outputs.Count); // 3 outputs with change
+ Assert.True(transaction.Outputs.Count(i => i.Value.Satoshi < 5_000_000_000) == 1); // 1 output should have fees taken from the amount
+ }
+
+ [Fact]
+ public void When_BuildTransactionIsCalledWithoutTransactionFee_Then_MultipleSubtractFeeRecipients_ThrowsException()
+ {
+ DataFolder dataFolder = CreateDataFolder(this);
+
+ IWalletRepository walletRepository = new SQLiteWalletRepository(this.LoggerFactory.Object, dataFolder, this.Network, DateTimeProvider.Default, new ScriptAddressReader())
+ {
+ TestMode = true
+ };
+
+ var walletFeePolicy = new Mock();
+ walletFeePolicy.Setup(w => w.GetFeeRate(FeeType.Low.ToConfirmations())).Returns(new FeeRate(20000));
+
+ var walletManager = new WalletManager(this.LoggerFactory.Object, this.Network, new ChainIndexer(this.Network), new WalletSettings(NodeSettings.Default(this.Network)),
+ dataFolder, walletFeePolicy.Object, new Mock().Object, new NodeLifetime(), DateTimeProvider.Default, this.scriptAddressReader, walletRepository);
+
+ walletManager.Start();
+
+ var reserveUtxoService = new ReserveUtxoService(this.loggerFactory, new Mock().Object);
+
+ var walletTransactionHandler = new WalletTransactionHandler(this.LoggerFactory.Object, walletManager, walletFeePolicy.Object, this.Network, this.standardTransactionPolicy, reserveUtxoService);
+
+ (Wallet wallet, ExtKey extKey) = WalletTestsHelpers.GenerateBlankWalletWithExtKey("myWallet1", "password", walletRepository);
+
+ walletManager.Wallets.Add(wallet);
+
+ int accountIndex = 0;
+ ExtKey addressExtKey = extKey.Derive(new KeyPath($"m/44'/{this.Network.Consensus.CoinType}'/{accountIndex}'"));
+ ExtPubKey extPubKey = addressExtKey.Neuter();
+
+ HdAccount account = wallet.AddNewAccount(extPubKey, accountName: "account1");
+
+ var address = account.ExternalAddresses.First();
+ var destination = account.InternalAddresses.First();
+ var destination2 = account.InternalAddresses.Skip(1).First();
+ var destination3 = account.InternalAddresses.Skip(2).First();
+
+ // Wallet with 4 coinbase outputs of 50 = 200.
+ var chain = new ChainIndexer(wallet.Network);
+ WalletTestsHelpers.AddBlocksWithCoinbaseToChain(wallet.Network, chain, address, 4);
+
+ var walletReference = new WalletAccountReference
+ {
+ AccountName = "account1",
+ WalletName = "myWallet1"
+ };
+
+ // Create a transaction with 3 outputs 50 + 50 + 50 = 150 but with fees charged to recipients.
+ var context = new TransactionBuildContext(this.Network)
+ {
+ AccountReference = walletReference,
+ MinConfirmations = 0,
+ FeeType = FeeType.Low,
+ WalletPassword = "password",
+ Recipients = new[]
+ {
+ new Recipient { Amount = new Money(50, MoneyUnit.BTC), ScriptPubKey = destination.ScriptPubKey, SubtractFeeFromAmount = true },
+ new Recipient { Amount = new Money(50, MoneyUnit.BTC), ScriptPubKey = destination2.ScriptPubKey, SubtractFeeFromAmount = true },
+ new Recipient { Amount = new Money(50, MoneyUnit.BTC), ScriptPubKey = destination3.ScriptPubKey, SubtractFeeFromAmount = false }
+ }.ToList()
+ };
+
+ Assert.Throws(() => walletTransactionHandler.BuildTransaction(context));
+ }
+
public static TransactionBuildContext CreateContext(Network network, WalletAccountReference accountReference, string password,
Script destinationScript, Money amount, FeeType feeType, int minConfirmations, string opReturnData = null, List recipients = null)
{
diff --git a/src/Stratis.Bitcoin.Features.Wallet/Controllers/WalletController.cs b/src/Stratis.Bitcoin.Features.Wallet/Controllers/WalletController.cs
index 8692ef5848..3a71fb5455 100644
--- a/src/Stratis.Bitcoin.Features.Wallet/Controllers/WalletController.cs
+++ b/src/Stratis.Bitcoin.Features.Wallet/Controllers/WalletController.cs
@@ -829,6 +829,15 @@ public async Task ListWallets(CancellationToken cancellationToken
return await this.Execute(request, cancellationToken, async (req, token) => this.Json(await this.walletService.OfflineSignRequest(req, token)));
}
+ [HttpPost]
+ [Route("consolidate")]
+ public async Task Consolidate([FromBody] ConsolidationRequest request,
+ CancellationToken cancellationToken = default(CancellationToken))
+ {
+ return await this.Execute(request, cancellationToken,
+ async (req, token) => this.Json(await this.walletService.Consolidate(req, token)));
+ }
+
private TransactionItemModel FindSimilarReceivedTransactionOutput(List items,
TransactionData transaction)
{
diff --git a/src/Stratis.Bitcoin.Features.Wallet/Interfaces/IWalletTransactionHandler.cs b/src/Stratis.Bitcoin.Features.Wallet/Interfaces/IWalletTransactionHandler.cs
index fd3d45b30a..2abd265982 100644
--- a/src/Stratis.Bitcoin.Features.Wallet/Interfaces/IWalletTransactionHandler.cs
+++ b/src/Stratis.Bitcoin.Features.Wallet/Interfaces/IWalletTransactionHandler.cs
@@ -40,5 +40,7 @@ public interface IWalletTransactionHandler
/// The context that is used to build a new transaction.
/// The estimated fee.
Money EstimateFee(TransactionBuildContext context);
+
+ int EstimateSize(TransactionBuildContext context);
}
}
diff --git a/src/Stratis.Bitcoin.Features.Wallet/Models/RequestModels.cs b/src/Stratis.Bitcoin.Features.Wallet/Models/RequestModels.cs
index cc64891e62..2869060eb5 100644
--- a/src/Stratis.Bitcoin.Features.Wallet/Models/RequestModels.cs
+++ b/src/Stratis.Bitcoin.Features.Wallet/Models/RequestModels.cs
@@ -989,4 +989,43 @@ public class SweepRequest : RequestModel
public bool Broadcast { get; set; }
}
+
+ public sealed class ConsolidationRequest : RequestModel
+ {
+ public ConsolidationRequest()
+ {
+ this.AccountName = WalletManager.DefaultAccount;
+ }
+
+ [Required(ErrorMessage = "The name of the wallet is missing.")]
+ public string WalletName { get; set; }
+
+ ///
+ /// The account from which UTXOs should be consolidated.
+ /// If this is not set the default account of the selected wallet will be used.
+ ///
+ public string AccountName { get; set; }
+
+ [Required(ErrorMessage = "A password is required.")]
+ public string WalletPassword { get; set; }
+
+ ///
+ /// If this is set, only UTXOs within this wallet address will be consolidated.
+ /// If it is not set, all the UTXOs within the selected account will be consolidated.
+ ///
+ public string SingleAddress { get; set; }
+
+ ///
+ /// Which address the UTXOs should be sent to. It does not have to be within the wallet.
+ /// If it is not provided the UTXOs will be consolidated to an unused address within the specified wallet.
+ ///
+ public string DestinationAddress { get; set; }
+
+ ///
+ /// If provided, UTXOs that are larger in value will not be consolidated.
+ /// Dust UTXOs will not be consolidated regardless of their value, so there is an implicit lower bound as well.
+ ///
+ [MoneyFormat(isRequired: false, ErrorMessage = "The amount is not in the correct format.")]
+ public string UtxoValueThreshold { get; set; }
+ }
}
diff --git a/src/Stratis.Bitcoin.Features.Wallet/Services/IWalletService.cs b/src/Stratis.Bitcoin.Features.Wallet/Services/IWalletService.cs
index 4b689f4f23..90a50682da 100644
--- a/src/Stratis.Bitcoin.Features.Wallet/Services/IWalletService.cs
+++ b/src/Stratis.Bitcoin.Features.Wallet/Services/IWalletService.cs
@@ -59,5 +59,8 @@ public interface IWalletService
Task OfflineSignRequest(OfflineSignRequest request,
CancellationToken cancellationToken);
+
+ Task Consolidate(ConsolidationRequest request,
+ CancellationToken cancellationToken);
}
}
diff --git a/src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs b/src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs
index b9e8fa7a17..276af61df5 100644
--- a/src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs
+++ b/src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs
@@ -1382,6 +1382,101 @@ public async Task OfflineSignRequest(OfflineSignReq
}, cancellationToken);
}
+ public async Task Consolidate(ConsolidationRequest request, CancellationToken cancellationToken)
+ {
+ return await Task.Run(() =>
+ {
+ var utxos = new List();
+ var accountReference = new WalletAccountReference(request.WalletName, request.AccountName);
+
+ if (!string.IsNullOrWhiteSpace(request.SingleAddress))
+ {
+ utxos = this.walletManager.GetSpendableTransactionsInWallet(request.WalletName).Where(u => u.Address.Address == request.SingleAddress || u.Address.Address == request.SingleAddress).OrderBy(u2 => u2.Transaction.Amount).ToList();
+ }
+ else
+ {
+ utxos = this.walletManager.GetSpendableTransactionsInAccount(accountReference).OrderBy(u2 => u2.Transaction.Amount).ToList();
+ }
+
+ if (utxos.Count == 0)
+ {
+ throw new FeatureException(HttpStatusCode.BadRequest, "Failed to locate any unspent outputs to consolidate.",
+ "Failed to locate any unspent outputs to consolidate.");
+ }
+
+ if (utxos.Count == 1)
+ {
+ throw new FeatureException(HttpStatusCode.BadRequest, "Already consolidated.",
+ "Already consolidated.");
+ }
+
+
+ if (!string.IsNullOrWhiteSpace(request.UtxoValueThreshold))
+ {
+ var threshold = Money.Parse(request.UtxoValueThreshold);
+
+ utxos = utxos.Where(u => u.Transaction.Amount <= threshold).ToList();
+ }
+
+ Script destination;
+ if (!string.IsNullOrWhiteSpace(request.DestinationAddress))
+ {
+ destination = BitcoinAddress.Create(request.DestinationAddress, this.network).ScriptPubKey;
+ }
+ else
+ {
+ destination = this.walletManager.GetUnusedAddress(accountReference).ScriptPubKey;
+ }
+
+ Money totalToSend = Money.Zero;
+ var outpoints = new List();
+
+ TransactionBuildContext context = null;
+
+ foreach (var utxo in utxos)
+ {
+ totalToSend += utxo.Transaction.Amount;
+ outpoints.Add(utxo.ToOutPoint());
+
+ context = new TransactionBuildContext(this.network)
+ {
+ AccountReference = accountReference,
+ AllowOtherInputs = false,
+ FeeType = FeeType.Medium,
+ // It is intended that consolidation should result in no change address, so the fee has to be subtracted from the single recipient.
+ Recipients = new List() { new Recipient() { ScriptPubKey = destination, Amount = totalToSend, SubtractFeeFromAmount = true } },
+ SelectedInputs = outpoints,
+
+ Sign = false
+ };
+
+ // Note that this is the virtual size taking the witness scale factor of the current network into account, and not the raw byte count.
+ int size = this.walletTransactionHandler.EstimateSize(context);
+
+ // Leave a bit of an error margin for size estimates that are not completely correct.
+ if (size > (0.95m * this.network.Consensus.Options.MaxStandardTxWeight))
+ break;
+ }
+
+ // Build the final version of the consolidation transaction.
+ context = new TransactionBuildContext(this.network)
+ {
+ AccountReference = accountReference,
+ AllowOtherInputs = false,
+ FeeType = FeeType.Medium,
+ Recipients = new List() { new Recipient() { ScriptPubKey = destination, Amount = totalToSend, SubtractFeeFromAmount = true } },
+ SelectedInputs = outpoints,
+ WalletPassword = request.WalletPassword,
+
+ Sign = true
+ };
+
+ Transaction transaction = this.walletTransactionHandler.BuildTransaction(context);
+
+ return transaction.ToHex();
+ }, cancellationToken);
+ }
+
private TransactionItemModel FindSimilarReceivedTransactionOutput(List items,
TransactionData transaction)
{
diff --git a/src/Stratis.Bitcoin.Features.Wallet/WalletTransactionHandler.cs b/src/Stratis.Bitcoin.Features.Wallet/WalletTransactionHandler.cs
index 8b77ab3c22..112736a284 100644
--- a/src/Stratis.Bitcoin.Features.Wallet/WalletTransactionHandler.cs
+++ b/src/Stratis.Bitcoin.Features.Wallet/WalletTransactionHandler.cs
@@ -5,7 +5,6 @@
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Policy;
-using Stratis.Bitcoin.Features.Wallet;
using Stratis.Bitcoin.Features.Wallet.Interfaces;
using Stratis.Bitcoin.Features.Wallet.Services;
using Stratis.Bitcoin.Utilities;
@@ -216,6 +215,15 @@ public Money EstimateFee(TransactionBuildContext context)
return context.TransactionFee;
}
+ public int EstimateSize(TransactionBuildContext context)
+ {
+ this.InitializeTransactionBuilder(context);
+
+ Transaction transaction = context.TransactionBuilder.BuildTransaction(false);
+
+ return context.TransactionBuilder.EstimateSize(transaction, true);
+ }
+
///
/// Initializes the context transaction builder from information in .
///
@@ -403,16 +411,27 @@ protected virtual void AddRecipients(TransactionBuildContext context)
if (context.Recipients.Any(a => a.Amount == Money.Zero))
throw new WalletException("No amount specified.");
- int totalRecipients = context.Recipients.Count(r => r.SubtractFeeFromAmount);
+ int totalSubtractingRecipients = context.Recipients.Count(r => r.SubtractFeeFromAmount);
- // If we have any recipients that require a fee to be subtracted from the amount, then
- // calculate fee and evenly distribute it among all recipients. Any remaining fee should be
+ // If none of them need the fee subtracted then it's simply a matter of adding the individual recipients to the builder.
+ if (totalSubtractingRecipients == 0)
+ {
+ foreach (Recipient recipient in context.Recipients)
+ {
+ context.TransactionBuilder.Send(recipient.ScriptPubKey, recipient.Amount);
+ }
+
+ return;
+ }
+
+ // If the transaction fee has been explicitly specified, and we have any recipients that require a fee to be subtracted
+ // from the amount to be sent, then evenly distribute the chosen fee among all recipients. Any remaining fee should be
// subtracted from the first recipient.
- if (totalRecipients > 0 && context.TransactionFee != null)
+ if (context.TransactionFee != null)
{
Money fee = context.TransactionFee;
- long recipientFee = fee.Satoshi / totalRecipients;
- long remainingFee = fee.Satoshi % totalRecipients;
+ long recipientFee = fee.Satoshi / totalSubtractingRecipients;
+ long remainingFee = fee.Satoshi % totalSubtractingRecipients;
for (int i = 0; i < context.Recipients.Count; i++)
{
@@ -434,9 +453,19 @@ protected virtual void AddRecipients(TransactionBuildContext context)
}
else
{
+ // This is currently a limitation of the NBitcoin TransactionBuilder.
+ // The only alternative would possibly be to recompute the output sizes after the AddFee call.
+ if (totalSubtractingRecipients > 1)
+ throw new WalletException($"Cannot subtract fee from more than 1 recipient if {nameof(context.TransactionFee)} is not set.");
+
+ // If the transaction fee has not been explicitly specified yet, then the builder needs to assign it later from the wallet fee policy.
+ // So we just need to indicate to the builder that the fees must be subtracted from the specified recipient.
foreach (Recipient recipient in context.Recipients)
{
context.TransactionBuilder.Send(recipient.ScriptPubKey, recipient.Amount);
+
+ if (recipient.SubtractFeeFromAmount)
+ context.TransactionBuilder.SubtractFees();
}
}
}