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
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.
## 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
```
{
"walletName": "myFirstWallet",
"accountName": "account 0",
"coinType": 0,
"password": "password",
"address": "1Xyz...",
"destinationAddress": "1Xyz...",
"amount": "0.12", // in btc, if 0, then spends all available
"feeType": "low", // "low"/"medium"/"high"
"allowUnconfirmed": true // if spending unconfirmed outputs is allowed
......@@ -280,51 +283,8 @@ Unconfirmed balance is the difference of unconfirmed incoming and outgoing trans
#### 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",
"feePercentOfSent": "0.1" // Percentage of the total spent amount, there must be a safety limit implemented here
"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
}
"hex": "0100000002d9dced2b6fc80c706d3564670cb6706afe7a798863a9218efcdcf415d58f0f82000000006a473044022030b8bea478444bd52f08de33b082cde1176d3137111f506eefefa91b47b1f6bf02204f12746abd1aeac5805872d163592cf145967fa0619339a9c5348d674852ef4801210224ec1e4c270ce373e6999eebfa01d0a7e7db3c537c026f265233350d5aab81fbfeffffffa0706db65c5e3594d43df5a2a8b6dfd3c9ee506b678f8c26f7820b324b26aa0f000000006a473044022061b718034f876590d6d80bac77a63248b2548d934849acd02c4f4236309e853002201aded6b24f553b6902cf571276b37b12f76b75650164d8738c74469b4edd547e012103d649294a0ca4db920a69eacd6a75cb8a38ae1b81129900621ce45e6ba3438a7bfeffffff0280a90300000000001976a914d0965947ebb329b776328624ebde8f8b32dc639788ac1cc80f00000000001976a914c2a420d34fc86cff932b8c3191549a0ddfd2b0d088acba770f00"
}
```
......
......@@ -17,7 +17,8 @@
<ItemGroup>
<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" />
</ItemGroup>
......
......@@ -287,8 +287,14 @@ namespace Breeze.Wallet.Controllers
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)
{
......
......@@ -123,8 +123,20 @@ namespace Breeze.Wallet
/// <returns></returns>
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);
/// <summary>
......
......@@ -79,11 +79,20 @@ namespace Breeze.Wallet.Models
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.")]
public string Password { get; set; }
[Required(ErrorMessage = "A destination address is required.")]
public string Address { get; set; }
public string DestinationAddress { get; set; }
[Required(ErrorMessage = "An amount is required.")]
public string Amount { get; set; }
......
......@@ -8,62 +8,11 @@ using Newtonsoft.Json;
namespace Breeze.Wallet.Models
{
public class WalletBuildTransactionModel
{
[JsonProperty(PropertyName = "spendsUnconfirmed")]
public bool SpendsUnconfirmed { get; set; }
{
[JsonProperty(PropertyName = "fee")]
public Money Fee { get; set; }
[JsonProperty(PropertyName = "feePercentOfSent")]
public decimal FeePercentOfSent { get; set; }
[JsonProperty(PropertyName = "hex")]
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; }
}
public string Hex { get; set; }
}
}
......@@ -125,6 +125,23 @@ namespace Breeze.Wallet
var index = unusedAccounts.Min(a => a.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>
......@@ -216,9 +233,28 @@ namespace Breeze.Wallet
/// Gets the first receiving address that contains no transaction.
/// </summary>
/// <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())
{
return null;
......@@ -258,6 +294,27 @@ namespace Breeze.Wallet
var addresses = this.ExternalAddresses.Concat(this.InternalAddresses);
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>
......@@ -353,5 +410,6 @@ namespace Breeze.Wallet
[JsonProperty(PropertyName = "creationTime")]
[JsonConverter(typeof(DateTimeOffsetConverter))]
public DateTimeOffset CreationTime { get; set; }
}
}
\ No newline at end of file
......@@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using Breeze.Wallet.Helpers;
using Breeze.Wallet.Models;
using NBitcoin;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Transaction = NBitcoin.Transaction;
......@@ -203,24 +204,20 @@ namespace Breeze.Wallet
return newAccount;
}
/// <inheritdoc />
public string GetUnusedAddress(string walletName, CoinType coinType, string accountName)
{
Wallet wallet = this.GetWalletByName(walletName);
// get the account
HdAccount account = wallet.AccountsRoot.Single(a => a.CoinType == coinType).Accounts.SingleOrDefault(a => a.Name == accountName);
if (account == null)
{
throw new Exception($"No account with name {accountName} could be found.");
}
HdAccount account = wallet.AccountsRoot.Single(a => a.CoinType == coinType).GetAccountByName(accountName);
// validate address creation
if (account.ExternalAddresses.Any())
{
// check last created address contains transactions.
var firstUnusedExternalAddress = account.GetFirstUnusedExternalAddress();
var firstUnusedExternalAddress = account.GetFirstUnusedReceivingAddress();
if (firstUnusedExternalAddress != null)
{
return firstUnusedExternalAddress.Address;
......@@ -235,7 +232,7 @@ namespace Breeze.Wallet
// adds the address to the list of tracked addresses
this.PubKeys = this.LoadKeys(coinType);
return account.GetFirstUnusedExternalAddress().Address;
return account.GetFirstUnusedReceivingAddress().Address;
}
/// <inheritdoc />
......@@ -284,7 +281,7 @@ namespace Breeze.Wallet
for (int i = firstNewAddressIndex; i < firstNewAddressIndex + addressesQuantity; i++)
{
// 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
addresses = addresses.Concat(new[] {new HdAddress
......@@ -324,9 +321,94 @@ namespace Breeze.Wallet
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)
......
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