From 50e3c291f7708ff22f8b24e409d74dad43dd8faa Mon Sep 17 00:00:00 2001 From: zeptin Date: Mon, 30 Nov 2020 07:48:09 +0000 Subject: [PATCH] Implement importpubkey and watchonly flag for gettransaction (#236) --- .../ColdStakingWalletRPCController.cs | 7 +- .../RPCClient.Wallet.cs | 5 ++ .../RPCOperations.cs | 1 + .../HdAddress.cs | 2 +- .../Interfaces/IWalletManager.cs | 8 ++ .../Services/WalletService.cs | 2 +- src/Stratis.Bitcoin.Features.Wallet/Wallet.cs | 11 +++ .../WalletManager.cs | 59 +++++++++++++ .../WalletRPCController.cs | 84 ++++++++++++++++++- .../RPC/RPCTestsMutable.cs | 44 ++++++++++ .../External/ITransactionsToLists.cs | 3 +- .../SQLiteWalletRepository.cs | 20 ++++- 12 files changed, 234 insertions(+), 12 deletions(-) diff --git a/src/Stratis.Bitcoin.Features.ColdStaking/Controllers/ColdStakingWalletRPCController.cs b/src/Stratis.Bitcoin.Features.ColdStaking/Controllers/ColdStakingWalletRPCController.cs index 930a91b21a..52022a7af8 100644 --- a/src/Stratis.Bitcoin.Features.ColdStaking/Controllers/ColdStakingWalletRPCController.cs +++ b/src/Stratis.Bitcoin.Features.ColdStaking/Controllers/ColdStakingWalletRPCController.cs @@ -25,9 +25,10 @@ public class ColdStakingWalletRPCController : WalletRPCController StoreSettings storeSettings, IWalletManager walletManager, WalletSettings walletSettings, - IWalletTransactionHandler walletTransactionHandler) : - base(blockStore, broadcasterManager, chainIndexer, consensusManager, fullNode, loggerFactory, network, scriptAddressReader, storeSettings, walletManager, walletSettings, walletTransactionHandler) + IWalletTransactionHandler walletTransactionHandler, + IWalletSyncManager walletSyncManager) : + base(blockStore, broadcasterManager, chainIndexer, consensusManager, fullNode, loggerFactory, network, scriptAddressReader, storeSettings, walletManager, walletSettings, walletTransactionHandler, walletSyncManager) { } } -} \ No newline at end of file +} diff --git a/src/Stratis.Bitcoin.Features.RPC/RPCClient.Wallet.cs b/src/Stratis.Bitcoin.Features.RPC/RPCClient.Wallet.cs index 575c19bc40..27ec412c62 100644 --- a/src/Stratis.Bitcoin.Features.RPC/RPCClient.Wallet.cs +++ b/src/Stratis.Bitcoin.Features.RPC/RPCClient.Wallet.cs @@ -262,6 +262,11 @@ public Money GetReceivedByAddress(BitcoinAddress address, int confirmations) return Money.Coins(response.Result.Value()); } + public void ImportPubKey(string pubkey, bool rescan = true) + { + SendCommand(RPCOperations.importpubkey, pubkey, "", rescan); + } + // importprivkey public void ImportPrivKey(BitcoinSecret secret) diff --git a/src/Stratis.Bitcoin.Features.RPC/RPCOperations.cs b/src/Stratis.Bitcoin.Features.RPC/RPCOperations.cs index c33069b9e6..5a831638ba 100644 --- a/src/Stratis.Bitcoin.Features.RPC/RPCOperations.cs +++ b/src/Stratis.Bitcoin.Features.RPC/RPCOperations.cs @@ -13,6 +13,7 @@ public enum RPCOperations dumpprivkey, importprivkey, importaddress, + importpubkey, dumpwallet, importwallet, diff --git a/src/Stratis.Bitcoin.Features.Wallet/HdAddress.cs b/src/Stratis.Bitcoin.Features.Wallet/HdAddress.cs index 08d8f97726..9b36de492a 100644 --- a/src/Stratis.Bitcoin.Features.Wallet/HdAddress.cs +++ b/src/Stratis.Bitcoin.Features.Wallet/HdAddress.cs @@ -165,7 +165,7 @@ public HdAddress AsPaginated(long? prevOutputTxTime, int? prevOutputIndex, int l public Script Pubkey { get; set; } /// - /// The base32 representation of a segwit (P2WPH) address. + /// The bech32 representation of a segwit (P2WPKH) address. /// [JsonProperty(PropertyName = "bech32Address")] public string Bech32Address { get; set; } diff --git a/src/Stratis.Bitcoin.Features.Wallet/Interfaces/IWalletManager.cs b/src/Stratis.Bitcoin.Features.Wallet/Interfaces/IWalletManager.cs index 81ff13bae0..0fa8a9cfba 100644 --- a/src/Stratis.Bitcoin.Features.Wallet/Interfaces/IWalletManager.cs +++ b/src/Stratis.Bitcoin.Features.Wallet/Interfaces/IWalletManager.cs @@ -287,6 +287,14 @@ public interface IWalletManager /// The list of all accounts. IEnumerable GetAllAccounts(); + HdAccount GetAccount(string walletName, string accountName); + + HdAccount GetAccount(WalletAccountReference accountReference); + + HdAccount GetOrCreateWatchOnlyAccount(string walletName); + + void AddWatchOnlyAddress(string walletName, string accountName, Script p2pkScriptPubKey, Script p2pkhScriptPubKey); + /// /// Gets the last block height. /// diff --git a/src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs b/src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs index 1dbd811812..b9e8fa7a17 100644 --- a/src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs +++ b/src/Stratis.Bitcoin.Features.Wallet/Services/WalletService.cs @@ -1390,4 +1390,4 @@ public async Task OfflineSignRequest(OfflineSignReq i.ConfirmedInBlock == transaction.BlockHeight); } } -} \ No newline at end of file +} diff --git a/src/Stratis.Bitcoin.Features.Wallet/Wallet.cs b/src/Stratis.Bitcoin.Features.Wallet/Wallet.cs index 876ab601ee..ffa41fac58 100644 --- a/src/Stratis.Bitcoin.Features.Wallet/Wallet.cs +++ b/src/Stratis.Bitcoin.Features.Wallet/Wallet.cs @@ -21,9 +21,20 @@ public class Wallet /// Account numbers greater or equal to this number are reserved for special purpose account indexes. public const int SpecialPurposeAccountIndexesStart = 100_000_000; + /// + /// The wallet account used for storing watched addresses that the wallet does not possess a private key for. + /// This is intended to supersede the WatchOnlyWallet feature going forwards. + /// Note that indices 0 and 1 are used for cold staking. + /// + public const int WatchOnlyAccountIndex = SpecialPurposeAccountIndexesStart + 2; + + public const string WatchOnlyAccountName = "watchOnly"; + /// Filter for identifying normal wallet accounts. public static Func NormalAccounts = a => a.Index < SpecialPurposeAccountIndexesStart; + public static Func WatchOnlyAccount = a => a.Index == WatchOnlyAccountIndex; + /// Filter for all wallet accounts. public static Func AllAccounts = a => true; diff --git a/src/Stratis.Bitcoin.Features.Wallet/WalletManager.cs b/src/Stratis.Bitcoin.Features.Wallet/WalletManager.cs index d08e406388..271c1ca3b3 100644 --- a/src/Stratis.Bitcoin.Features.Wallet/WalletManager.cs +++ b/src/Stratis.Bitcoin.Features.Wallet/WalletManager.cs @@ -994,9 +994,68 @@ public IEnumerable GetAccounts(string walletName) { res = wallet.GetAccounts().ToArray(); } + + return res; + } + + public HdAccount GetAccount(string walletName, string accountName) + { + Guard.NotEmpty(walletName, nameof(walletName)); + + Wallet wallet = this.GetWallet(walletName); + + HdAccount res = null; + lock (this.lockObject) + { + res = wallet.GetAccounts().FirstOrDefault(a => a.Name == accountName); + } + return res; } + public HdAccount GetAccount(WalletAccountReference accountReference) + { + return GetAccount(accountReference.WalletName, accountReference.AccountName); + } + + public HdAccount GetOrCreateWatchOnlyAccount(string walletName) + { + Guard.NotEmpty(walletName, nameof(walletName)); + + Wallet wallet = this.GetWallet(walletName); + + HdAccount[] res = null; + lock (this.lockObject) + { + res = wallet.GetAccounts(Wallet.WatchOnlyAccount).ToArray(); + } + + HdAccount watchOnlyAccount = res.FirstOrDefault(a => a.Index == Wallet.WatchOnlyAccountIndex); + + if (watchOnlyAccount == null) + { + watchOnlyAccount = this.WalletRepository.CreateAccount(walletName, Wallet.WatchOnlyAccountIndex, Wallet.WatchOnlyAccountName, null); + } + + return watchOnlyAccount; + } + + // TODO: Perhaps this shouldn't be in the WalletManager itself, although it doesn't fit well with HdAccount either + public void AddWatchOnlyAddress(string walletName, string accountName, Script p2pkScriptPubKey, Script p2pkhScriptPubKey) + { + string address = p2pkhScriptPubKey.GetDestinationAddress(this.network).ToString(); + + // TODO: Is it sufficient to only define these fields here, or do we need all the other available fields? + var hdAddress = new HdAddress() + { + ScriptPubKey = p2pkhScriptPubKey, + Pubkey = p2pkScriptPubKey, + Address = address + }; + + this.WalletRepository.AddWatchOnlyAddresses(walletName, accountName, 0, new List() { hdAddress }); + } + public IEnumerable GetAllAccounts() { HdAccount[] res = null; diff --git a/src/Stratis.Bitcoin.Features.Wallet/WalletRPCController.cs b/src/Stratis.Bitcoin.Features.Wallet/WalletRPCController.cs index 2f6a736c9f..47b567cb7d 100644 --- a/src/Stratis.Bitcoin.Features.Wallet/WalletRPCController.cs +++ b/src/Stratis.Bitcoin.Features.Wallet/WalletRPCController.cs @@ -42,6 +42,8 @@ public class WalletRPCController : FeatureController private readonly IWalletTransactionHandler walletTransactionHandler; + private readonly IWalletSyncManager walletSyncManager; + private readonly IReserveUtxoService reserveUtxoService; private readonly WalletSettings walletSettings; @@ -64,6 +66,7 @@ public class WalletRPCController : FeatureController IWalletManager walletManager, WalletSettings walletSettings, IWalletTransactionHandler walletTransactionHandler, + IWalletSyncManager walletSyncManager, IReserveUtxoService reserveUtxoService = null) : base(fullNode: fullNode, consensusManager: consensusManager, chainIndexer: chainIndexer, network: network) { this.blockStore = blockStore; @@ -74,6 +77,7 @@ public class WalletRPCController : FeatureController this.walletManager = walletManager; this.walletSettings = walletSettings; this.walletTransactionHandler = walletTransactionHandler; + this.walletSyncManager = walletSyncManager; this.reserveUtxoService = reserveUtxoService; } @@ -453,11 +457,12 @@ public decimal GetBalance(string accountName, int minConfirmations = 0) /// Transaction information. [ActionName("gettransaction")] [ActionDescription("Get detailed information about an in-wallet transaction.")] - public GetTransactionModel GetTransaction(string txid) + public GetTransactionModel GetTransaction(string txid, bool include_watchonly = false) { if (!uint256.TryParse(txid, out uint256 trxid)) throw new ArgumentException(nameof(txid)); + // First check the regular wallet accounts. WalletAccountReference accountReference = this.GetWalletAccountReference(); Wallet hdWallet = this.walletManager.WalletRepository.GetWallet(accountReference.WalletName); @@ -477,6 +482,24 @@ bool IsChangeAddress(Script scriptPubKey) TransactionData firstReceivedTransaction = receivedTransactions.FirstOrDefault(); TransactionData firstSendTransaction = sentTransactions.FirstOrDefault(); + + if (firstReceivedTransaction == null && firstSendTransaction == null && include_watchonly) + { + accountReference = this.GetWatchOnlyWalletAccountReference(); + + hdAccount = this.walletManager.GetOrCreateWatchOnlyAccount(accountReference.WalletName); + + addressLookup = this.walletManager.WalletRepository.GetWalletAddressLookup(accountReference.WalletName); + + // Get the transaction from the wallet by looking into received and send transactions. + receivedTransactions = this.walletManager.WalletRepository.GetTransactionOutputs(hdAccount, null, trxid, true) + .Where(td => !IsChangeAddress(td.ScriptPubKey)).ToList(); + sentTransactions = this.walletManager.WalletRepository.GetTransactionInputs(hdAccount, null, trxid, true).ToList(); + + firstReceivedTransaction = receivedTransactions.FirstOrDefault(); + firstSendTransaction = sentTransactions.FirstOrDefault(); + } + if (firstReceivedTransaction == null && firstSendTransaction == null) throw new RPCServerException(RPCErrorCode.RPC_INVALID_ADDRESS_OR_KEY, "Invalid or non-wallet transaction id."); @@ -619,6 +642,27 @@ bool IsChangeAddress(Script scriptPubKey) return model; } + [ActionName("importpubkey")] + public bool ImportPubkey(string pubkey, string label = "", bool rescan = true) + { + WalletAccountReference walletAccountReference = this.GetWatchOnlyWalletAccountReference(); + + // As we are not sure whether the P2PK or P2PKH was desired, we have to add both to the watch only account simultaneously. + // We would not be able to infer the P2PK from the P2PKH later anyhow. + Script p2pkScriptPubKey = new PubKey(pubkey).ScriptPubKey; + Script p2pkhScriptPubKey = new PubKey(pubkey).Hash.ScriptPubKey; + + this.walletManager.AddWatchOnlyAddress(walletAccountReference.WalletName, walletAccountReference.AccountName, p2pkScriptPubKey, p2pkhScriptPubKey); + + // As we cannot be sure when an imported pubkey was transacted against, we have to rescan from genesis if requested. + if (rescan) + { + this.walletSyncManager.SyncFromHeight(0, walletAccountReference.WalletName); + } + + return true; + } + [ActionName("listaddressgroupings")] [ActionDescription("Returns a list of grouped addresses which have had their common ownership made public by common use as inputs or as the resulting change in past transactions.")] public List ListAddressGroupings() @@ -874,7 +918,7 @@ private int GetConfirmationCount(TransactionData transaction) } /// - /// Gets the first account from the "default" wallet if it specified, + /// Gets the first account from the "default" wallet if it is specified, /// otherwise returns the first available account in the existing wallets. /// /// Reference to the default wallet account, or the first available if no default wallet is specified. @@ -908,5 +952,41 @@ private WalletAccountReference GetWalletAccountReference() return new WalletAccountReference(walletName, account.Name); } + + /// + /// Gets the first watch only account from the "default" wallet if it is specified, + /// otherwise returns the first available watch only account in the existing wallets. + /// + /// Reference to the default wallet watch only account, or the first available if no default wallet is specified. + private WalletAccountReference GetWatchOnlyWalletAccountReference() + { + string walletName = null; + + if (string.IsNullOrWhiteSpace(WalletRPCController.CurrentWalletName)) + { + if (this.walletSettings.IsDefaultWalletEnabled()) + walletName = this.walletManager.GetWalletsNames().FirstOrDefault(w => w == this.walletSettings.DefaultWalletName); + else + { + // TODO: Support multi wallet like core by mapping passed RPC credentials to a wallet/account + walletName = this.walletManager.GetWalletsNames().FirstOrDefault(); + } + } + else + { + // Read the wallet name from the class instance. + walletName = WalletRPCController.CurrentWalletName; + } + + if (walletName == null) + throw new RPCServerException(RPCErrorCode.RPC_INVALID_REQUEST, "No wallet found"); + + HdAccount account = this.walletManager.GetOrCreateWatchOnlyAccount(walletName); + + if (account == null) + throw new RPCServerException(RPCErrorCode.RPC_INVALID_REQUEST, "Unable to retrieve watch only account"); + + return new WalletAccountReference(walletName, account.Name); + } } } diff --git a/src/Stratis.Bitcoin.IntegrationTests/RPC/RPCTestsMutable.cs b/src/Stratis.Bitcoin.IntegrationTests/RPC/RPCTestsMutable.cs index 4d0caefdfb..21967c16cc 100644 --- a/src/Stratis.Bitcoin.IntegrationTests/RPC/RPCTestsMutable.cs +++ b/src/Stratis.Bitcoin.IntegrationTests/RPC/RPCTestsMutable.cs @@ -8,7 +8,9 @@ using NBitcoin.DataEncoders; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Stratis.Bitcoin.Base; using Stratis.Bitcoin.Features.RPC; +using Stratis.Bitcoin.Features.Wallet; using Stratis.Bitcoin.IntegrationTests.Common; using Stratis.Bitcoin.IntegrationTests.Common.EnvironmentMockUpHelpers; using Stratis.Bitcoin.IntegrationTests.Common.ReadyData; @@ -61,6 +63,48 @@ public void TestRpcGetTransactionIsSuccessful() } } + [Fact] + public void TestRpcImportPubkeyIsSuccessful() + { + using (NodeBuilder builder = NodeBuilder.Create(this)) + { + CoreNode node = builder.CreateStratisPowNode(new BitcoinRegTest()).AlwaysFlushBlocks().WithWallet().Start(); + CoreNode node2 = builder.CreateStratisPowNode(new BitcoinRegTest()).WithReadyBlockchainData(ReadyBlockchain.BitcoinRegTest10Miner).Start(); + + TestHelper.ConnectAndSync(node, node2); + + UnspentOutputReference tx = node2.FullNode.WalletManager().GetUnspentTransactionsInWallet("mywallet", 0, Features.Wallet.Wallet.NormalAccounts).First(); + + RPCClient rpc = node.CreateRPCClient(); + + PubKey pubKey = PayToPubkeyTemplate.Instance.ExtractScriptPubKeyParameters(tx.Address.Pubkey); + + Assert.Throws(() => rpc.SendCommand(RPCOperations.gettransaction, tx.Transaction.Id.ToString(), true)); + + rpc.ImportPubKey(pubKey.ToHex()); + + TestBase.WaitLoop(() => node.FullNode.WalletManager().WalletTipHeight == node2.FullNode.WalletManager().WalletTipHeight); + + TestBase.WaitLoop(() => + { + try + { + // Check if gettransaction can now find the transaction in the watch only account. + RPCResponse walletTx = rpc.SendCommand(RPCOperations.gettransaction, tx.Transaction.Id.ToString(), true); + + return walletTx != null; + } + catch (RPCException e) + { + return false; + } + }); + + // Check that when include_watchonly is not set, the watched transaction cannot be located in the normal wallet accounts. + Assert.Throws(() => rpc.SendCommand(RPCOperations.gettransaction, tx.Transaction.Id.ToString(), false)); + } + } + [Fact] public void TestRpcGetBlockWithValidHashIsSuccessful() { diff --git a/src/Stratis.Features.SQLiteWalletRepository/External/ITransactionsToLists.cs b/src/Stratis.Features.SQLiteWalletRepository/External/ITransactionsToLists.cs index 77d00f8695..4213121851 100644 --- a/src/Stratis.Features.SQLiteWalletRepository/External/ITransactionsToLists.cs +++ b/src/Stratis.Features.SQLiteWalletRepository/External/ITransactionsToLists.cs @@ -152,7 +152,8 @@ public bool ProcessTransactions(IEnumerable transactions, HashHeigh { // This tests the converse. // Don't allow special-purpose accounts (e.g. coldstaking) to be used like normal accounts. - if (address.AccountIndex >= Wallet.SpecialPurposeAccountIndexesStart) + // However, for the purposes of recording transactions to watch-only accounts we need to make an allowance. + if (address.AccountIndex >= Wallet.SpecialPurposeAccountIndexesStart && address.AccountIndex != Wallet.WatchOnlyAccountIndex) continue; } diff --git a/src/Stratis.Features.SQLiteWalletRepository/SQLiteWalletRepository.cs b/src/Stratis.Features.SQLiteWalletRepository/SQLiteWalletRepository.cs index 851a1c5c08..51169fefd6 100644 --- a/src/Stratis.Features.SQLiteWalletRepository/SQLiteWalletRepository.cs +++ b/src/Stratis.Features.SQLiteWalletRepository/SQLiteWalletRepository.cs @@ -1574,11 +1574,23 @@ public IEnumerable GetTransactionOutputs(HdAccount hdAccount, D if (!addressDict.TryGetValue(addressIdentifier, out HdAddress hdAddress)) { - ExtPubKey extPubKey = ExtPubKey.Parse(hdAccount.ExtendedPubKey, this.Network); + string pubKeyHex = null; - var keyPath = new KeyPath($"{tranData.AddressType}/{tranData.AddressIndex}"); + if (hdAccount.ExtendedPubKey != null) + { + ExtPubKey extPubKey = ExtPubKey.Parse(hdAccount.ExtendedPubKey, this.Network); - PubKey pubKey = extPubKey.Derive(keyPath).PubKey; + var keyPath = new KeyPath($"{tranData.AddressType}/{tranData.AddressIndex}"); + + PubKey pubKey = extPubKey.Derive(keyPath).PubKey; + + pubKeyHex = pubKey.ScriptPubKey.ToHex(); + } + else + { + // If it is a watch only account we have limited information available. + pubKeyHex = addressIdentifier.PubKeyScript; + } hdAddress = this.ToHdAddress(new HDAddress() { @@ -1587,7 +1599,7 @@ public IEnumerable GetTransactionOutputs(HdAccount hdAccount, D AddressType = (int)addressIdentifier.AddressType, AddressIndex = (int)addressIdentifier.AddressIndex, ScriptPubKey = addressIdentifier.ScriptPubKey, - PubKey = pubKey.ScriptPubKey.ToHex(), + PubKey = pubKeyHex, Address = tranData.Address }, this.Network);