Commit 1ee990f6 authored by Jeremy Bokobza's avatar Jeremy Bokobza Committed by GitHub

Merge pull request #50 from bokobza/feature/build-transaction

Feature/build transaction
parents 25588866 20d4beef
...@@ -264,12 +264,15 @@ This endpoint will get the last address containing no transaction or will create ...@@ -264,12 +264,15 @@ This endpoint will get the last address containing no transaction or will create
Confirmed balance is the (amount of unspent confirmed outputs - unconfirmed outgoing transactions). It cannot be negative. Confirmed balance is the (amount of unspent confirmed outputs - unconfirmed outgoing transactions). It cannot be negative.
Unconfirmed balance is the difference of unconfirmed incoming and outgoing transactions. It can be negative. Unconfirmed balance is the difference of unconfirmed incoming and outgoing transactions. It can be negative.
## POST /wallet/build-transaction/[account1/account2] - Attempts to build a transaction with the specified wallet account ## POST /wallet/build-transaction/ - Attempts to build a transaction with the specified wallet account
### Parameters ### Parameters
``` ```
{ {
"walletName": "myFirstWallet",
"accountName": "account 0",
"coinType": 0,
"password": "password", "password": "password",
"address": "1Xyz...", "destinationAddress": "1Xyz...",
"amount": "0.12", // in btc, if 0, then spends all available "amount": "0.12", // in btc, if 0, then spends all available
"feeType": "low", // "low"/"medium"/"high" "feeType": "low", // "low"/"medium"/"high"
"allowUnconfirmed": true // if spending unconfirmed outputs is allowed "allowUnconfirmed": true // if spending unconfirmed outputs is allowed
...@@ -280,51 +283,8 @@ Unconfirmed balance is the difference of unconfirmed incoming and outgoing trans ...@@ -280,51 +283,8 @@ Unconfirmed balance is the difference of unconfirmed incoming and outgoing trans
#### Successful #### Successful
``` ```
{ {
"spendsUnconfirmed": false, // If spends unconfirmed you can ask the user if it's sure about spending unconfirmed transaction (if inputs are malleated or inputs never confirm then this transaction will never confirm either"
"fee": "0.0001", "fee": "0.0001",
"feePercentOfSent": "0.1" // Percentage of the total spent amount, there must be a safety limit implemented here "hex": "0100000002d9dced2b6fc80c706d3564670cb6706afe7a798863a9218efcdcf415d58f0f82000000006a473044022030b8bea478444bd52f08de33b082cde1176d3137111f506eefefa91b47b1f6bf02204f12746abd1aeac5805872d163592cf145967fa0619339a9c5348d674852ef4801210224ec1e4c270ce373e6999eebfa01d0a7e7db3c537c026f265233350d5aab81fbfeffffffa0706db65c5e3594d43df5a2a8b6dfd3c9ee506b678f8c26f7820b324b26aa0f000000006a473044022061b718034f876590d6d80bac77a63248b2548d934849acd02c4f4236309e853002201aded6b24f553b6902cf571276b37b12f76b75650164d8738c74469b4edd547e012103d649294a0ca4db920a69eacd6a75cb8a38ae1b81129900621ce45e6ba3438a7bfeffffff0280a90300000000001976a914d0965947ebb329b776328624ebde8f8b32dc639788ac1cc80f00000000001976a914c2a420d34fc86cff932b8c3191549a0ddfd2b0d088acba770f00"
"hex": "0100000002d9dced2b6fc80c706d3564670cb6706afe7a798863a9218efcdcf415d58f0f82000000006a473044022030b8bea478444bd52f08de33b082cde1176d3137111f506eefefa91b47b1f6bf02204f12746abd1aeac5805872d163592cf145967fa0619339a9c5348d674852ef4801210224ec1e4c270ce373e6999eebfa01d0a7e7db3c537c026f265233350d5aab81fbfeffffffa0706db65c5e3594d43df5a2a8b6dfd3c9ee506b678f8c26f7820b324b26aa0f000000006a473044022061b718034f876590d6d80bac77a63248b2548d934849acd02c4f4236309e853002201aded6b24f553b6902cf571276b37b12f76b75650164d8738c74469b4edd547e012103d649294a0ca4db920a69eacd6a75cb8a38ae1b81129900621ce45e6ba3438a7bfeffffff0280a90300000000001976a914d0965947ebb329b776328624ebde8f8b32dc639788ac1cc80f00000000001976a914c2a420d34fc86cff932b8c3191549a0ddfd2b0d088acba770f00"
"transaction": // NBitcoin.Transaction.ToString()
{
"transaction": "0100000002d9dced2b6fc80c706d3564670cb6706afe7a798863a9218efcdcf415d58f0f82000000006a473044022030b8bea478444bd52f08de33b082cde1176d3137111f506eefefa91b47b1f6bf02204f12746abd1aeac5805872d163592cf145967fa0619339a9c5348d674852ef4801210224ec1e4c270ce373e6999eebfa01d0a7e7db3c537c026f265233350d5aab81fbfeffffffa0706db65c5e3594d43df5a2a8b6dfd3c9ee506b678f8c26f7820b324b26aa0f000000006a473044022061b718034f876590d6d80bac77a63248b2548d934849acd02c4f4236309e853002201aded6b24f553b6902cf571276b37b12f76b75650164d8738c74469b4edd547e012103d649294a0ca4db920a69eacd6a75cb8a38ae1b81129900621ce45e6ba3438a7bfeffffff0280a90300000000001976a914d0965947ebb329b776328624ebde8f8b32dc639788ac1cc80f00000000001976a914c2a420d34fc86cff932b8c3191549a0ddfd2b0d088acba770f00",
"transactionId": "22ab5e9b703c0d4cb6023e3a1622b493adc8f83a79771c83a73dfa38ef35b07c",
"isCoinbase": false,
"block": null,
"spentCoins": [
{
"transactionId": "820f8fd515f4dcfc8e21a96388797afe6a70b60c6764356d700cc86f2beddcd9",
"index": 0,
"value": 100000,
"scriptPubKey": "76a914e7c1345fc8f87c68170b3aa798a956c2fe6a9eff88ac",
"redeemScript": null
},
{
"transactionId": "0faa264b320b82f7268c8f676b50eec9d3dfb6a8a2f53dd494355e5cb66d70a0",
"index": 0,
"value": 1180443,
"scriptPubKey": "76a914f3821cff5a90328271d8596198f68e97fbe2ea0e88ac",
"redeemScript": null
}
],
"receivedCoins": [
{
"transactionId": "22ab5e9b703c0d4cb6023e3a1622b493adc8f83a79771c83a73dfa38ef35b07c",
"index": 0,
"value": 240000,
"scriptPubKey": "76a914d0965947ebb329b776328624ebde8f8b32dc639788ac",
"redeemScript": null
},
{
"transactionId": "22ab5e9b703c0d4cb6023e3a1622b493adc8f83a79771c83a73dfa38ef35b07c",
"index": 1,
"value": 1034268,
"scriptPubKey": "76a914c2a420d34fc86cff932b8c3191549a0ddfd2b0d088ac",
"redeemScript": null
}
],
"firstSeen": "2016-10-31T09:13:18.4420023+00:00",
"fees": 6175
}
} }
``` ```
......
...@@ -17,7 +17,8 @@ ...@@ -17,7 +17,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="ConcurrentHashSet" Version="1.0.1" /> <PackageReference Include="ConcurrentHashSet" Version="1.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="1.0.3" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="1.0.3" />
<PackageReference Include="System.ValueTuple" Version="4.3.1" />
<PackageReference Include="Stratis.Bitcoin" Version="1.0.1.6-alpha" /> <PackageReference Include="Stratis.Bitcoin" Version="1.0.1.6-alpha" />
</ItemGroup> </ItemGroup>
......
...@@ -287,8 +287,14 @@ namespace Breeze.Wallet.Controllers ...@@ -287,8 +287,14 @@ namespace Breeze.Wallet.Controllers
try try
{ {
return this.Json(this.walletManager.BuildTransaction(request.Password, request.Address, request.Amount, request.FeeType, request.AllowUnconfirmed)); var transaction = this.walletManager.BuildTransaction(request.WalletName, request.AccountName, request.CoinType, request.Password, request.DestinationAddress, request.Amount, request.FeeType, request.AllowUnconfirmed);
var fee = transaction.TotalOut - request.Amount;
var model = new WalletBuildTransactionModel
{
Hex = transaction.ToHex(),
Fee = fee
};
return this.Json(model);
} }
catch (Exception e) catch (Exception e)
{ {
......
...@@ -123,8 +123,20 @@ namespace Breeze.Wallet ...@@ -123,8 +123,20 @@ namespace Breeze.Wallet
/// <returns></returns> /// <returns></returns>
IEnumerable<HdAccount> GetAccountsByCoinType(string walletName, CoinType coinType); IEnumerable<HdAccount> GetAccountsByCoinType(string walletName, CoinType coinType);
WalletBuildTransactionModel BuildTransaction(string password, string address, Money amount, string feeType, bool allowUnconfirmed); /// <summary>
/// Builds a transaction to be sent to the network.
/// </summary>
/// <param name="walletName">The name of the wallet in which this address is contained.</param>
/// <param name="coinType">The type of coin for which to get the address.</param>
/// <param name="accountName">The name of the account in which this address is contained.</param>
/// <param name="password">The password used to decrypt the private key.</param>
/// <param name="destinationAddress">The destination address to send the funds to.</param>
/// <param name="amount">The amount of funds to be sent.</param>
/// <param name="feeType">The type of fee to be included.</param>
/// <param name="allowUnconfirmed">Whether or not we allow this transaction to rely on unconfirmed outputs.</param>
/// <returns></returns>
NBitcoin.Transaction BuildTransaction(string walletName, string accountName, CoinType coinType, string password, string destinationAddress, Money amount, string feeType, bool allowUnconfirmed);
bool SendTransaction(string transactionHex); bool SendTransaction(string transactionHex);
/// <summary> /// <summary>
......
...@@ -79,11 +79,20 @@ namespace Breeze.Wallet.Models ...@@ -79,11 +79,20 @@ namespace Breeze.Wallet.Models
public class BuildTransactionRequest public class BuildTransactionRequest
{ {
[Required(ErrorMessage = "The name of the wallet is missing.")]
public string WalletName { get; set; }
[Required]
public CoinType CoinType { get; set; }
[Required(ErrorMessage = "The name of the account is missing.")]
public string AccountName { get; set; }
[Required(ErrorMessage = "A password is required.")] [Required(ErrorMessage = "A password is required.")]
public string Password { get; set; } public string Password { get; set; }
[Required(ErrorMessage = "A destination address is required.")] [Required(ErrorMessage = "A destination address is required.")]
public string Address { get; set; } public string DestinationAddress { get; set; }
[Required(ErrorMessage = "An amount is required.")] [Required(ErrorMessage = "An amount is required.")]
public string Amount { get; set; } public string Amount { get; set; }
......
...@@ -8,62 +8,11 @@ using Newtonsoft.Json; ...@@ -8,62 +8,11 @@ using Newtonsoft.Json;
namespace Breeze.Wallet.Models namespace Breeze.Wallet.Models
{ {
public class WalletBuildTransactionModel public class WalletBuildTransactionModel
{ {
[JsonProperty(PropertyName = "spendsUnconfirmed")]
public bool SpendsUnconfirmed { get; set; }
[JsonProperty(PropertyName = "fee")] [JsonProperty(PropertyName = "fee")]
public Money Fee { get; set; } public Money Fee { get; set; }
[JsonProperty(PropertyName = "feePercentOfSent")]
public decimal FeePercentOfSent { get; set; }
[JsonProperty(PropertyName = "hex")] [JsonProperty(PropertyName = "hex")]
public string Hex { get; set; } public string Hex { get; set; }
}
[JsonProperty(PropertyName = "transaction")]
public Transaction Transaction { get; set; }
}
public class Transaction
{
[JsonProperty(PropertyName = "id")]
public string Id { get; set; }
[JsonProperty(PropertyName = "isCoinbase")]
public bool IsCoinbase { get; set; }
[JsonProperty(PropertyName = "block")]
public string Block { get; set; }
[JsonProperty(PropertyName = "spentCoins")]
public IEnumerable<TransactionDetails> SpentCoins { get; set; }
[JsonProperty(PropertyName = "receivedCoins")]
public IEnumerable<TransactionDetails> ReceivedCoins { get; set; }
[JsonProperty(PropertyName = "firstSendDate")]
public DateTime FirstSeenDate { get; set; }
[JsonProperty(PropertyName = "fees")]
public Money Fees { get; set; }
}
public class TransactionDetails
{
[JsonProperty(PropertyName = "transactionId")]
public string TransactionId { get; set; }
[JsonProperty(PropertyName = "index")]
public int Index { get; set; }
[JsonProperty(PropertyName = "value")]
public int Value { get; set; }
[JsonProperty(PropertyName = "scriptPubKey")]
public string ScriptPubKey { get; set; }
[JsonProperty(PropertyName = "redeemScript")]
public string RedeemScript { get; set; }
}
} }
...@@ -125,6 +125,23 @@ namespace Breeze.Wallet ...@@ -125,6 +125,23 @@ namespace Breeze.Wallet
var index = unusedAccounts.Min(a => a.Index); var index = unusedAccounts.Min(a => a.Index);
return unusedAccounts.Single(a => a.Index == index); return unusedAccounts.Single(a => a.Index == index);
} }
/// <summary>
/// Gets the account matching the name passed as a parameter.
/// </summary>
/// <param name="accountName">The name of the account to get.</param>
/// <returns></returns>
/// <exception cref="System.Exception"></exception>
public HdAccount GetAccountByName(string accountName)
{
// get the account
HdAccount account = this.Accounts.SingleOrDefault(a => a.Name == accountName);
if (account == null)
{
throw new Exception($"No account with name {accountName} could be found.");
}
return account;
}
} }
/// <summary> /// <summary>
...@@ -216,9 +233,28 @@ namespace Breeze.Wallet ...@@ -216,9 +233,28 @@ namespace Breeze.Wallet
/// Gets the first receiving address that contains no transaction. /// Gets the first receiving address that contains no transaction.
/// </summary> /// </summary>
/// <returns>An unused address</returns> /// <returns>An unused address</returns>
public HdAddress GetFirstUnusedExternalAddress() public HdAddress GetFirstUnusedReceivingAddress()
{
return this.GetFirstUnusedAddress(false);
}
/// <summary>
/// Gets the first change address that contains no transaction.
/// </summary>
/// <returns>An unused address</returns>
public HdAddress GetFirstUnusedChangeAddress()
{
return this.GetFirstUnusedAddress(true);
}
/// <summary>
/// Gets the first receiving address that contains no transaction.
/// </summary>
/// <returns>An unused address</returns>
private HdAddress GetFirstUnusedAddress(bool isChange)
{ {
var unusedAddresses = this.ExternalAddresses.Where(acc => !acc.Transactions.Any()).ToList(); IEnumerable<HdAddress> addresses = isChange ? this.InternalAddresses : this.ExternalAddresses;
var unusedAddresses = addresses.Where(acc => !acc.Transactions.Any()).ToList();
if (!unusedAddresses.Any()) if (!unusedAddresses.Any())
{ {
return null; return null;
...@@ -258,6 +294,27 @@ namespace Breeze.Wallet ...@@ -258,6 +294,27 @@ namespace Breeze.Wallet
var addresses = this.ExternalAddresses.Concat(this.InternalAddresses); var addresses = this.ExternalAddresses.Concat(this.InternalAddresses);
return addresses.SelectMany(a => a.Transactions.Where(t => t.Id == id)); return addresses.SelectMany(a => a.Transactions.Where(t => t.Id == id));
} }
/// <summary>
/// Gets a collection of transactions with spendable outputs.
/// </summary>
/// <returns></returns>
public IEnumerable<TransactionData> GetSpendableTransactions()
{
var addresses = this.ExternalAddresses.Concat(this.InternalAddresses);
return addresses.SelectMany(a => a.Transactions.Where(t => t.SpentInTransaction == null && t.Amount > Money.Zero));
}
/// <summary>
/// Finds the address in which the transaction is contained.
/// </summary>
/// <param name="transactionId">The transaction identifier.</param>
/// <returns></returns>
public HdAddress FindAddressForTransaction(uint256 transactionId)
{
var addresses = this.ExternalAddresses.Concat(this.InternalAddresses);
return addresses.SingleOrDefault(a => a.Transactions.Any(t => t.Id == transactionId));
}
} }
/// <summary> /// <summary>
...@@ -353,5 +410,6 @@ namespace Breeze.Wallet ...@@ -353,5 +410,6 @@ namespace Breeze.Wallet
[JsonProperty(PropertyName = "creationTime")] [JsonProperty(PropertyName = "creationTime")]
[JsonConverter(typeof(DateTimeOffsetConverter))] [JsonConverter(typeof(DateTimeOffsetConverter))]
public DateTimeOffset CreationTime { get; set; } public DateTimeOffset CreationTime { get; set; }
} }
} }
\ No newline at end of file
...@@ -6,6 +6,7 @@ using System.Runtime.InteropServices; ...@@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using Breeze.Wallet.Helpers; using Breeze.Wallet.Helpers;
using Breeze.Wallet.Models; using Breeze.Wallet.Models;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders;
using Newtonsoft.Json; using Newtonsoft.Json;
using Transaction = NBitcoin.Transaction; using Transaction = NBitcoin.Transaction;
...@@ -203,24 +204,20 @@ namespace Breeze.Wallet ...@@ -203,24 +204,20 @@ namespace Breeze.Wallet
return newAccount; return newAccount;
} }
/// <inheritdoc /> /// <inheritdoc />
public string GetUnusedAddress(string walletName, CoinType coinType, string accountName) public string GetUnusedAddress(string walletName, CoinType coinType, string accountName)
{ {
Wallet wallet = this.GetWalletByName(walletName); Wallet wallet = this.GetWalletByName(walletName);
// get the account // get the account
HdAccount account = wallet.AccountsRoot.Single(a => a.CoinType == coinType).Accounts.SingleOrDefault(a => a.Name == accountName); HdAccount account = wallet.AccountsRoot.Single(a => a.CoinType == coinType).GetAccountByName(accountName);
if (account == null)
{
throw new Exception($"No account with name {accountName} could be found.");
}
// validate address creation // validate address creation
if (account.ExternalAddresses.Any()) if (account.ExternalAddresses.Any())
{ {
// check last created address contains transactions. // check last created address contains transactions.
var firstUnusedExternalAddress = account.GetFirstUnusedExternalAddress(); var firstUnusedExternalAddress = account.GetFirstUnusedReceivingAddress();
if (firstUnusedExternalAddress != null) if (firstUnusedExternalAddress != null)
{ {
return firstUnusedExternalAddress.Address; return firstUnusedExternalAddress.Address;
...@@ -235,7 +232,7 @@ namespace Breeze.Wallet ...@@ -235,7 +232,7 @@ namespace Breeze.Wallet
// adds the address to the list of tracked addresses // adds the address to the list of tracked addresses
this.PubKeys = this.LoadKeys(coinType); this.PubKeys = this.LoadKeys(coinType);
return account.GetFirstUnusedExternalAddress().Address; return account.GetFirstUnusedReceivingAddress().Address;
} }
/// <inheritdoc /> /// <inheritdoc />
...@@ -284,7 +281,7 @@ namespace Breeze.Wallet ...@@ -284,7 +281,7 @@ namespace Breeze.Wallet
for (int i = firstNewAddressIndex; i < firstNewAddressIndex + addressesQuantity; i++) for (int i = firstNewAddressIndex; i < firstNewAddressIndex + addressesQuantity; i++)
{ {
// generate new receiving address // generate new receiving address
BitcoinPubKeyAddress address = this.GenerateAddress(account.ExtendedPubKey, i, false, network); BitcoinPubKeyAddress address = this.GenerateAddress(account.ExtendedPubKey, i, isChange, network);
// add address details // add address details
addresses = addresses.Concat(new[] {new HdAddress addresses = addresses.Concat(new[] {new HdAddress
...@@ -324,9 +321,94 @@ namespace Breeze.Wallet ...@@ -324,9 +321,94 @@ namespace Breeze.Wallet
return wallet.GetAccountsByCoinType(coinType); return wallet.GetAccountsByCoinType(coinType);
} }
public WalletBuildTransactionModel BuildTransaction(string password, string address, Money amount, string feeType, bool allowUnconfirmed) /// <inheritdoc />
public Transaction BuildTransaction(string walletName, string accountName, CoinType coinType, string password, string destinationAddress, Money amount, string feeType, bool allowUnconfirmed)
{ {
throw new System.NotImplementedException(); if (amount == Money.Zero)
{
throw new Exception($"Cannot send transaction with 0 {this.coinType}");
}
// get the wallet and the account
Wallet wallet = this.GetWalletByName(walletName);
HdAccount account = wallet.AccountsRoot.Single(a => a.CoinType == coinType).GetAccountByName(accountName);
// get a list of transactions outputs that have not been spent
IEnumerable<TransactionData> spendableTransactions = account.GetSpendableTransactions();
// get total spendable balance in the account.
var balance = spendableTransactions.Sum(t => t.Amount);
// make sure we have enough funds
if (balance < amount)
{
throw new Exception("Not enough funds.");
}
// calculate which addresses needs to be used as well as the fee to be charged
var calculationResult = this.CalculateFees(spendableTransactions, amount);
// get extended private key
var privateKey = Key.Parse(wallet.EncryptedSeed, password, wallet.Network);
var seedExtKey = new ExtKey(privateKey, wallet.ChainCode);
var signingKeys = new HashSet<ISecret>();
var coins = new List<Coin>();
foreach (var transactionToUse in calculationResult.transactionsToUse)
{
var address = account.FindAddressForTransaction(transactionToUse.Id);
ExtKey addressExtKey = seedExtKey.Derive(new KeyPath(address.HdPath));
BitcoinExtKey addressPrivateKey = addressExtKey.GetWif(wallet.Network);
signingKeys.Add(addressPrivateKey);
coins.Add(new Coin(transactionToUse.Id, (uint)transactionToUse.Index, transactionToUse.Amount, address.ScriptPubKey));
}
// get address to send the change to
var changeAddress = account.GetFirstUnusedChangeAddress();
// get script destination address
Script destinationScript = PayToPubkeyHashTemplate.Instance.GenerateScriptPubKey(new BitcoinPubKeyAddress(destinationAddress, wallet.Network));
// build transaction
var builder = new TransactionBuilder();
Transaction tx = builder
.AddCoins(coins)
.AddKeys(signingKeys.ToArray())
.Send(destinationScript, amount)
.SetChange(changeAddress.ScriptPubKey)
.SendFees(calculationResult.fee)
.BuildTransaction(true);
if (!builder.Verify(tx))
{
throw new Exception("Could not build transaction, please make sure you entered the correct data.");
}
return tx;
}
/// <summary>
/// Calculates which outputs are to be used in the transaction, as well as the fees that will be charged.
/// </summary>
/// <param name="spendableTransactions">The transactions with unspent funds.</param>
/// <param name="amount">The amount to be sent.</param>
/// <returns>The collection of transactions to be used and the fee to be charged</returns>
private (List<TransactionData> transactionsToUse, Money fee) CalculateFees(IEnumerable<TransactionData> spendableTransactions, Money amount)
{
// TODO make this a bit smarter!
List<TransactionData> transactionsToUse = new List<TransactionData>();
foreach (var transaction in spendableTransactions)
{
transactionsToUse.Add(transaction);
if (transactionsToUse.Sum(t => t.Amount) >= amount)
{
break;
}
}
Money fee = new Money(new decimal(0.001), MoneyUnit.BTC);
return (transactionsToUse, fee);
} }
public bool SendTransaction(string transactionHex) public bool SendTransaction(string transactionHex)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment