From fe42f21f5f0d3a4b60a423b1b25fec480eb54edb Mon Sep 17 00:00:00 2001 From: Kukks Date: Wed, 22 Nov 2023 10:53:40 +0100 Subject: [PATCH] add hierarchy management API --- NBXplorer.Client/ExplorerClient.cs | 49 ++++++++++++++- .../Models/TrackedSourceRequest.cs | 6 ++ NBXplorer.Tests/UnitTest1.cs | 48 +++++++++++++++ .../Backends/Postgres/PostgresRepository.cs | 33 +++++++++- NBXplorer/Controllers/MainController.PSBT.cs | 11 +--- NBXplorer/Controllers/MainController.cs | 13 ++-- .../Controllers/PostgresMainController.cs | 60 +++++++++++++++++++ NBXplorer/Extensions.cs | 8 +++ 8 files changed, 210 insertions(+), 18 deletions(-) create mode 100644 NBXplorer.Client/Models/TrackedSourceRequest.cs diff --git a/NBXplorer.Client/ExplorerClient.cs b/NBXplorer.Client/ExplorerClient.cs index d284d561c..46e4d207b 100644 --- a/NBXplorer.Client/ExplorerClient.cs +++ b/NBXplorer.Client/ExplorerClient.cs @@ -561,6 +561,53 @@ public GenerateWalletResponse GenerateWallet(GenerateWalletRequest request = nul return GenerateWalletAsync(request, cancellationToken).GetAwaiter().GetResult(); } + public async Task GetChildWallets(TrackedSource trackedSource, + CancellationToken cancellation = default) + { + return await GetAsync( $"{GetBasePath(trackedSource)}/children", cancellation); + } + public async Task GetParentWallets(TrackedSource trackedSource, + CancellationToken cancellation = default) + { + return await GetAsync( $"{GetBasePath(trackedSource)}/parents", cancellation); + } + public async Task AddChildWallet(TrackedSource trackedSource, TrackedSource childWallet, CancellationToken cancellation = default) + { + var request = new TrackedSourceRequest() + { + TrackedSource = childWallet + }; + await SendAsync(HttpMethod.Post, request, $"{GetBasePath(trackedSource)}/children", cancellation); + } + + public async Task AddParentWallet(TrackedSource trackedSource, TrackedSource parentWallet, + CancellationToken cancellation = default) + { + var request = new TrackedSourceRequest() + { + TrackedSource = parentWallet + }; + await SendAsync(HttpMethod.Post, request, $"{GetBasePath(trackedSource)}/parents", cancellation); + } + public async Task RemoveChildWallet(TrackedSource trackedSource, TrackedSource childWallet, CancellationToken cancellation = default) + { + var request = new TrackedSourceRequest() + { + TrackedSource = childWallet + }; + await SendAsync(HttpMethod.Delete, request, $"{GetBasePath(trackedSource)}/children", cancellation); + } + + public async Task RemoveParentWallet(TrackedSource trackedSource, TrackedSource parentWallet, + CancellationToken cancellation = default) + { + var request = new TrackedSourceRequest() + { + TrackedSource = parentWallet + }; + await SendAsync(HttpMethod.Delete, request, $"{GetBasePath(trackedSource)}/parents", cancellation); + } + private static readonly HttpClient SharedClient = new HttpClient(); internal HttpClient Client = SharedClient; @@ -738,7 +785,7 @@ private FormattableString GetBasePath(TrackedSource trackedSource) DerivationSchemeTrackedSource dsts => $"v1/cryptos/{CryptoCode}/derivations/{dsts.DerivationStrategy}", AddressTrackedSource asts => $"v1/cryptos/{CryptoCode}/addresses/{asts.Address}", WalletTrackedSource wts => $"v1/cryptos/{CryptoCode}/wallets/{wts.WalletId}", - _ => throw UnSupported(trackedSource) + _ => $"v1/cryptos/{CryptoCode}/tracked-sources/{trackedSource}", }; } } diff --git a/NBXplorer.Client/Models/TrackedSourceRequest.cs b/NBXplorer.Client/Models/TrackedSourceRequest.cs new file mode 100644 index 000000000..86f2845a0 --- /dev/null +++ b/NBXplorer.Client/Models/TrackedSourceRequest.cs @@ -0,0 +1,6 @@ +namespace NBXplorer.Models; + +public class TrackedSourceRequest +{ + public TrackedSource TrackedSource { get; set; } +} \ No newline at end of file diff --git a/NBXplorer.Tests/UnitTest1.cs b/NBXplorer.Tests/UnitTest1.cs index 56664c367..411db03c6 100644 --- a/NBXplorer.Tests/UnitTest1.cs +++ b/NBXplorer.Tests/UnitTest1.cs @@ -4665,6 +4665,54 @@ await Eventually(async () => var kpi = await tester.Client.GetKeyInformationsAsync(addressA.ScriptPubKey, CancellationToken.None); var tss = kpi.Select(information => information.TrackedSource); Assert.True(tss.Distinct().Count() == tss.Count(), "The result should only distinct tracked source matches. While this endpoint is marked obsolete, the same logic is used to trigger events, which means there will be duplicated events when the script is matched against"); + + var parentsOfC = await tester.Client.GetParentWallets(walletC); + Assert.Equal(2, parentsOfC.Length); + Assert.Contains(parentsOfC, w => w == walletA); + Assert.Contains(parentsOfC, w => w == walletB); + + var parentsOfB = await tester.Client.GetParentWallets(walletB); + Assert.Equal(1, parentsOfB.Length); + Assert.Contains(parentsOfB, w => w == walletA); + + var parentsOfA = await tester.Client.GetParentWallets(walletA); + Assert.Empty(parentsOfA); + + var childrenOfA= await tester.Client.GetChildWallets(walletA); + Assert.Equal(2, childrenOfA.Length); + + Assert.Contains(childrenOfA, w => w == walletB); + Assert.Contains(childrenOfA, w => w == walletC); + + var childrenOfB= await tester.Client.GetChildWallets(walletB); + Assert.Equal(1, childrenOfB.Length); + Assert.Contains(childrenOfB, w => w == walletC); + + var childrenOfC = await tester.Client.GetChildWallets(walletC); + Assert.Empty(childrenOfC); + + await tester.Client.RemoveParentWallet(walletB, walletA); + await tester.Client.RemoveChildWallet(walletB, walletC); + + parentsOfB = await tester.Client.GetParentWallets(walletB); + Assert.Empty(parentsOfB); + + childrenOfB = await tester.Client.GetChildWallets(walletB); + Assert.Empty(childrenOfB); + + + await tester.Client.AddParentWallet(walletB, walletA); + await tester.Client.AddChildWallet(walletB, walletC); + + + childrenOfB= await tester.Client.GetChildWallets(walletB); + Assert.Equal(1, childrenOfB.Length); + Assert.Contains(childrenOfB, w => w == walletC); + + parentsOfB = await tester.Client.GetParentWallets(walletB); + Assert.Equal(1, parentsOfB.Length); + Assert.Contains(parentsOfB, w => w == walletA); + } } } diff --git a/NBXplorer/Backends/Postgres/PostgresRepository.cs b/NBXplorer/Backends/Postgres/PostgresRepository.cs index cc6e1554d..26cec6255 100644 --- a/NBXplorer/Backends/Postgres/PostgresRepository.cs +++ b/NBXplorer/Backends/Postgres/PostgresRepository.cs @@ -239,6 +239,36 @@ internal WalletKey GetWalletKey(TrackedSource source) _ => throw new NotSupportedException(source.GetType().ToString()) }; } + + internal TrackedSource GetTrackedSource(WalletKey walletKey) + { + var metadata = JObject.Parse(walletKey.metadata); + if (metadata.TryGetValue("type", StringComparison.OrdinalIgnoreCase, out JToken typeJToken) && + typeJToken.Value() is { } type) + { + if ((metadata.TryGetValue("code", StringComparison.OrdinalIgnoreCase, out JToken codeJToken) && + codeJToken.Value() is { } code) && !code.Equals(Network.CryptoCode, + StringComparison.InvariantCultureIgnoreCase)) + { + return null; + } + + switch (type) + { + case "NBXv1-Derivation": + var derivation = metadata["derivation"].Value(); + return new DerivationSchemeTrackedSource(Network.DerivationStrategyFactory.Parse(derivation)); + case "NBXv1-Address": + var address = metadata["address"].Value(); + return new AddressTrackedSource(BitcoinAddress.Create(address, Network.NBitcoinNetwork)); + case "Wallet": + return new WalletTrackedSource(walletKey.wid); + } + } + + return null; + } + public async Task AssociateScriptsToWalletExplicitly(TrackedSource trackedSource, Dictionary scripts) { @@ -1302,8 +1332,9 @@ public async Task EnsureWalletCreated(DerivationStrategyBase strategy) await EnsureWalletCreated(GetWalletKey(strategy)); } - public async Task EnsureWalletCreated(TrackedSource trackedSource, TrackedSource[] parentTrackedSource = null) + public async Task EnsureWalletCreated(TrackedSource trackedSource, params TrackedSource[] parentTrackedSource) { + parentTrackedSource = parentTrackedSource.Where(source => source is not null).ToArray(); await EnsureWalletCreated(GetWalletKey(trackedSource), parentTrackedSource?.Select(GetWalletKey).ToArray()); } diff --git a/NBXplorer/Controllers/MainController.PSBT.cs b/NBXplorer/Controllers/MainController.PSBT.cs index 5c934d105..f121a1e97 100644 --- a/NBXplorer/Controllers/MainController.PSBT.cs +++ b/NBXplorer/Controllers/MainController.PSBT.cs @@ -28,7 +28,7 @@ public async Task CreatePSBT( { if (body == null) throw new ArgumentNullException(nameof(body)); - CreatePSBTRequest request = ParseJObject(body, trackedSourceContext.Network); + CreatePSBTRequest request = trackedSourceContext.Network.ParseJObject(body); var repo = RepositoryProvider.GetRepository(trackedSourceContext.Network); var txBuilder = request.Seed is int s ? trackedSourceContext.Network.NBitcoinNetwork.CreateTransactionBuilder(s) @@ -388,7 +388,7 @@ public async Task UpdatePSBT( [FromBody] JObject body) { - var update = ParseJObject(body, network); + var update = network.ParseJObject(body); if (update.PSBT == null) throw new NBXplorerException(new NBXplorerError(400, "missing-parameter", "'psbt' is missing")); await UpdatePSBTCore(update, network); @@ -586,11 +586,6 @@ await Task.WhenAll(update.PSBT.Inputs } } - protected T ParseJObject(JObject requestObj, NBXplorerNetwork network) - { - if (requestObj == null) - return default; - return network.Serializer.ToObject(requestObj); - } + } } diff --git a/NBXplorer/Controllers/MainController.cs b/NBXplorer/Controllers/MainController.cs index 6c1cea33a..ed3ca723d 100644 --- a/NBXplorer/Controllers/MainController.cs +++ b/NBXplorer/Controllers/MainController.cs @@ -503,7 +503,6 @@ private bool HasTxIndex(string cryptoCode) } [HttpPost] - [Route($"{CommonRoutes.DerivationEndpoint}")] [Route($"{CommonRoutes.AddressEndpoint}")] [Route($"{CommonRoutes.WalletEndpoint}")] @@ -512,7 +511,7 @@ public async Task TrackWallet( TrackedSourceContext trackedSourceContext, [FromBody] JObject rawRequest = null) { - var request = ParseJObject(rawRequest ?? new JObject(), trackedSourceContext.Network); + var request = trackedSourceContext.Network.ParseJObject(rawRequest ?? new JObject()); var repo = RepositoryProvider.GetRepository(trackedSourceContext.Network); if (repo is PostgresRepository postgresRepository && @@ -524,7 +523,7 @@ public async Task TrackWallet( throw new NBXplorerException(new NBXplorerError(400, "parent-wallet-same-as-tracked-source", "Parent wallets cannot be the same as the tracked source")); } - await postgresRepository.EnsureWalletCreated(trackedSourceContext.TrackedSource, request?.ParentWallet is null? null: new []{request?.ParentWallet }); + await postgresRepository.EnsureWalletCreated(trackedSourceContext.TrackedSource, request?.ParentWallet); } if (repo is not PostgresRepository && request.ParentWallet is not null) throw new NBXplorerException(new NBXplorerError(400, "parent-wallet-not-supported", @@ -586,9 +585,7 @@ public async Task GetTransactions( bool includeTransaction = true) { TransactionInformation fetchedTransactionInfo = null; - - var repo = RepositoryProvider.GetRepository(trackedSourceContext.Network); - + var repo = trackedSourceContext.Repository; var response = new GetTransactionsResponse(); int currentHeight = (await repo.GetTip()).Height; response.Height = currentHeight; @@ -676,7 +673,7 @@ public async Task Rescan(TrackedSourceContext trackedSourceContex { if (body == null) throw new ArgumentNullException(nameof(body)); - var rescanRequest = ParseJObject(body, trackedSourceContext.Network); + var rescanRequest = trackedSourceContext.Network.ParseJObject(body); if (rescanRequest == null) throw new ArgumentNullException(nameof(rescanRequest)); if (rescanRequest?.Transactions == null) @@ -1036,7 +1033,7 @@ public async Task Broadcast( [TrackedSourceContext.TrackedSourceContextRequirement(false, false)] public async Task GenerateWallet(TrackedSourceContext trackedSourceContext, [FromBody] JObject rawRequest = null) { - var request = ParseJObject(rawRequest, trackedSourceContext.Network) ?? new GenerateWalletRequest(); + var request = trackedSourceContext.Network.ParseJObject(rawRequest ) ?? new GenerateWalletRequest(); if (request.ImportKeysToRPC && trackedSourceContext.RpcClient is null) { diff --git a/NBXplorer/Controllers/PostgresMainController.cs b/NBXplorer/Controllers/PostgresMainController.cs index d8fa4f4a5..fbf337c83 100644 --- a/NBXplorer/Controllers/PostgresMainController.cs +++ b/NBXplorer/Controllers/PostgresMainController.cs @@ -13,6 +13,7 @@ using NBXplorer.Models; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; namespace NBXplorer.Controllers { @@ -256,5 +257,64 @@ public async Task GetUTXOs( TrackedSourceContext trackedSourceCon } return Json(changes, trackedSourceContext.Network.JsonSerializerSettings); } + + + [HttpGet("children")] + public async Task GetWalletChildren( TrackedSourceContext trackedSourceContext) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + await using var conn = await ConnectionFactory.CreateConnection(); + var children = await conn.QueryAsync($"SELECT w.wallet_id, w.metadata FROM wallets_wallets ww JOIN wallets w ON ww.wallet_id = w.wallet_id WHERE ww.parent_id=@walletId", new { walletId = repo.GetWalletKey(trackedSourceContext.TrackedSource).wid }); + + return Json(children.Select(c => repo.GetTrackedSource(new PostgresRepository.WalletKey(c.wallet_id, c.metadata)) ).ToArray(), trackedSourceContext.Network.JsonSerializerSettings); + } + [HttpGet("parents")] + public async Task GetWalletParents( TrackedSourceContext trackedSourceContext) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + await using var conn = await ConnectionFactory.CreateConnection(); + var children = await conn.QueryAsync($"SELECT w.wallet_id, w.metadata FROM wallets_wallets ww JOIN wallets w ON ww.parent_id = w.wallet_id WHERE ww.wallet_id=@walletId", new { walletId = repo.GetWalletKey(trackedSourceContext.TrackedSource).wid }); + + return Json(children.Select(c => repo.GetTrackedSource(new PostgresRepository.WalletKey(c.wallet_id, c.metadata)) ).ToArray(), trackedSourceContext.Network.JsonSerializerSettings); + } + [HttpPost("children")] + public async Task AddWalletChild( TrackedSourceContext trackedSourceContext, [FromBody] JObject request) + { + var trackedSource = trackedSourceContext.Network.ParseJObject(request).TrackedSource; + var repo = (PostgresRepository)trackedSourceContext.Repository; + await repo.EnsureWalletCreated(trackedSource, trackedSourceContext.TrackedSource); + return Ok(); + } + [HttpPost("parents")] + public async Task AddWalletParent( TrackedSourceContext trackedSourceContext, [FromBody] JObject request) + { + var trackedSource = trackedSourceContext.Network.ParseJObject(request).TrackedSource; + var repo = (PostgresRepository)trackedSourceContext.Repository; + await repo.EnsureWalletCreated(trackedSourceContext.TrackedSource, trackedSource); + return Ok(); + } + [HttpDelete("children")] + public async Task RemoveWalletChild( TrackedSourceContext trackedSourceContext, [FromBody] JObject request) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + + var trackedSource = repo.GetWalletKey(trackedSourceContext.Network + .ParseJObject(request).TrackedSource); + var conn = await ConnectionFactory.CreateConnection(); + await conn.ExecuteAsync($"DELETE FROM wallets_wallets WHERE wallet_id=@walletId AND parent_id=@parentId", new { walletId = trackedSource.wid, parentId = repo.GetWalletKey(trackedSourceContext.TrackedSource).wid }); + return Ok(); + } + [HttpDelete("parents")] + public async Task RemoveWalletParent( TrackedSourceContext trackedSourceContext, [FromBody] JObject request) + { + var repo = (PostgresRepository)trackedSourceContext.Repository; + + var trackedSource = repo.GetWalletKey(trackedSourceContext.Network + .ParseJObject(request).TrackedSource); + var conn = await ConnectionFactory.CreateConnection(); + await conn.ExecuteAsync($"DELETE FROM wallets_wallets WHERE wallet_id=@walletId AND parent_id=@parentId", new { walletId = repo.GetWalletKey(trackedSourceContext.TrackedSource).wid, parentId = trackedSource.wid }); + return Ok(); + } } + } diff --git a/NBXplorer/Extensions.cs b/NBXplorer/Extensions.cs index dca02252a..a3df78380 100644 --- a/NBXplorer/Extensions.cs +++ b/NBXplorer/Extensions.cs @@ -31,11 +31,19 @@ using Npgsql; using NBitcoin.Altcoins; using System.Threading; +using Newtonsoft.Json.Linq; namespace NBXplorer { public static class Extensions { + public static T ParseJObject(this NBXplorerNetwork network, JObject requestObj) + { + if (requestObj == null) + return default; + return network.Serializer.ToObject(requestObj); + } + public static async Task ReliableOpenConnectionAsync(this NpgsqlDataSource ds, CancellationToken cancellationToken = default) { int maxRetries = 10;