Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add consolidate endpoint and adjust subtractfee logic #235

Merged
merged 3 commits into from
Nov 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/NBitcoin/TransactionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1545,7 +1545,7 @@ public ICoin[] FindSpentCoins(Transaction tx)
/// Estimate the physical size of the transaction
/// </summary>
/// <param name="tx">The transaction to be estimated</param>
/// <returns></returns>
/// <returns>The estimated size of the transaction in bytes.</returns>
public int EstimateSize(Transaction tx)
{
return EstimateSize(tx, false);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
{
Expand All @@ -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<IWalletFeePolicy>();
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<IAsyncProvider>().Object, new NodeLifetime(), DateTimeProvider.Default, this.scriptAddressReader, walletRepository);

walletManager.Start();

var reserveUtxoService = new ReserveUtxoService(this.loggerFactory, new Mock<ISignals>().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<IWalletFeePolicy>();
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<IAsyncProvider>().Object, new NodeLifetime(), DateTimeProvider.Default, this.scriptAddressReader, walletRepository);

walletManager.Start();

var reserveUtxoService = new ReserveUtxoService(this.loggerFactory, new Mock<ISignals>().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<WalletException>(() => 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<Recipient> recipients = null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,15 @@ public async Task<IActionResult> 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<IActionResult> 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<TransactionItemModel> items,
TransactionData transaction)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,7 @@ public interface IWalletTransactionHandler
/// <param name="context">The context that is used to build a new transaction.</param>
/// <returns>The estimated fee.</returns>
Money EstimateFee(TransactionBuildContext context);

int EstimateSize(TransactionBuildContext context);
}
}
39 changes: 39 additions & 0 deletions src/Stratis.Bitcoin.Features.Wallet/Models/RequestModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

/// <summary>
/// The account from which UTXOs should be consolidated.
/// If this is not set the default account of the selected wallet will be used.
/// </summary>
public string AccountName { get; set; }

[Required(ErrorMessage = "A password is required.")]
public string WalletPassword { get; set; }

/// <summary>
/// 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.
/// </summary>
public string SingleAddress { get; set; }

/// <summary>
/// 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.
/// </summary>
public string DestinationAddress { get; set; }

/// <summary>
/// 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.
/// </summary>
[MoneyFormat(isRequired: false, ErrorMessage = "The amount is not in the correct format.")]
public string UtxoValueThreshold { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,8 @@ public interface IWalletService

Task<WalletBuildTransactionModel> OfflineSignRequest(OfflineSignRequest request,
CancellationToken cancellationToken);

Task<string> Consolidate(ConsolidationRequest request,
CancellationToken cancellationToken);
}
}
97 changes: 96 additions & 1 deletion src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,101 @@ public async Task<WalletBuildTransactionModel> OfflineSignRequest(OfflineSignReq
}, cancellationToken);
}

public async Task<string> Consolidate(ConsolidationRequest request, CancellationToken cancellationToken)
{
return await Task.Run(() =>
{
var utxos = new List<UnspentOutputReference>();
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<OutPoint>();

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<Recipient>() { 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<Recipient>() { 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<TransactionItemModel> items,
TransactionData transaction)
{
Expand All @@ -1390,4 +1485,4 @@ public async Task<WalletBuildTransactionModel> OfflineSignRequest(OfflineSignReq
i.ConfirmedInBlock == transaction.BlockHeight);
}
}
}
}
Loading