Commit b0838544 authored by Jeremy Bokobza's avatar Jeremy Bokobza Committed by GitHub

Merge pull request #34 from bokobza/feature/walletmanager

Creates accounts and create addresses
parents f24e1da0 a48af926
......@@ -61,6 +61,8 @@ POST /wallet/create - Creates the wallet
POST /wallet/load - Loads the wallet and starts syncing
POST /wallet/recover - Recovers the wallet
DELETE /wallet - Deletes the wallet
POST /wallet/account
POST /wallet/address
```
## Syncing
......@@ -165,6 +167,35 @@ Cannot check if the password is good or not. If the password is wrong it'll reco
## DELETE /wallet - Deletes the wallet
Works as expected.
## POST /wallet/account - Adds an account to the wallet
### Parameters
```
{
"walletName": "myFirstWallet",
"accountName": "account one",
"password": "123456",
"coinType": 105
}
```
### Responses
```
"account one"
```
## POST /wallet/address - Adds an address to an account
### Parameters
```
{
"walletName": "myFirstWallet",
"accountName": "account one",
"coinType": 0
}
```
### Responses
```
"1HDypWxXWZC5KXK259EHMnrWaa2youy7Mj"
```
## GET /wallet/mempool/?allow=[true/false] - Allows or disallows mempool syncing
Works as expected.
......
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.AspNetCore.Mvc;
using Xunit;
using Moq;
using Breeze.Wallet.Wrappers;
using Breeze.Wallet;
using Breeze.Wallet.Controllers;
using Breeze.Wallet.Errors;
using Breeze.Wallet.Helpers;
using Breeze.Wallet.Models;
using NBitcoin;
namespace Breeze.Api.Tests
{
......@@ -16,8 +18,9 @@ namespace Breeze.Api.Tests
[Fact]
public void CreateWalletSuccessfullyReturnsMnemonic()
{
var mockWalletCreate = new Mock<IWalletWrapper>();
mockWalletCreate.Setup(wallet => wallet.Create(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns("mnemonic");
Mnemonic mnemonic = new Mnemonic(Wordlist.English, WordCount.Twelve);
var mockWalletCreate = new Mock<IWalletManager>();
mockWalletCreate.Setup(wallet => wallet.CreateWallet(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), null)).Returns(mnemonic);
var controller = new WalletController(mockWalletCreate.Object);
......@@ -33,32 +36,31 @@ namespace Breeze.Api.Tests
// Assert
mockWalletCreate.VerifyAll();
var viewResult = Assert.IsType<JsonResult>(result);
Assert.Equal("mnemonic", viewResult.Value);
Assert.Equal(mnemonic.ToString(), viewResult.Value);
Assert.NotNull(result);
}
[Fact]
public void LoadWalletSuccessfullyReturnsWalletModel()
{
WalletModel walletModel = new WalletModel
Wallet.Wallet wallet = new Wallet.Wallet
{
FileName = "myWallet",
Network = "MainNet",
Addresses = new List<string> { "address1", "address2", "address3", "address4", "address5" }
Name = "myWallet",
Network = WalletHelpers.GetNetwork("mainnet")
};
var mockWalletWrapper = new Mock<IWalletWrapper>();
mockWalletWrapper.Setup(wallet => wallet.Recover(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(walletModel);
var mockWalletWrapper = new Mock<IWalletManager>();
mockWalletWrapper.Setup(w => w.RecoverWallet(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), null, null)).Returns(wallet);
var controller = new WalletController(mockWalletWrapper.Object);
// Act
var result = controller.Recover(new WalletRecoveryRequest
{
Name = "myName",
Name = "myWallet",
FolderPath = "",
Password = "",
Network = "",
Network = "MainNet",
Mnemonic = "mnemonic"
});
......@@ -69,28 +71,26 @@ namespace Breeze.Api.Tests
Assert.IsType<WalletModel>(viewResult.Value);
var model = viewResult.Value as WalletModel;
Assert.Equal("myWallet", model.FileName);
Assert.Equal("Main", model.Network);
}
[Fact]
public void RecoverWalletSuccessfullyReturnsWalletModel()
{
WalletModel walletModel = new WalletModel
Wallet.Wallet wallet = new Wallet.Wallet
{
FileName = "myWallet",
Network = "MainNet",
Addresses = new List<string> { "address1", "address2", "address3", "address4", "address5" }
Name = "myWallet",
Network = WalletHelpers.GetNetwork("mainnet")
};
var mockWalletWrapper = new Mock<IWalletWrapper>();
mockWalletWrapper.Setup(wallet => wallet.Load(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(walletModel);
var mockWalletWrapper = new Mock<IWalletManager>();
mockWalletWrapper.Setup(w => w.LoadWallet(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Returns(wallet);
var controller = new WalletController(mockWalletWrapper.Object);
// Act
var result = controller.Load(new WalletLoadRequest
{
Name = "myName",
Name = "myWallet",
FolderPath = "",
Password = ""
});
......@@ -102,14 +102,14 @@ namespace Breeze.Api.Tests
Assert.IsType<WalletModel>(viewResult.Value);
var model = viewResult.Value as WalletModel;
Assert.Equal("myWallet", model.FileName);
Assert.Equal("Main", model.Network);
}
[Fact]
public void FileNotFoundExceptionandReturns404()
{
var mockWalletWrapper = new Mock<IWalletWrapper>();
mockWalletWrapper.Setup(wallet => wallet.Load(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Throws<FileNotFoundException>();
var mockWalletWrapper = new Mock<IWalletManager>();
mockWalletWrapper.Setup(wallet => wallet.LoadWallet(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>())).Throws<FileNotFoundException>();
var controller = new WalletController(mockWalletWrapper.Object);
......
......@@ -2,7 +2,7 @@
"variables": [],
"info": {
"name": "Wallet",
"_postman_id": "25ac0bc5-23f5-e00d-932f-a8fde7787e7a",
"_postman_id": "5eec0912-fcf0-50f5-05a2-0835fa13c670",
"description": "Requests relating to operations on the wallet",
"schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json"
},
......@@ -206,6 +206,66 @@
"description": "Gets all the wallets files stored in the default folder"
},
"response": []
},
{
"name": "New account for non-existant wallet fails",
"request": {
"url": "http://localhost:5000/api/v1/wallet/account",
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"description": ""
}
],
"body": {
"mode": "raw",
"raw": "{\n\t\"walletName\": \"myFirstWallet\",\n\t\"accountName\": \"account one\"\n}"
},
"description": ""
},
"response": []
},
{
"name": "Create new account for wallet",
"request": {
"url": "http://localhost:5000/api/v1/wallet/account",
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"description": ""
}
],
"body": {
"mode": "raw",
"raw": "{\n\t\"walletName\": \"myFirstWallet\",\n\t\"accountName\": \"account one\",\n\t\"password\": \"123456\",\n\t\"coinType\": 105\n}"
},
"description": ""
},
"response": []
},
{
"name": "Create new address for wallet",
"request": {
"url": "http://localhost:5000/api/v1/wallet/address",
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"description": ""
}
],
"body": {
"mode": "raw",
"raw": "{\n\t\"walletName\": \"myFirstWallet\",\n\t\"accountName\": \"account one\",\n\t\"coinType\": 0\n}"
},
"description": ""
},
"response": []
}
]
}
\ No newline at end of file
......@@ -16,6 +16,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ConcurrentHashSet" Version="1.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="1.0.3" />
<PackageReference Include="Stratis.Bitcoin" Version="1.0.1.5-alpha" />
</ItemGroup>
......
......@@ -8,6 +8,7 @@ using Breeze.Wallet.Errors;
using Microsoft.AspNetCore.Mvc;
using Breeze.Wallet.Models;
using Breeze.Wallet.Wrappers;
using NBitcoin;
namespace Breeze.Wallet.Controllers
{
......@@ -17,11 +18,11 @@ namespace Breeze.Wallet.Controllers
[Route("api/v{version:apiVersion}/[controller]")]
public class WalletController : Controller
{
private readonly IWalletWrapper walletWrapper;
private readonly IWalletManager walletManager;
public WalletController(IWalletWrapper walletWrapper)
public WalletController(IWalletManager walletManager)
{
this.walletWrapper = walletWrapper;
this.walletManager = walletManager;
}
/// <summary>
......@@ -44,9 +45,9 @@ namespace Breeze.Wallet.Controllers
{
// get the wallet folder
DirectoryInfo walletFolder = GetWalletFolder(request.FolderPath);
Mnemonic mnemonic = this.walletManager.CreateWallet(request.Password, walletFolder.FullName, request.Name, request.Network);
var mnemonic = this.walletWrapper.Create(request.Password, walletFolder.FullName, request.Name, request.Network);
return this.Json(mnemonic);
return this.Json(mnemonic.ToString());
}
catch (InvalidOperationException e)
{
......@@ -76,9 +77,13 @@ namespace Breeze.Wallet.Controllers
// get the wallet folder
DirectoryInfo walletFolder = GetWalletFolder(request.FolderPath);
var wallet = this.walletWrapper.Load(request.Password, walletFolder.FullName, request.Name);
return this.Json(wallet);
Wallet wallet = this.walletManager.LoadWallet(request.Password, walletFolder.FullName, request.Name);
return this.Json(new WalletModel
{
Network = wallet.Network.Name,
// Addresses = wallet.GetFirstNAddresses(10).Select(a => a.ToWif()),
FileName = wallet.WalletFilePath
});
}
catch (FileNotFoundException e)
{
......@@ -116,8 +121,13 @@ namespace Breeze.Wallet.Controllers
// get the wallet folder
DirectoryInfo walletFolder = GetWalletFolder(request.FolderPath);
var wallet = this.walletWrapper.Recover(request.Password, walletFolder.FullName, request.Name, request.Network, request.Mnemonic);
return this.Json(wallet);
Wallet wallet = this.walletManager.RecoverWallet(request.Password, walletFolder.FullName, request.Name, request.Network, request.Mnemonic);
return this.Json(new WalletModel
{
Network = wallet.Network.Name,
// Addresses = wallet.GetFirstNAddresses(10).Select(a => a.ToWif()),
FileName = wallet.WalletFilePath
});
}
catch (InvalidOperationException e)
{
......@@ -153,7 +163,7 @@ namespace Breeze.Wallet.Controllers
try
{
return this.Json(this.walletWrapper.GetGeneralInfo(model.Name));
return this.Json(this.walletManager.GetGeneralInfo(model.Name));
}
catch (Exception e)
......@@ -180,7 +190,7 @@ namespace Breeze.Wallet.Controllers
try
{
return this.Json(this.walletWrapper.GetHistory(model.Name));
return this.Json(this.walletManager.GetHistory(model.Name));
}
catch (Exception e)
......@@ -207,7 +217,7 @@ namespace Breeze.Wallet.Controllers
try
{
return this.Json(this.walletWrapper.GetBalance(model.Name));
return this.Json(this.walletManager.GetBalance(model.Name));
}
catch (Exception e)
......@@ -234,7 +244,7 @@ namespace Breeze.Wallet.Controllers
try
{
return this.Json(this.walletWrapper.BuildTransaction(request.Password, request.Address, request.Amount, request.FeeType, request.AllowUnconfirmed));
return this.Json(this.walletManager.BuildTransaction(request.Password, request.Address, request.Amount, request.FeeType, request.AllowUnconfirmed));
}
catch (Exception e)
......@@ -261,7 +271,7 @@ namespace Breeze.Wallet.Controllers
try
{
var result = this.walletWrapper.SendTransaction(request.Hex);
var result = this.walletManager.SendTransaction(request.Hex);
if (result)
{
return this.Ok();
......@@ -301,6 +311,58 @@ namespace Breeze.Wallet.Controllers
}
}
/// <summary>
/// Creates a new account for a wallet.
/// </summary>
/// <returns>An account name.</returns>
[Route("account")]
[HttpPost]
public IActionResult CreateNewAccount([FromBody]CreateAccountModel request)
{
// checks the request is valid
if (!this.ModelState.IsValid)
{
var 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
{
var result = this.walletManager.CreateNewAccount(request.WalletName, request.CoinType, request.AccountName, request.Password);
return this.Json(result);
}
catch (Exception e)
{
return ErrorHelpers.BuildErrorResponse(HttpStatusCode.BadRequest, e.Message, e.ToString());
}
}
/// <summary>
/// Creates a new address for a wallet.
/// </summary>
/// <returns>An address in Base58 format.</returns>
[Route("address")]
[HttpPost]
public IActionResult CreateNewAddress([FromBody]CreateAddressModel request)
{
// checks the request is valid
if (!this.ModelState.IsValid)
{
var 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
{
var result = this.walletManager.CreateNewAddress(request.WalletName, request.CoinType, request.AccountName);
return this.Json(result);
}
catch (Exception e)
{
return ErrorHelpers.BuildErrorResponse(HttpStatusCode.BadRequest, e.Message, e.ToString());
}
}
/// <summary>
/// Gets a folder.
/// </summary>
......
using System;
using Breeze.Wallet.Models;
using NBitcoin;
namespace Breeze.Wallet
......@@ -12,36 +13,72 @@ namespace Breeze.Wallet
/// Creates a wallet and persist it as a file on the local system.
/// </summary>
/// <param name="password">The password used to encrypt sensitive info.</param>
/// <param name="passphrase">The passphrase used in the seed.</param>
/// <param name="walletFilePath">The path where the wallet file will be created.</param>
/// <param name="folderPath">The folder where the wallet will be saved.</param>
/// <param name="name">The name of the wallet.</param>
/// <param name="network">The network this wallet is for.</param>
/// <param name="passphrase">The passphrase used in the seed.</param>
/// <returns>A mnemonic defining the wallet's seed used to generate addresses.</returns>
Mnemonic CreateWallet(string password, string walletFilePath, Network network, string passphrase = null);
Mnemonic CreateWallet(string password, string folderPath, string name, string network, string passphrase = null);
/// <summary>
/// Loads a wallet from a file.
/// </summary>
/// <param name="password">The password used to encrypt sensitive info.</param>
/// <param name="walletFilePath">The location of the wallet file.</param>
/// <param name="password">The user's password.</param>
/// <param name="folderPath">The folder where the wallet will be loaded.</param>
/// <param name="name">The name of the wallet.</param>
/// <returns>The wallet.</returns>
Wallet LoadWallet(string password, string walletFilePath);
Wallet LoadWallet(string password, string folderPath, string name);
/// <summary>
/// Recovers a wallet.
/// </summary>
/// <param name="mnemonic">A mnemonic defining the wallet's seed used to generate addresses.</param>
/// <param name="password">The password used to encrypt sensitive info.</param>
/// <param name="walletFilePath">The location of the wallet file.</param>
/// <param name="network">The network this wallet is for.</param>
/// <param name="password">The user's password.</param>
/// <param name="folderPath">The folder where the wallet will be loaded.</param>
/// <param name="name">The name of the wallet.</param>
/// <param name="network">The network in which to creae this wallet</param>
/// <param name="mnemonic">The user's mnemonic for the wallet.</param>
/// <param name="passphrase">The passphrase used in the seed.</param>
/// <param name="creationTime">The time this wallet was created.</param>
/// <returns>The recovered wallet.</returns>
Wallet RecoverWallet(Mnemonic mnemonic, string password, string walletFilePath, Network network, string passphrase = null, DateTimeOffset? creationTime = null);
Wallet RecoverWallet(string password, string folderPath, string name, string network, string mnemonic, string passphrase = null, DateTimeOffset? creationTime = null);
/// <summary>
/// Deleted a wallet.
/// </summary>
/// <param name="walletFilePath">The location of the wallet file.</param>
void DeleteWallet(string walletFilePath);
/// <summary>
/// Creates a new account.
/// </summary>
/// <param name="walletName">The name of the wallet in which this account will be created.</param>
/// <param name="coinType">the type of coin for which to create an account.</param>
/// <param name="accountName">The name by which this account will be identified.</param>
/// <param name="password">The password used to decrypt the private key.</param>
/// <remarks>
/// According to BIP44, an account at index (i) can only be created when the account
/// at index (i - 1) contains transactions.
/// </remarks>
/// <returns>The name of the new account.</returns>
string CreateNewAccount(string walletName, CoinType coinType, string accountName, string password);
/// <summary>
/// Creates the new address.
/// </summary>
/// <param name="walletName">The name of the wallet in which this address will be created.</param>
/// <param name="coinType">the type of coin for which to create an account.</param>
/// <param name="accountName">The name of the account in which this address will be created.</param>
/// <returns>The new address, in Base58 format.</returns>
string CreateNewAddress(string walletName, CoinType coinType, string accountName);
WalletGeneralInfoModel GetGeneralInfo(string walletName);
WalletBalanceModel GetBalance(string walletName);
WalletHistoryModel GetHistory(string walletName);
WalletBuildTransactionModel BuildTransaction(string password, string address, Money amount, string feeType, bool allowUnconfirmed);
bool SendTransaction(string transactionHex);
}
}
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text;
namespace Breeze.Wallet.Models
{
public class CreateAccountModel
{
/// <summary>
/// The name of the wallet in which to create the account.
/// </summary>
[Required]
public string WalletName { get; set; }
/// <summary>
/// The type of coin this account contains.
/// </summary>
[Required]
public CoinType CoinType { get; set; }
/// <summary>
/// The name of the account.
/// </summary>
[Required]
public string AccountName { get; set; }
/// <summary>
/// The password for this wallet.
/// </summary>
[Required]
public string Password { get; set; }
}
}
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Text;
namespace Breeze.Wallet.Models
{
public class CreateAddressModel
{
/// <summary>
/// The name of the wallet in which to create the address.
/// </summary>
[Required]
public string WalletName { get; set; }
/// <summary>
/// The type of coin this account contains.
/// </summary>
[Required]
public CoinType CoinType { get; set; }
/// <summary>
/// The name of the account in which to create the address.
/// </summary>
[Required]
public string AccountName { get; set; }
}
}
//from https://github.com/brianchance/MonoTouchMVVMCrossValidationTester/blob/master/Validation.Core/ConcurrentObservableDictionary.cs
//modified
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.ComponentModel;
using System.Collections.Generic;
using System.Collections.Specialized;
namespace System.Collections.ObjectModel
{
public class ConcurrentObservableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, INotifyCollectionChanged, INotifyPropertyChanged
{
private const string CountString = "Count";
private const string IndexerName = "Item[]";
private const string KeysName = "Keys";
private const string ValuesName = "Values";
private readonly object Lock = new object();
protected ConcurrentDictionary<TKey, TValue> ConcurrentDictionary { get; private set; }
#region Constructors
public ConcurrentObservableDictionary()
{
ConcurrentDictionary = new ConcurrentDictionary<TKey, TValue>();
}
public ConcurrentObservableDictionary(ConcurrentDictionary<TKey, TValue> dictionary)
{
ConcurrentDictionary = new ConcurrentDictionary<TKey, TValue>(dictionary);
}
public ConcurrentObservableDictionary(IEqualityComparer<TKey> comparer)
{
ConcurrentDictionary = new ConcurrentDictionary<TKey, TValue>(comparer);
}
public ConcurrentObservableDictionary(IDictionary<TKey, TValue> dictionary, IEqualityComparer<TKey> comparer)
{
ConcurrentDictionary = new ConcurrentDictionary<TKey, TValue>(dictionary, comparer);
}
#endregion
#region IDictionary<TKey,TValue> Members
public void Add(TKey key, TValue value) => Insert(key, value, true);
public bool ContainsKey(TKey key) => ConcurrentDictionary.ContainsKey(key);
public ICollection<TKey> Keys => ConcurrentDictionary.Keys;
public bool Remove(TKey key) => Remove(key, suppressNotifications: false);
private bool Remove(TKey key, bool suppressNotifications)
{
lock(Lock)
{
TValue value;
var ret = ConcurrentDictionary.TryRemove(key, out value);
if(ret && !suppressNotifications) OnCollectionChanged();
return ret;
}
}
public bool TryGetValue(TKey key, out TValue value) => ConcurrentDictionary.TryGetValue(key, out value);
public ICollection<TValue> Values => ConcurrentDictionary.Values;
public TValue this[TKey key]
{
get
{
TValue value;
return TryGetValue(key, out value) ? value : default(TValue);
}
set
{
Insert(key, value, false);
}
}
#endregion
#region ICollection<KeyValuePair<TKey,TValue>> Members
public void Add(KeyValuePair<TKey, TValue> item) => Insert(item.Key, item.Value, true);
public void Clear()
{
lock(Lock)
{
if (ConcurrentDictionary.Count > 0)
{
ConcurrentDictionary.Clear();
OnCollectionChanged();
}
}
}
public bool Contains(KeyValuePair<TKey, TValue> item) => ConcurrentDictionary.Contains(item);
/// <summary>
/// NotImplementedException
/// </summary>
/// <param name="array"></param>
/// <param name="arrayIndex"></param>
public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
{
throw new NotImplementedException();
}
/// <summary>
/// NotImplementedException
/// </summary>
public bool IsReadOnly
{
get { throw new NotImplementedException(); }
}
public int Count => ConcurrentDictionary.Count;
public bool Remove(KeyValuePair<TKey, TValue> item) => Remove(item.Key);
#endregion
#region IEnumerable<KeyValuePair<TKey,TValue>> Members
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() => ConcurrentDictionary.GetEnumerator();
#endregion
#region IEnumerable Members
IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)ConcurrentDictionary).GetEnumerator();
#endregion
#region INotifyCollectionChanged Members
public event NotifyCollectionChangedEventHandler CollectionChanged;
#endregion
#region INotifyPropertyChanged Members
public event PropertyChangedEventHandler PropertyChanged;
#endregion
public void AddOrReplace(TKey key, TValue value)
{
if (ContainsKey(key))
{
Remove(key, suppressNotifications: true);
Add(key, value);
}
else
{
Add(key, value);
}
}
/// <summary>
/// NotImplementedException
/// </summary>
/// <param name="items"></param>
public void AddRange(IDictionary<TKey, TValue> items)
{
throw new NotImplementedException();
}
private void Insert(TKey key, TValue value, bool add)
{
lock(Lock)
{
if (key == null) throw new ArgumentNullException(nameof(key));
TValue item;
if (ConcurrentDictionary.TryGetValue(key, out item))
{
if (add) throw new ArgumentException("An item with the same key has already been added.");
if (Equals(item, value)) return;
ConcurrentDictionary[key] = value;
OnCollectionChanged(NotifyCollectionChangedAction.Replace, new KeyValuePair<TKey, TValue>(key, value), new KeyValuePair<TKey, TValue>(key, item));
OnPropertyChanged(key.ToString());
}
else
{
ConcurrentDictionary[key] = value;
OnCollectionChanged(NotifyCollectionChangedAction.Add, new KeyValuePair<TKey, TValue>(key, value));
OnPropertyChanged(key.ToString());
}
}
}
private void OnPropertyChanged()
{
OnPropertyChanged(CountString);
OnPropertyChanged(IndexerName);
OnPropertyChanged(KeysName);
OnPropertyChanged(ValuesName);
}
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private void OnCollectionChanged()
{
OnPropertyChanged();
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
private void OnCollectionChanged(NotifyCollectionChangedAction action, KeyValuePair<TKey, TValue> changedItem)
{
OnPropertyChanged();
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action, changedItem, 0));
}
private void OnCollectionChanged(NotifyCollectionChangedAction action, KeyValuePair<TKey, TValue> newItem, KeyValuePair<TKey, TValue> oldItem)
{
OnPropertyChanged();
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action, newItem, oldItem, 0));
}
private void OnCollectionChanged(NotifyCollectionChangedAction action, IList newItems)
{
OnPropertyChanged();
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(action, newItems, 0));
}
}
}
\ No newline at end of file
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Linq;
using System.ComponentModel;
using System.Collections.Generic;
using System.Collections.Specialized;
using ConcurrentCollections;
namespace System.Collections.ObjectModel
{
public class ConcurrentObservableHashSet<T> : INotifyCollectionChanged, IReadOnlyCollection<T>
{
protected ConcurrentHashSet<T> ConcurrentHashSet { get; }
private readonly object Lock = new object();
public ConcurrentObservableHashSet()
{
ConcurrentHashSet = new ConcurrentHashSet<T>();
}
public event NotifyCollectionChangedEventHandler CollectionChanged;
private void OnCollectionChanged()
{
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public IEnumerator<T> GetEnumerator() => ConcurrentHashSet.GetEnumerator();
public bool TryAdd(T item)
{
lock(Lock)
{
if(ConcurrentHashSet.Add(item))
{
OnCollectionChanged();
return true;
}
return false;
}
}
public void Clear()
{
lock(Lock)
{
if(ConcurrentHashSet.Count > 0)
{
ConcurrentHashSet.Clear();
OnCollectionChanged();
}
}
}
public bool Contains(T item) => ConcurrentHashSet.Contains(item);
public bool TryRemove(T item)
{
lock(Lock)
{
if(ConcurrentHashSet.TryRemove(item))
{
OnCollectionChanged();
return true;
}
return false;
}
}
public int Count => ConcurrentHashSet.Count;
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace HBitcoin.Models
{
public struct Height : IEquatable<Height>, IEquatable<int>, IComparable<Height>, IComparable<int>
{
public HeightType Type { get; }
private readonly int _value;
public int Value
{
get
{
if (Type == HeightType.Chain)
return _value;
if (Type == HeightType.MemPool)
return int.MaxValue - 1;
//if(Type == HeightType.Unknown)
return int.MaxValue;
}
}
public static Height MemPool => new Height(HeightType.MemPool);
public static Height Unknown => new Height(HeightType.Unknown);
public Height(int height)
{
if(height < 0) throw new ArgumentException($"{nameof(height)} : {height} cannot be < 0");
if(height == int.MaxValue) Type = HeightType.Unknown;
else if(height == int.MaxValue - 1) Type = HeightType.MemPool;
else Type = HeightType.Chain;
_value = height;
}
public Height(string heightOrHeightType)
{
var trimmed = heightOrHeightType.Trim();
if (trimmed == HeightType.MemPool.ToString())
this = MemPool;
else if (trimmed == HeightType.Unknown.ToString())
this = Unknown;
else this = new Height(int.Parse(trimmed));
}
public Height(HeightType type)
{
if(type == HeightType.Chain) throw new NotSupportedException($"For {type} height must be specified");
Type = type;
if (Type == HeightType.MemPool)
_value = int.MaxValue - 1;
else _value = int.MaxValue; // HeightType.Unknown
}
public override string ToString()
{
if(Type == HeightType.Chain) return Value.ToString();
else return Type.ToString();
}
#region EqualityAndComparison
public override bool Equals(object obj) => obj is Height && this == (Height) obj;
public bool Equals(Height other) => this == other;
public override int GetHashCode() => Value.GetHashCode();
public static bool operator ==(Height x, Height y) => x.Value == y.Value;
public static bool operator !=(Height x, Height y) => !(x == y);
public bool Equals(int other) => Value == other;
public static bool operator ==(int x, Height y) => x == y.Value;
public static bool operator ==(Height x, int y) => x.Value == y;
public static bool operator !=(int x, Height y) => !(x == y);
public static bool operator !=(Height x, int y) => !(x == y);
public int CompareTo(Height other) => Value.CompareTo(other.Value);
public int CompareTo(int other)
{
if (Value > other) return -1;
if (Value == other) return 0;
return 1;
}
public static bool operator >(Height x, Height y) => x.Value > y.Value;
public static bool operator <(Height x, Height y) => x.Value < y.Value;
public static bool operator >=(Height x, Height y) => x.Value >= y.Value;
public static bool operator <=(Height x, Height y) => x.Value <= y.Value;
public static bool operator >(int x, Height y) => x > y.Value;
public static bool operator >(Height x, int y) => x.Value > y;
public static bool operator <(int x, Height y) => x < y.Value;
public static bool operator <(Height x, int y) => x.Value < y;
public static bool operator >=(int x, Height y) => x >= y.Value;
public static bool operator <=(int x, Height y) => x <= y.Value;
public static bool operator >=(Height x, int y) => x.Value >= y;
public static bool operator <=(Height x, int y) => x.Value <= y;
#endregion
}
public enum HeightType
{
Chain,
MemPool,
Unknown
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using ConcurrentCollections;
using HBitcoin.Models;
using NBitcoin;
namespace HBitcoin.Models
{
public class SmartMerkleBlock : IEquatable<SmartMerkleBlock>, IComparable<SmartMerkleBlock>
{
#region Members
public Height Height { get; }
public MerkleBlock MerkleBlock { get; }
public IEnumerable<uint256> GetMatchedTransactions() => MerkleBlock.PartialMerkleTree.GetMatchedTransactions();
public uint TransactionCount => MerkleBlock.PartialMerkleTree.TransactionCount;
#endregion
#region Constructors
public SmartMerkleBlock()
{
}
public SmartMerkleBlock(Height height, Block block, params uint256[] interestedTransactionIds)
{
Height = height;
MerkleBlock = interestedTransactionIds == null || interestedTransactionIds.Length == 0 ? block.Filter() : block.Filter(interestedTransactionIds);
}
public SmartMerkleBlock(int height, Block block, params uint256[] interestedTransactionIds)
{
Height = new Height(height);
MerkleBlock = interestedTransactionIds == null || interestedTransactionIds.Length == 0 ? block.Filter() : block.Filter(interestedTransactionIds);
}
public SmartMerkleBlock(Height height, MerkleBlock merkleBlock)
{
Height = height;
MerkleBlock = merkleBlock;
}
#endregion
#region Formatting
public static byte[] ToBytes(SmartMerkleBlock smartMerkleBlock) =>
BitConverter.GetBytes(smartMerkleBlock.Height.Value) // 4bytes
.Concat(smartMerkleBlock.MerkleBlock.ToBytes())
.ToArray();
public byte[] ToBytes() => ToBytes(this);
public static SmartMerkleBlock FromBytes(byte[] bytes)
{
var heightBytes = bytes.Take(4).ToArray();
var merkleBlockBytes = bytes.Skip(4).ToArray();
var height = new Height(BitConverter.ToInt32(heightBytes, startIndex: 0));
// Bypass NBitcoin bug
var merkleBlock = new MerkleBlock();
if(!merkleBlock.ToBytes().SequenceEqual(merkleBlockBytes)) // if not default MerkleBlock
{
merkleBlock.FromBytes(merkleBlockBytes);
}
return new SmartMerkleBlock(height, merkleBlock);
}
#endregion
#region EqualityAndComparison
public override bool Equals(object obj) => obj is SmartMerkleBlock && this == (SmartMerkleBlock)obj;
public bool Equals(SmartMerkleBlock other) => this == other;
public override int GetHashCode()
{
var hash = Height.GetHashCode();
hash = hash ^ MerkleBlock.Header.GetHash().GetHashCode();
hash = hash ^ MerkleBlock.Header.HashPrevBlock.GetHashCode();
hash = hash ^ MerkleBlock.Header.HashMerkleRoot.GetHashCode();
foreach(uint256 txhash in GetMatchedTransactions())
hash = hash ^ txhash.GetHashCode();
return hash;
}
public static bool operator ==(SmartMerkleBlock x, SmartMerkleBlock y)
{
if (x.Height != y.Height)
return false;
if(x.MerkleBlock.Header.GetHash() != y.MerkleBlock.Header.GetHash())
return false;
if (x.MerkleBlock.Header.HashPrevBlock != y.MerkleBlock.Header.HashPrevBlock)
return false;
if (x.MerkleBlock.Header.HashMerkleRoot != y.MerkleBlock.Header.HashMerkleRoot)
return false;
if (x.TransactionCount != y.TransactionCount)
return false;
if(x.TransactionCount == 0) return true;
if (!x.GetMatchedTransactions().SequenceEqual(y.GetMatchedTransactions()))
return false;
return true;
}
public static bool operator !=(SmartMerkleBlock x, SmartMerkleBlock y) => !(x == y);
public int CompareTo(SmartMerkleBlock other) => Height.CompareTo(other.Height);
public static bool operator >(SmartMerkleBlock x, SmartMerkleBlock y) => x.Height > y.Height;
public static bool operator <(SmartMerkleBlock x, SmartMerkleBlock y) => x.Height < y.Height;
public static bool operator >=(SmartMerkleBlock x, SmartMerkleBlock y) => x.Height >= y.Height;
public static bool operator <=(SmartMerkleBlock x, SmartMerkleBlock y) => x.Height <= y.Height;
#endregion
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using HBitcoin.Models;
using NBitcoin;
namespace HBitcoin.Models
{
public class SmartTransaction: IEquatable<SmartTransaction>
{
#region Members
public Height Height { get; }
public Transaction Transaction { get; }
public bool Confirmed => Height.Type == HeightType.Chain;
public uint256 GetHash() => Transaction.GetHash();
#endregion
#region Constructors
public SmartTransaction()
{
}
public SmartTransaction(Transaction transaction, Height height)
{
Height = height;
Transaction = transaction;
}
#endregion
#region Equality
public bool Equals(SmartTransaction other) => GetHash().Equals(other.GetHash());
public bool Equals(Transaction other) => GetHash().Equals(other.GetHash());
public override bool Equals(object obj)
{
bool rc = false;
if (obj is SmartTransaction)
{
var transaction = (SmartTransaction)obj;
rc = GetHash().Equals(transaction.GetHash());
}
else if (obj is Transaction)
{
var transaction = (Transaction)obj;
rc = GetHash().Equals(transaction.GetHash());
}
return rc;
}
public override int GetHashCode()
{
return GetHash().GetHashCode();
}
public static bool operator !=(SmartTransaction tx1, SmartTransaction tx2)
{
return !(tx1 == tx2);
}
public static bool operator ==(SmartTransaction tx1, SmartTransaction tx2)
{
bool rc;
if(ReferenceEquals(tx1, tx2)) rc = true;
else if((object) tx1 == null || (object) tx2 == null)
{
rc = false;
}
else
{
rc = tx1.GetHash().Equals(tx2.GetHash());
}
return rc;
}
public static bool operator ==(Transaction tx1, SmartTransaction tx2)
{
bool rc;
if ((object)tx1 == null || (object)tx2 == null)
{
rc = false;
}
else
{
rc = tx1.GetHash().Equals(tx2.GetHash());
}
return rc;
}
public static bool operator !=(Transaction tx1, SmartTransaction tx2)
{
return !(tx1 == tx2);
}
public static bool operator ==(SmartTransaction tx1, Transaction tx2)
{
bool rc;
if ((object)tx1 == null || (object)tx2 == null)
{
rc = false;
}
else
{
rc = tx1.GetHash().Equals(tx2.GetHash());
}
return rc;
}
public static bool operator !=(SmartTransaction tx1, Transaction tx2)
{
return !(tx1 == tx2);
}
#endregion
}
}
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ConcurrentCollections;
using HBitcoin.Models;
using NBitcoin;
namespace HBitcoin.FullBlockSpv
{
public class Tracker
{
#region Members
public Network Network { get; private set; }
public ConcurrentHashSet<SmartMerkleBlock> MerkleChain { get; } = new ConcurrentHashSet<SmartMerkleBlock>();
/// <summary>
///
/// </summary>
/// <param name="scriptPubKey"></param>
/// <param name="receivedTransactions">int: block height</param>
/// <param name="spentTransactions">int: block height</param>
/// <returns></returns>
public bool TryFindConfirmedTransactions(Script scriptPubKey, out ConcurrentHashSet<SmartTransaction> receivedTransactions, out ConcurrentHashSet<SmartTransaction> spentTransactions)
{
var found = false;
receivedTransactions = new ConcurrentHashSet<SmartTransaction>();
spentTransactions = new ConcurrentHashSet<SmartTransaction>();
foreach(var tx in TrackedTransactions.Where(x=>x.Confirmed))
{
// if already has that tx continue
if(receivedTransactions.Any(x => x.GetHash() == tx.GetHash()))
continue;
foreach(var output in tx.Transaction.Outputs)
{
if(output.ScriptPubKey.Equals(scriptPubKey))
{
receivedTransactions.Add(tx);
found = true;
}
}
}
if(found)
{
foreach(var tx in TrackedTransactions.Where(x => x.Confirmed))
{
// if already has that tx continue
if(spentTransactions.Any(x => x.GetHash() == tx.GetHash()))
continue;
foreach(var input in tx.Transaction.Inputs)
{
if(receivedTransactions.Select(x => x.GetHash()).Contains(input.PrevOut.Hash))
{
spentTransactions.Add(tx);
found = true;
}
}
}
}
return found;
}
/// <summary>
///
/// </summary>
/// <param name="scriptPubKey"></param>
/// <returns>if never had any money on it</returns>
public bool IsClean(Script scriptPubKey) => TrackedTransactions.All(tx => !tx.Transaction.Outputs.Any(output => output.ScriptPubKey.Equals(scriptPubKey)));
public ConcurrentObservableHashSet<SmartTransaction> TrackedTransactions { get; }
= new ConcurrentObservableHashSet<SmartTransaction>();
public ConcurrentHashSet<Script> TrackedScriptPubKeys { get; }
= new ConcurrentHashSet<Script>();
public readonly UnprocessedBlockBuffer UnprocessedBlockBuffer = new UnprocessedBlockBuffer();
private Height _bestHeight = Height.Unknown;
public Height BestHeight
{
get { return _bestHeight; }
private set
{
if (_bestHeight == value) return;
_bestHeight = value;
OnBestHeightChanged();
}
}
public event EventHandler BestHeightChanged;
private void OnBestHeightChanged() => BestHeightChanged?.Invoke(this, EventArgs.Empty);
public int BlockCount => MerkleChain.Count;
#endregion
#region Constructors
private Tracker()
{
}
public Tracker(Network network)
{
Network = network;
UnprocessedBlockBuffer.HaveBlocks += UnprocessedBlockBuffer_HaveBlocks;
}
#endregion
#region Tracking
private IEnumerable<SmartTransaction> GetNotYetFoundTrackedTransactions()
{
var notFound = new HashSet<SmartTransaction>();
foreach (var tx in TrackedTransactions.Where(x=> !x.Confirmed))
{
notFound.Add(tx);
}
return notFound;
}
#endregion
public void ReorgOne()
{
// remove the last block
if (MerkleChain.Count != 0)
{
if(MerkleChain.TryRemove(MerkleChain.Max()))
{
BestHeight = MerkleChain.Max().Height;
}
}
}
public void AddOrReplaceBlock(Height height, Block block)
{
UnprocessedBlockBuffer.TryAddOrReplace(height, block);
}
#region TransactionProcessing
/// <returns>if processed it transaction</returns>
public bool ProcessTransaction(SmartTransaction transaction)
{
// 1. If already tracking can we update it?
var found = TrackedTransactions.FirstOrDefault(x => x == transaction);
if (found != default(SmartTransaction))
{
// if in a lower level don't track
if(found.Height.Type <= transaction.Height.Type)
return false;
else
{
// else update
TrackedTransactions.TryRemove(transaction);
TrackedTransactions.TryAdd(transaction);
return true;
}
}
// 2. If this transaction arrived to any of our scriptpubkey track it!
if (transaction.Transaction.Outputs.Any(output => TrackedScriptPubKeys.Contains(output.ScriptPubKey)))
{
TrackedTransactions.TryAdd(transaction);
return true;
}
// 3. If this transaction spends any of our scriptpubkeys track it!
if(transaction.Transaction.Inputs.Any(input => TrackedTransactions
.Where(ttx => ttx.GetHash() == input.PrevOut.Hash)
.Any(ttx => TrackedScriptPubKeys
.Contains(ttx.Transaction.Outputs[input.PrevOut.N].ScriptPubKey))))
{
TrackedTransactions.TryAdd(transaction);
return true;
}
// if got so far we are not interested
return false;
}
/// <returns>transactions it processed, empty if not processed any</returns>
private HashSet<SmartTransaction> ProcessTransactions(IEnumerable<Transaction> transactions, Height height)
{
var processedTransactions = new HashSet<SmartTransaction>();
try
{
// Process all transactions
foreach(var tx in transactions)
{
var smartTx = new SmartTransaction(tx, height);
if(ProcessTransaction(smartTx))
{
processedTransactions.Add(smartTx);
}
}
// If processed any then do it again recursively until zero new is processed
if(processedTransactions.Count > 0)
{
var newlyProcessedTransactions = ProcessTransactions(transactions, height);
foreach(var ptx in newlyProcessedTransactions)
{
processedTransactions.Add(ptx);
}
}
}
catch (Exception ex)
{
Debug.WriteLine($"Ignoring {nameof(ProcessBlock)} exception at height {height}:");
Debug.WriteLine(ex);
}
return processedTransactions;
}
/// <returns>transactions it processed, empty if not processed any</returns>
private HashSet<SmartTransaction> ProcessBlock(Height height, Block block)
{
var foundTransactions = ProcessTransactions(block.Transactions, height);
var smartMerkleBlock = new SmartMerkleBlock(height, block, foundTransactions.Select(x => x.GetHash()).ToArray());
var sameHeights = MerkleChain.Where(x => x.Height == height);
foreach (var elem in sameHeights)
{
MerkleChain.TryRemove(elem);
}
MerkleChain.Add(smartMerkleBlock);
BestHeight = height;
return foundTransactions;
}
#endregion
private void UnprocessedBlockBuffer_HaveBlocks(object sender, EventArgs e)
{
Height height;
Block block;
while (UnprocessedBlockBuffer.TryGetAndRemoveOldest(out height, out block))
{
ProcessBlock(height, block);
}
}
#region Saving
private readonly SemaphoreSlim Saving = new SemaphoreSlim(1, 1);
private const string TrackedScriptPubKeysFileName = "TrackedScriptPubKeys.dat";
private const string TrackedTransactionsFileName = "TrackedTransactions.dat";
private const string MerkleChainFileName = "MerkleChain.dat";
private static readonly byte[] blockSep = new byte[] { 0x10, 0x1A, 0x7B, 0x23, 0x5D, 0x12, 0x7D };
public async Task SaveAsync(string trackerFolderPath)
{
await Saving.WaitAsync().ConfigureAwait(false);
try
{
if (TrackedScriptPubKeys.Count > 0 || TrackedTransactions.Count > 0 || MerkleChain.Count > 0)
{
Directory.CreateDirectory(trackerFolderPath);
}
if (TrackedScriptPubKeys.Count > 0)
{
File.WriteAllLines(
Path.Combine(trackerFolderPath, TrackedScriptPubKeysFileName),
TrackedScriptPubKeys.Select(x => x.ToString()));
}
if (TrackedTransactions.Count > 0)
{
File.WriteAllLines(
Path.Combine(trackerFolderPath, TrackedTransactionsFileName),
TrackedTransactions
.Select(x => $"{x.Transaction.ToHex()}:{x.Height}"));
}
if (MerkleChain.Count > 0)
{
var path = Path.Combine(trackerFolderPath, MerkleChainFileName);
if(File.Exists(path))
{
const string backupName = MerkleChainFileName + "_backup";
var backupPath = Path.Combine(trackerFolderPath, backupName);
File.Copy(path, backupPath, overwrite: true);
File.Delete(path);
}
using(FileStream stream = File.OpenWrite(path))
{
var toFile = MerkleChain.First().ToBytes();
await stream.WriteAsync(toFile, 0, toFile.Length).ConfigureAwait(false);
foreach(var block in MerkleChain.Skip(1))
{
await stream.WriteAsync(blockSep, 0, blockSep.Length).ConfigureAwait(false);
var blockBytes = block.ToBytes();
await stream.WriteAsync(blockBytes, 0, blockBytes.Length).ConfigureAwait(false);
}
}
}
}
finally
{
Saving.Release();
}
}
public async Task LoadAsync(string trackerFolderPath)
{
await Saving.WaitAsync().ConfigureAwait(false);
try
{
if (!Directory.Exists(trackerFolderPath))
throw new DirectoryNotFoundException($"No Blockchain found at {trackerFolderPath}");
var tspb = Path.Combine(trackerFolderPath, TrackedScriptPubKeysFileName);
if (File.Exists(tspb) && new FileInfo(tspb).Length != 0)
{
foreach (var line in File.ReadAllLines(tspb))
{
TrackedScriptPubKeys.Add(new Script(line));
}
}
var tt = Path.Combine(trackerFolderPath, TrackedTransactionsFileName);
if (File.Exists(tt) && new FileInfo(tt).Length != 0)
{
foreach (var line in File.ReadAllLines(tt))
{
var pieces = line.Split(':');
ProcessTransaction(new SmartTransaction(new Transaction(pieces[0]), new Height(pieces[1])));
}
}
var pbc = Path.Combine(trackerFolderPath, MerkleChainFileName);
if (File.Exists(pbc) && new FileInfo(pbc).Length != 0)
{
foreach (var block in Util.Separate(File.ReadAllBytes(pbc), blockSep))
{
SmartMerkleBlock smartMerkleBlock = SmartMerkleBlock.FromBytes(block);
MerkleChain.Add(smartMerkleBlock);
}
BestHeight = MerkleChain.Max().Height;
}
}
finally
{
Saving.Release();
}
}
#endregion
}
}
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using HBitcoin.Models;
using NBitcoin;
namespace HBitcoin.FullBlockSpv
{
public class UnprocessedBlockBuffer
{
public const int Capacity = 50;
private readonly ConcurrentObservableDictionary<Height, Block> _blocks = new ConcurrentObservableDictionary<Height, Block>();
public event EventHandler HaveBlocks;
private void OnHaveBlocks() => HaveBlocks?.Invoke(this, EventArgs.Empty);
/// <summary>
///
/// </summary>
/// <param name="height"></param>
/// <param name="block"></param>
/// <returns>false if we have more than UnprocessedBlockBuffer.Capacity blocks in memory already</returns>
public bool TryAddOrReplace(Height height, Block block)
{
if (_blocks.Count > Capacity) return false;
_blocks.AddOrReplace(height, block);
if (_blocks.Count == 1) OnHaveBlocks();
return true;
}
public bool Full => _blocks.Count == Capacity;
public Height BestHeight => _blocks.Count == 0 ? Height.Unknown : _blocks.Keys.Max();
/// <summary>
///
/// </summary>
/// <returns>false if empty</returns>
public bool TryGetAndRemoveOldest(out Height height, out Block block)
{
height = Height.Unknown;
block = default(Block);
if(_blocks.Count == 0) return false;
height = _blocks.Keys.Min();
block = _blocks[height];
_blocks.Remove(height);
return true;
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace HBitcoin
{
internal static class Util
{
internal static byte[][] Separate(byte[] source, byte[] separator)
{
var Parts = new List<byte[]>();
var Index = 0;
byte[] Part;
for (var I = 0; I < source.Length; ++I)
{
if (Equals(source, separator, I))
{
Part = new byte[I - Index];
Array.Copy(source, Index, Part, 0, Part.Length);
Parts.Add(Part);
Index = I + separator.Length;
I += separator.Length - 1;
}
}
Part = new byte[source.Length - Index];
Array.Copy(source, Index, Part, 0, Part.Length);
Parts.Add(Part);
return Parts.ToArray();
}
private static bool Equals(byte[] source, byte[] separator, int index)
{
for (int i = 0; i < separator.Length; ++i)
if (index + i >= source.Length || source[index + i] != separator[i])
return false;
return true;
}
/// <summary>
/// Splits an array into several smaller arrays.
/// </summary>
/// <typeparam name="T">The type of the array.</typeparam>
/// <param name="array">The array to split.</param>
/// <param name="size">The size of the smaller arrays.</param>
/// <returns>An array containing smaller arrays.</returns>
public static IEnumerable<IEnumerable<T>> Split<T>(T[] array, int size)
{
for (var i = 0; i < (float)array.Length / size; i++)
{
yield return array.Skip(i * size).Take(size);
}
}
}
}
using System;
using System.Collections.Generic;
using Breeze.Wallet.JsonConverters;
using NBitcoin;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace Breeze.Wallet
{
......@@ -8,29 +12,210 @@ namespace Breeze.Wallet
/// </summary>
public class Wallet
{
/// <summary>
/// The name of this wallet.
/// </summary>
[JsonProperty(PropertyName = "name")]
public string Name { get; set; }
/// <summary>
/// The seed for this wallet, password encrypted.
/// </summary>
[JsonProperty(PropertyName = "encryptedSeed")]
public string EncryptedSeed { get; set; }
/// <summary>
/// The chain code.
/// </summary>
[JsonProperty(PropertyName = "chainCode")]
[JsonConverter(typeof(ByteArrayConverter))]
public byte[] ChainCode { get; set; }
/// <summary>
/// The network this wallet is for.
/// </summary>
[JsonProperty(PropertyName = "network")]
[JsonConverter(typeof(NetworkConverter))]
public Network Network { get; set; }
/// <summary>
/// The time this wallet was created.
/// </summary>
[JsonProperty(PropertyName = "creationTime")]
[JsonConverter(typeof(DateTimeOffsetConverter))]
public DateTimeOffset CreationTime { get; set; }
/// <summary>
/// The location of the wallet file on the local system.
/// </summary>
[JsonProperty(PropertyName = "walletFilePath")]
public string WalletFilePath { get; set; }
/// <summary>
/// The key used to generate keys.
/// The root of the accounts tree.
/// </summary>
[JsonProperty(PropertyName = "accountsRoot")]
public IEnumerable<AccountRoot> AccountsRoot { get; set; }
}
/// <summary>
/// The root for the accounts for any type of coins.
/// </summary>
public class AccountRoot
{
/// <summary>
/// The type of coin, Bitcoin or Stratis.
/// </summary>
[JsonProperty(PropertyName = "coinType")]
public CoinType CoinType { get; set; }
/// <summary>
/// The accounts used in the wallet.
/// </summary>
[JsonProperty(PropertyName = "accounts")]
public IEnumerable<HdAccount> Accounts { get; set; }
}
/// <summary>
/// The type of coin, as specified in BIP44.
/// </summary>
public enum CoinType
{
Bitcoin = 0,
Stratis = 105
}
/// <summary>
/// An Hd account's details.
/// </summary>
public class HdAccount
{
/// <summary>
/// The index of the account.
/// </summary>
/// <remarks>
/// According to BIP44, an account at index (i) can only be created when the account
/// at index (i - 1) contains transactions.
/// </remarks>
[JsonProperty(PropertyName = "index")]
public int Index { get; set; }
/// <summary>
/// The name of this account.
/// </summary>
[JsonProperty(PropertyName = "name")]
public string Name { get; set; }
/// <summary>
/// An extended pub key used to generate addresses.
/// </summary>
[JsonProperty(PropertyName = "extPubKey")]
public string ExtendedPubKey { get; set; }
/// <summary>
/// Gets or sets the creation time.
/// </summary>
[JsonProperty(PropertyName = "creationTime")]
[JsonConverter(typeof(DateTimeOffsetConverter))]
public DateTimeOffset CreationTime { get; set; }
/// <summary>
/// The list of external addresses, typically used for receiving money.
/// </summary>
[JsonProperty(PropertyName = "externalAddresses")]
public IEnumerable<HdAddress> ExternalAddresses { get; set; }
/// <summary>
/// The list of internal addresses, typically used to receive change.
/// </summary>
[JsonProperty(PropertyName = "internalAddresses")]
public IEnumerable<HdAddress> InternalAddresses { get; set; }
}
/// <summary>
/// An Hd address.
/// </summary>
public class HdAddress
{
/// <summary>
/// The index of the address.
/// </summary>
[JsonProperty(PropertyName = "index")]
public int Index { get; set; }
/// <summary>
/// Gets or sets the creation time.
/// </summary>
public ExtKey ExtendedKey { get; set; }
[JsonProperty(PropertyName = "creationTime")]
[JsonConverter(typeof(DateTimeOffsetConverter))]
public DateTimeOffset CreationTime { get; set; }
/// <summary>
/// The script pub key for this address.
/// </summary>
[JsonProperty(PropertyName = "scriptPubKey")]
[JsonConverter(typeof(ScriptJsonConverter))]
public Script ScriptPubKey { get; set; }
/// <summary>
/// The Base58 representation of this address.
/// </summary>
[JsonProperty(PropertyName = "address")]
public string Address { get; set; }
/// <summary>
/// A path to the address as defined in BIP44.
/// </summary>
[JsonProperty(PropertyName = "hdPath")]
public string HdPath { get; set; }
/// <summary>
/// A list detailing which blocks have been scanned for this address.
/// </summary>
[JsonIgnore]
public SortedList<int, int> BlocksScanned { get; set; }
/// <summary>
/// A list of transactions involving this address.
/// </summary>
[JsonProperty(PropertyName = "transactions")]
public IEnumerable<TransactionData> Transactions { get; set; }
}
/// <summary>
/// An object containing transaction data.
/// </summary>
public class TransactionData
{
/// <summary>
/// Transaction id.
/// </summary>
[JsonProperty(PropertyName = "id")]
public string Id { get; set; }
/// <summary>
/// The transaction amount.
/// </summary>
[JsonProperty(PropertyName = "amount")]
public Money Amount { get; set; }
/// <summary>
/// The height of the block including this transaction.
/// </summary>
[JsonProperty(PropertyName = "blockHeight")]
public int BlockHeight { get; set; }
/// <summary>
/// Whether this transaction has been confirmed or not.
/// </summary>
[JsonProperty(PropertyName = "confirmed")]
public bool Confirmed { get; set; }
/// <summary>
/// Gets or sets the creation time.
/// </summary>
[JsonProperty(PropertyName = "creationTime")]
[JsonConverter(typeof(DateTimeOffsetConverter))]
public DateTimeOffset CreationTime { get; set; }
}
}
\ No newline at end of file
......@@ -41,8 +41,7 @@ namespace Breeze.Wallet
.AddFeature<WalletFeature>()
.FeatureServices(services =>
{
services.AddTransient<IWalletWrapper, WalletWrapper>();
services.AddTransient<ITrackerWrapper, TrackerWrapper>();
services.AddSingleton<ITrackerWrapper, TrackerWrapper>();
services.AddSingleton<IWalletManager, WalletManager>();
services.AddSingleton<WalletController>();
});
......
using System;
using NBitcoin;
using Newtonsoft.Json;
using Breeze.Wallet.JsonConverters;
namespace Breeze.Wallet
{
/// <summary>
/// An object representing a wallet on a local file system.
/// </summary>
public class WalletFile
{
/// <summary>
/// The seed for this wallet, password encrypted.
/// </summary>
[JsonProperty(PropertyName = "encryptedSeed")]
public string EncryptedSeed { get; set; }
/// <summary>
/// The chain code.
/// </summary>
[JsonProperty(PropertyName = "chainCode")]
[JsonConverter(typeof(ByteArrayConverter))]
public byte[] ChainCode { get; set; }
/// <summary>
/// The network this wallet is for.
/// </summary>
[JsonProperty(PropertyName = "network")]
[JsonConverter(typeof(NetworkConverter))]
public Network Network { get; set; }
/// <summary>
/// The time this wallet was created.
/// </summary>
[JsonProperty(PropertyName = "creationTime")]
[JsonConverter(typeof(DateTimeOffsetConverter))]
public DateTimeOffset CreationTime { get; set; }
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Breeze.Wallet.Helpers;
using Breeze.Wallet.Models;
using NBitcoin;
using Newtonsoft.Json;
using Stratis.Bitcoin.Utilities;
namespace Breeze.Wallet
{
......@@ -10,64 +15,199 @@ namespace Breeze.Wallet
/// </summary>
public class WalletManager : IWalletManager
{
public List<Wallet> Wallets { get; }
public WalletManager()
{
this.Wallets = new List<Wallet>();
}
/// <inheritdoc />
public Mnemonic CreateWallet(string password, string walletFilePath, Network network, string passphrase = null)
public Mnemonic CreateWallet(string password, string folderPath, string name, string network, string passphrase = null)
{
// for now the passphrase is set to be the password by default.
if (passphrase == null)
{
passphrase = password;
}
// generate the root seed used to generate keys from a mnemonic picked at random
// and a passphrase optionally provided by the user
Mnemonic mnemonic = new Mnemonic(Wordlist.English, WordCount.Twelve);
ExtKey extendedKey = mnemonic.DeriveExtKey(passphrase);
// create a wallet file
this.GenerateWalletFile(password, walletFilePath, network, extendedKey);
Wallet wallet = this.GenerateWalletFile(password, folderPath, name, WalletHelpers.GetNetwork(network), extendedKey);
this.Load(wallet);
return mnemonic;
}
/// <inheritdoc />
public Wallet LoadWallet(string password, string walletFilePath)
public Wallet LoadWallet(string password, string folderPath, string name)
{
string walletFilePath = Path.Combine(folderPath, $"{name}.json");
if (!File.Exists(walletFilePath))
throw new FileNotFoundException($"No wallet file found at {walletFilePath}");
// load the file from the local system
WalletFile walletFile = JsonConvert.DeserializeObject<WalletFile>(File.ReadAllText(walletFilePath));
Wallet wallet = JsonConvert.DeserializeObject<Wallet>(File.ReadAllText(walletFilePath));
// decrypt the private key and use it to regenerate the seed
var privateKey = Key.Parse(walletFile.EncryptedSeed, password, walletFile.Network);
var seedExtKey = new ExtKey(privateKey, walletFile.ChainCode);
this.Load(wallet);
return wallet;
}
Wallet wallet = new Wallet
/// <inheritdoc />
public Wallet RecoverWallet(string password, string folderPath, string name, string network, string mnemonic, string passphrase = null, DateTimeOffset? creationTime = null)
{
ChainCode = walletFile.ChainCode,
CreationTime = walletFile.CreationTime,
Network = walletFile.Network,
WalletFilePath = walletFilePath,
ExtendedKey = seedExtKey
};
// for now the passphrase is set to be the password by default.
if (passphrase == null)
{
passphrase = password;
}
// generate the root seed used to generate keys
ExtKey extendedKey = (new Mnemonic(mnemonic)).DeriveExtKey(passphrase);
// create a wallet file
Wallet wallet = this.GenerateWalletFile(password, folderPath, name, WalletHelpers.GetNetwork(network), extendedKey, creationTime);
this.Load(wallet);
return wallet;
}
/// <inheritdoc />
public Wallet RecoverWallet(Mnemonic mnemonic, string password, string walletFilePath, Network network, string passphrase = null, DateTimeOffset? creationTime = null)
public string CreateNewAccount(string walletName, CoinType coinType, string accountName, string password)
{
// generate the root seed used to generate keys
ExtKey extendedKey = mnemonic.DeriveExtKey(passphrase);
Wallet wallet = this.Wallets.SingleOrDefault(w => w.Name == walletName);
if (wallet == null)
{
throw new Exception($"No wallet with name {walletName} could be found.");
}
// create a wallet file
WalletFile walletFile = this.GenerateWalletFile(password, walletFilePath, network, extendedKey, creationTime);
// get the accounts for this type of coin
var accounts = wallet.AccountsRoot.Single(a => a.CoinType == coinType).Accounts.ToList();
int newAccountIndex = 0;
Wallet wallet = new Wallet
// validate account creation
if (accounts.Any())
{
ChainCode = walletFile.ChainCode,
CreationTime = walletFile.CreationTime,
Network = walletFile.Network,
WalletFilePath = walletFilePath,
ExtendedKey = extendedKey
};
// check account with same name doesn't already exists
if (accounts.Any(a => a.Name == accountName))
{
throw new Exception($"Account with name '{accountName}' already exists in '{walletName}'.");
}
return wallet;
// check account at index i - 1 contains transactions.
int lastAccountIndex = accounts.Max(a => a.Index);
HdAccount previousAccount = accounts.Single(a => a.Index == lastAccountIndex);
if (!previousAccount.ExternalAddresses.Any(addresses => addresses.Transactions.Any()) && !previousAccount.InternalAddresses.Any(addresses => addresses.Transactions.Any()))
{
throw new Exception($"Cannot create new account '{accountName}' in '{walletName}' if the previous account '{previousAccount.Name}' has not been used.");
}
newAccountIndex = lastAccountIndex + 1;
}
// get the extended pub key used to generate addresses for this account
var privateKey = Key.Parse(wallet.EncryptedSeed, password, wallet.Network);
var seedExtKey = new ExtKey(privateKey, wallet.ChainCode);
KeyPath keyPath = new KeyPath($"m/44'/{(int)coinType}'/{newAccountIndex}'");
ExtKey accountExtKey = seedExtKey.Derive(keyPath);
ExtPubKey accountExtPubKey = accountExtKey.Neuter();
accounts.Add(new HdAccount
{
Index = newAccountIndex,
ExtendedPubKey = accountExtPubKey.ToString(wallet.Network),
ExternalAddresses = new List<HdAddress>(),
InternalAddresses = new List<HdAddress>(),
Name = accountName,
CreationTime = DateTimeOffset.Now
});
wallet.AccountsRoot.Single(a => a.CoinType == coinType).Accounts = accounts;
this.SaveToFile(wallet);
return accountName;
}
/// <inheritdoc />
public string CreateNewAddress(string walletName, CoinType coinType, string accountName)
{
Wallet wallet = this.Wallets.SingleOrDefault(w => w.Name == walletName);
if (wallet == null)
{
throw new Exception($"No wallet with name {walletName} could be found.");
}
// 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.");
}
int newAddressIndex = 0;
// validate address creation
if (account.ExternalAddresses.Any())
{
// check last created address contains transactions.
int lastAddressIndex = account.ExternalAddresses.Max(a => a.Index);
var lastAddress = account.ExternalAddresses.SingleOrDefault(a => a.Index == lastAddressIndex);
if (lastAddress != null && !lastAddress.Transactions.Any())
{
throw new Exception($"Cannot create new address in account '{accountName}' if the previous address '{lastAddress.Address}' has not been used.");
}
newAddressIndex = lastAddressIndex + 1;
}
// generate new receiving address
BitcoinPubKeyAddress address = this.GenerateAddress(account.ExtendedPubKey, newAddressIndex, false, wallet.Network);
// add address details
account.ExternalAddresses = account.ExternalAddresses.Concat(new[] {new HdAddress
{
Index = newAddressIndex,
HdPath = CreateBip44Path(coinType, account.Index, newAddressIndex, false),
ScriptPubKey = address.ScriptPubKey,
Address = address.ToString(),
Transactions = new List<TransactionData>(),
CreationTime = DateTimeOffset.Now
}});
this.SaveToFile(wallet);
return address.ToString();
}
public WalletGeneralInfoModel GetGeneralInfo(string name)
{
throw new System.NotImplementedException();
}
public WalletBalanceModel GetBalance(string walletName)
{
throw new System.NotImplementedException();
}
public WalletHistoryModel GetHistory(string walletName)
{
throw new System.NotImplementedException();
}
public WalletBuildTransactionModel BuildTransaction(string password, string address, Money amount, string feeType,
bool allowUnconfirmed)
{
throw new System.NotImplementedException();
}
public bool SendTransaction(string transactionHex)
{
throw new System.NotImplementedException();
}
/// <inheritdoc />
......@@ -86,23 +226,32 @@ namespace Breeze.Wallet
/// Generates the wallet file.
/// </summary>
/// <param name="password">The password used to encrypt sensitive info.</param>
/// <param name="walletFilePath">The location of the wallet file.</param>
/// <param name="folderPath">The folder where the wallet will be generated.</param>
/// <param name="name">The name of the wallet.</param>
/// <param name="network">The network this wallet is for.</param>
/// <param name="extendedKey">The root key used to generate keys.</param>
/// <param name="coinType">The type of coin for which this wallet is created.</param>
/// <param name="creationTime">The time this wallet was created.</param>
/// <returns></returns>
/// <exception cref="System.NotSupportedException"></exception>
private WalletFile GenerateWalletFile(string password, string walletFilePath, Network network, ExtKey extendedKey, DateTimeOffset? creationTime = null)
private Wallet GenerateWalletFile(string password, string folderPath, string name, Network network, ExtKey extendedKey, DateTimeOffset? creationTime = null)
{
string walletFilePath = Path.Combine(folderPath, $"{name}.json");
if (File.Exists(walletFilePath))
throw new InvalidOperationException($"Wallet already exists at {walletFilePath}");
WalletFile walletFile = new WalletFile
Wallet walletFile = new Wallet
{
Name = name,
EncryptedSeed = extendedKey.PrivateKey.GetEncryptedBitcoinSecret(password, network).ToWif(),
ChainCode = extendedKey.ChainCode,
CreationTime = creationTime ?? DateTimeOffset.Now,
Network = network
Network = network,
AccountsRoot = new List<AccountRoot> {
new AccountRoot { Accounts = new List<HdAccount>(), CoinType = CoinType.Bitcoin },
new AccountRoot { Accounts = new List<HdAccount>(), CoinType = CoinType.Stratis} },
WalletFilePath = walletFilePath
};
// create a folder if none exists and persist the file
......@@ -111,5 +260,51 @@ namespace Breeze.Wallet
return walletFile;
}
/// <summary>
/// Saves the wallet into the file system.
/// </summary>
/// <param name="wallet">The wallet to save.</param>
private void SaveToFile(Wallet wallet)
{
File.WriteAllText(wallet.WalletFilePath, JsonConvert.SerializeObject(wallet, Formatting.Indented));
}
/// <summary>
/// Loads the wallet to be used by the manager.
/// </summary>
/// <param name="wallet">The wallet to load.</param>
private void Load(Wallet wallet)
{
if (this.Wallets.All(w => w.Name != wallet.Name))
{
this.Wallets.Add(wallet);
}
}
private BitcoinPubKeyAddress GenerateAddress(string accountExtPubKey, int index, bool isChange, Network network)
{
int change = isChange ? 1 : 0;
KeyPath keyPath = new KeyPath($"{change}/{index}");
ExtPubKey extPubKey = ExtPubKey.Parse(accountExtPubKey).Derive(keyPath);
return extPubKey.PubKey.GetAddress(network);
}
/// <summary>
/// Creates the bip44 path.
/// </summary>
/// <param name="coinType">Type of the coin.</param>
/// <param name="accountIndex">Index of the account.</param>
/// <param name="addressIndex">Index of the address.</param>
/// <param name="isChange">if set to <c>true</c> [is change].</param>
/// <returns></returns>
public static string CreateBip44Path(CoinType coinType, int accountIndex, int addressIndex, bool isChange = false)
{
//// populate the items according to the BIP44 path
//// [m/purpose'/coin_type'/account'/change/address_index]
int change = isChange ? 1 : 0;
return $"m/44'/{(int)coinType}'/{accountIndex}'/{change}/{addressIndex}";
}
}
}
using Breeze.Wallet.Models;
using NBitcoin;
namespace Breeze.Wallet.Wrappers
{
/// <summary>
/// An interface enabling wallet operations.
/// </summary>
public interface IWalletWrapper
{
string Create(string password, string folderPath, string name, string network);
WalletModel Load(string password, string folderPath, string name);
WalletModel Recover(string password, string folderPath, string name, string network, string mnemonic);
WalletGeneralInfoModel GetGeneralInfo(string walletName);
WalletBalanceModel GetBalance(string walletName);
WalletHistoryModel GetHistory(string walletName);
WalletBuildTransactionModel BuildTransaction(string password, string address, Money amount, string feeType, bool allowUnconfirmed);
bool SendTransaction(string transactionHex);
}
}
using System;
using System.IO;
using System.Linq;
using Breeze.Wallet.Helpers;
using Breeze.Wallet.Models;
using NBitcoin;
namespace Breeze.Wallet.Wrappers
{
/// <summary>
/// An implementation of the <see cref="IWalletWrapper"/> interface.
/// </summary>
public class WalletWrapper : IWalletWrapper
{
private readonly IWalletManager walletManager;
public WalletWrapper(IWalletManager walletManager)
{
this.walletManager = walletManager;
}
/// <summary>
/// Creates a wallet on the local device.
/// </summary>
/// <param name="password">The user's password.</param>
/// <param name="folderPath">The folder where the wallet will be saved.</param>
/// <param name="name">The name of the wallet.</param>
/// <param name="network">The network for which to create a wallet.</param>
/// <returns>A mnemonic allowing recovery of the wallet.</returns>
public string Create(string password, string folderPath, string name, string network)
{
Mnemonic mnemonic = this.walletManager.CreateWallet(password, Path.Combine(folderPath, $"{name}.json"), WalletHelpers.GetNetwork(network), password);
return mnemonic.ToString();
}
/// <summary>
/// Loads a wallet from the local device.
/// </summary>
/// <param name="password">The user's password.</param>
/// <param name="folderPath">The folder where the wallet will be loaded.</param>
/// <param name="name">The name of the wallet.</param>
/// <returns>The wallet loaded from the local device</returns>
public WalletModel Load(string password, string folderPath, string name)
{
Wallet wallet = this.walletManager.LoadWallet(password, Path.Combine(folderPath, $"{name}.json"));
//TODO review here which data should be returned
return new WalletModel
{
Network = wallet.Network.Name,
// Addresses = wallet.GetFirstNAddresses(10).Select(a => a.ToWif()),
FileName = wallet.WalletFilePath
};
}
/// <summary>
/// Recovers a wallet from the local device.
/// </summary>
/// <param name="password">The user's password.</param>
/// <param name="folderPath">The folder where the wallet will be loaded.</param>
/// <param name="name">The name of the wallet.</param>
/// <param name="network">The network in which to creae this wallet</param>
/// <param name="mnemonic">The user's mnemonic for the wallet.</param>
/// <returns></returns>
public WalletModel Recover(string password, string folderPath, string name, string network, string mnemonic)
{
Wallet wallet = this.walletManager.RecoverWallet(new Mnemonic(mnemonic), password, Path.Combine(folderPath, $"{name}.json"), WalletHelpers.GetNetwork(network), password);
//TODO review here which data should be returned
return new WalletModel
{
Network = wallet.Network.Name,
// Addresses = wallet.GetFirstNAddresses(10).Select(a => a.ToWif()),
FileName = wallet.WalletFilePath
};
}
public WalletGeneralInfoModel GetGeneralInfo(string name)
{
throw new System.NotImplementedException();
}
public WalletBalanceModel GetBalance(string walletName)
{
throw new System.NotImplementedException();
}
public WalletHistoryModel GetHistory(string walletName)
{
throw new System.NotImplementedException();
}
public WalletBuildTransactionModel BuildTransaction(string password, string address, Money amount, string feeType,
bool allowUnconfirmed)
{
throw new System.NotImplementedException();
}
public bool SendTransaction(string transactionHex)
{
throw new System.NotImplementedException();
}
}
}
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