diff --git a/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.Objects.cs b/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.Objects.cs index 979d064d7..9fd10fed1 100644 --- a/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.Objects.cs +++ b/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.Objects.cs @@ -11,45 +11,71 @@ namespace BTCPayServer.Client { public partial class BTCPayServerClient { - public virtual async Task GetOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query, - CancellationToken token = default) + public virtual async Task GetOnChainWalletObject(string storeId, string cryptoCode, OnChainWalletObjectId objectId, bool? includeNeighbourData = null, CancellationToken token = default) { + Dictionary parameters = new Dictionary(); + if (includeNeighbourData is bool v) + parameters.Add("includeNeighbourData", v); + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectId.Type}/{objectId.Id}", parameters, method: HttpMethod.Get), token); + try + { + return await HandleResponse(response); + } + catch (GreenfieldAPIException err) when (err.APIError.Code == "wallet-object-not-found") + { + return null; + } + } + public virtual async Task GetOnChainWalletObjects(string storeId, string cryptoCode, GetWalletObjectsRequest query = null, CancellationToken token = default) + { + Dictionary parameters = new Dictionary(); + if (query?.Type is string s) + parameters.Add("type", s); + if (query?.Ids is string[] ids) + parameters.Add("ids", ids); + if (query?.IncludeNeighbourData is bool v) + parameters.Add("includeNeighbourData", v); var response = await _httpClient.SendAsync( - CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects", method:HttpMethod.Get, bodyPayload: query), token); + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects", parameters, method:HttpMethod.Get), token); return await HandleResponse(response); } - public virtual async Task RemoveOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query, + public virtual async Task RemoveOnChainWalletObject(string storeId, string cryptoCode, OnChainWalletObjectId objectId, CancellationToken token = default) { var response = await _httpClient.SendAsync( - CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects", method:HttpMethod.Delete, bodyPayload: query), token); + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectId.Type}/{objectId.Id}", method:HttpMethod.Delete), token); await HandleResponse(response); } - public virtual async Task AddOrUpdateOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectData[] request, + public virtual async Task AddOrUpdateOnChainWalletObject(string storeId, string cryptoCode, AddOnChainWalletObjectRequest request, CancellationToken token = default) { var response = await _httpClient.SendAsync( CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects", method:HttpMethod.Post, bodyPayload: request), token); - await HandleResponse(response); + return await HandleResponse(response); } - public virtual async Task AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode, AddOnChainWalletObjectLinkRequest[] request, + public virtual async Task AddOrUpdateOnChainWalletLink(string storeId, string cryptoCode, + OnChainWalletObjectId objectId, + AddOnChainWalletObjectLinkRequest request = null, CancellationToken token = default) { var response = await _httpClient.SendAsync( - CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/object-links", method:HttpMethod.Post, bodyPayload: request), token); + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectId.Type}/{objectId.Id}/links", method:HttpMethod.Post, bodyPayload: request), token); await HandleResponse(response); } public virtual async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode, - RemoveOnChainWalletObjectLinkRequest[] request, + OnChainWalletObjectId objectId, + OnChainWalletObjectId link, CancellationToken token = default) { var response = await _httpClient.SendAsync( - CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/object-links", method:HttpMethod.Delete, bodyPayload: request), token); + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectId.Type}/{objectId.Id}/links/{link.Type}/{link.Id}", method:HttpMethod.Delete), token); await HandleResponse(response); } } diff --git a/BTCPayServer.Client/Models/MarkPayoutRequest.cs b/BTCPayServer.Client/Models/MarkPayoutRequest.cs index 313ccde30..962664108 100644 --- a/BTCPayServer.Client/Models/MarkPayoutRequest.cs +++ b/BTCPayServer.Client/Models/MarkPayoutRequest.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +#nullable enable +using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; diff --git a/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs b/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs index b44d8f82e..fafe4eca8 100644 --- a/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs +++ b/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs @@ -8,48 +8,81 @@ using Newtonsoft.Json.Linq; namespace BTCPayServer.Client.Models { - - public class OnChainWalletObjectQuery - { - public string[]? Types { get; set; } - public OnChainWalletObjectId[]? Parents { get; set; } - public OnChainWalletObjectId[]? Children { get; set; } - - public bool IncludeLinks { get; set; } - } - - - public class OnChainWalletObjectId { + public OnChainWalletObjectId() + { + + } + public OnChainWalletObjectId(string type, string id) + { + Type = type; + Id = id; + } public string Type { get; set; } public string Id { get; set; } } - - public class RemoveOnChainWalletObjectLinkRequest + public class AddOnChainWalletObjectLinkRequest : OnChainWalletObjectId { - public OnChainWalletObjectId Parent { get; set; } - public OnChainWalletObjectId Child { get; set; } - } - public class AddOnChainWalletObjectLinkRequest - { - public OnChainWalletObjectId Parent { get; set; } - public OnChainWalletObjectId Child { get; set; } - public JObject? Data { get; set; } - } - - - public class OnChainWalletObjectData:OnChainWalletObjectId - { - public class OnChainWalletObjectLink:OnChainWalletObjectId + public AddOnChainWalletObjectLinkRequest() { - public JObject? LinkData { get; set; } + } - public JObject? Data { get; set; } - public OnChainWalletObjectLink[]? Parents { get; set; } - public OnChainWalletObjectLink[]? Children { get; set; } + public AddOnChainWalletObjectLinkRequest(string objectType, string objectId) : base(objectType, objectId) + { + + } + public JObject Data { get; set; } } - + + public class GetWalletObjectsRequest + { + public string Type { get; set; } + public string[] Ids { get; set; } + public bool? IncludeNeighbourData { get; set; } + } + + public class AddOnChainWalletObjectRequest : OnChainWalletObjectId + { + public AddOnChainWalletObjectRequest() + { + + } + public AddOnChainWalletObjectRequest(string objectType, string objectId) : base(objectType, objectId) + { + + } + public JObject Data { get; set; } + } + + public class OnChainWalletObjectData : OnChainWalletObjectId + { + public OnChainWalletObjectData() + { + + } + public OnChainWalletObjectData(string type, string id) : base(type, id) + { + + } + + public class OnChainWalletObjectLink : OnChainWalletObjectId + { + public OnChainWalletObjectLink() + { + + } + public OnChainWalletObjectLink(string type, string id) : base(type, id) + { + + } + public JObject LinkData { get; set; } + public JObject ObjectData { get; set; } + } + public JObject Data { get; set; } + public OnChainWalletObjectLink[] Links { get; set; } + } + public class OnChainWalletTransactionData { [JsonConverter(typeof(UInt256JsonConverter))] diff --git a/BTCPayServer.Data/Data/WalletObjectData.cs b/BTCPayServer.Data/Data/WalletObjectData.cs index ddb0dc50d..9bbb8af4b 100644 --- a/BTCPayServer.Data/Data/WalletObjectData.cs +++ b/BTCPayServer.Data/Data/WalletObjectData.cs @@ -30,6 +30,36 @@ namespace BTCPayServer.Data public List ChildLinks { get; set; } public List ParentLinks { get; set; } + public IEnumerable<(string type, string id, string linkdata, string objectdata)> GetLinks() + { + if (ChildLinks is not null) + foreach (var c in ChildLinks) + { + yield return (c.ChildType, c.ChildId, c.Data, c.Child?.Data); + } + if (ParentLinks is not null) + foreach (var c in ParentLinks) + { + yield return (c.ParentType, c.ParentId, c.Data, c.Parent?.Data); + } + } + + public IEnumerable GetNeighbours() + { + if (ChildLinks != null) + foreach (var c in ChildLinks) + { + if (c.Child != null) + yield return c.Child; + } + if (ParentLinks != null) + foreach (var c in ParentLinks) + { + if (c.Parent != null) + yield return c.Parent; + } + } + internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) { builder.Entity().HasKey(o => diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 1300372c3..9181c34d3 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -2996,113 +2996,119 @@ namespace BTCPayServer.Tests { using var tester = CreateServerTester(); await tester.StartAsync(); - + var admin = tester.NewAccount(); await admin.GrantAccessAsync(true); - + var client = await admin.CreateClient(Policies.Unrestricted); - Assert.Empty(await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new())); - await client.AddOrUpdateOnChainWalletObjects(admin.StoreId, "BTC", - new OnChainWalletObjectData[] {new OnChainWalletObjectData() {Id = "test", Type = "test"}}); - Assert.Single(await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new())); - Assert.Single(await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new() - { - Types = new []{ "test"}, - })); - Assert.Empty(await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new() - { - Types = new []{ "test-wrong"}, - })); - await client.RemoveOnChainWalletObjects(admin.StoreId, "BTC", new() - { - Types = new []{ "test"}, - }); - - Assert.Empty(await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new())); - - await client.AddOrUpdateOnChainWalletObjects(admin.StoreId, "BTC", - new OnChainWalletObjectData[] {new OnChainWalletObjectData() {Id = "test", Type = "test", Children = new [] - { - new OnChainWalletObjectData.OnChainWalletObjectLink() - { - Id = "test-child", - Type = "test", - - }, - }, - Parents = new []{ new OnChainWalletObjectData.OnChainWalletObjectLink() - { - Id = "test-parent", - Type = "test", - - },} - }}); - Assert.Single(await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new() - { - Types = new []{ "test"}, - })); - - - await client.AddOrUpdateOnChainWalletObjects(admin.StoreId, "BTC", - new OnChainWalletObjectData[] { - new OnChainWalletObjectData() {Id = "test-child", Type = "test",}, - new OnChainWalletObjectData() {Id = "test-parent", Type = "test",}, - - - new OnChainWalletObjectData() {Id = "test", Type = "test", Children = new [] - { - new OnChainWalletObjectData.OnChainWalletObjectLink() - { - Id = "test-child", - Type = "test", - - }, - }, - Parents = new []{ new OnChainWalletObjectData.OnChainWalletObjectLink() - { - Id = "test-parent", - Type = "test", - - },} - }}); + Assert.Empty(await client.GetOnChainWalletObjects(admin.StoreId, "BTC")); + var test = new OnChainWalletObjectId("test", "test"); + Assert.NotNull(await client.AddOrUpdateOnChainWalletObject(admin.StoreId, "BTC", new AddOnChainWalletObjectRequest(test.Type, test.Id))); - var objs = await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new() {Types = new[] {"test"}, IncludeLinks = true}); + Assert.Single(await client.GetOnChainWalletObjects(admin.StoreId, "BTC")); + Assert.NotNull(await client.GetOnChainWalletObject(admin.StoreId, "BTC", test)); + Assert.Null(await client.GetOnChainWalletObject(admin.StoreId, "BTC", new OnChainWalletObjectId("test-wrong", "test"))); + Assert.Null(await client.GetOnChainWalletObject(admin.StoreId, "BTC", new OnChainWalletObjectId("test", "test-wrong"))); + + await client.RemoveOnChainWalletObject(admin.StoreId, "BTC", new OnChainWalletObjectId("test", "test")); + + Assert.Empty(await client.GetOnChainWalletObjects(admin.StoreId, "BTC")); + + var test1 = new OnChainWalletObjectId("test", "test1"); + var test2 = new OnChainWalletObjectId("test", "test2"); + await client.AddOrUpdateOnChainWalletObject(admin.StoreId, "BTC", new AddOnChainWalletObjectRequest(test.Type, test.Id)); + // Those links don't exists + await AssertAPIError("wallet-object-not-found", () => client.AddOrUpdateOnChainWalletLink(admin.StoreId, "BTC", test, new AddOnChainWalletObjectLinkRequest(test1.Type, test1.Id))); + await AssertAPIError("wallet-object-not-found", () => client.AddOrUpdateOnChainWalletLink(admin.StoreId, "BTC", test, new AddOnChainWalletObjectLinkRequest(test2.Type, test2.Id))); + + Assert.Single(await client.GetOnChainWalletObjects(admin.StoreId, "BTC")); + + await client.AddOrUpdateOnChainWalletObject(admin.StoreId, "BTC", new AddOnChainWalletObjectRequest(test1.Type, test1.Id)); + await client.AddOrUpdateOnChainWalletObject(admin.StoreId, "BTC", new AddOnChainWalletObjectRequest(test2.Type, test2.Id)); + + await client.AddOrUpdateOnChainWalletLink(admin.StoreId, "BTC", test, new AddOnChainWalletObjectLinkRequest(test1.Type, test1.Id)); + await client.AddOrUpdateOnChainWalletLink(admin.StoreId, "BTC", test, new AddOnChainWalletObjectLinkRequest(test2.Type, test2.Id)); + + var objs = await client.GetOnChainWalletObjects(admin.StoreId, "BTC"); Assert.Equal(3, objs.Length); var middleObj = objs.Single(data => data.Id == "test" && data.Type == "test"); - Assert.Equal("test-child", Assert.Single(middleObj.Children).Id); - Assert.Equal("test-parent", Assert.Single(middleObj.Parents).Id); - - Assert.Equal("test", Assert.Single(objs.Single(data => data.Id == "test-parent" && data.Type == "test").Children).Id); - Assert.Equal("test", Assert.Single(objs.Single(data => data.Id == "test-child" && data.Type == "test").Parents).Id); + Assert.Equal(2, middleObj.Links.Length); + Assert.Contains("test1", middleObj.Links.Select(l => l.Id)); + Assert.Contains("test2", middleObj.Links.Select(l => l.Id)); + + var test1Obj = objs.Single(data => data.Id == "test1" && data.Type == "test"); + var test2Obj = objs.Single(data => data.Id == "test2" && data.Type == "test"); + Assert.Single(test1Obj.Links.Select(l => l.Id), l => l == "test"); + Assert.Single(test2Obj.Links.Select(l => l.Id), l => l == "test"); await client.RemoveOnChainWalletLinks(admin.StoreId, "BTC", - new[] - { - new RemoveOnChainWalletObjectLinkRequest() - { - Parent = new OnChainWalletObjectId() {Id = "test-parent", Type = "test"}, - Child = new OnChainWalletObjectId() {Id = "test", Type = "test"} - } - }); + test1, + test); + + var testObj = await client.GetOnChainWalletObject(admin.StoreId, "BTC", test); + Assert.Single(testObj.Links.Select(l => l.Id), l => l == "test2"); + Assert.Single(testObj.Links); + test1Obj = await client.GetOnChainWalletObject(admin.StoreId, "BTC", test1); + Assert.Empty(test1Obj.Links); + + await client.AddOrUpdateOnChainWalletLink(admin.StoreId, "BTC", + test1, + new AddOnChainWalletObjectLinkRequest(test.Type, test.Id) { Data = new JObject() { ["testData"] = "lol" } }); + + // Add some data to test1 + await client.AddOrUpdateOnChainWalletObject(admin.StoreId, "BTC", new AddOnChainWalletObjectRequest() { Type = test1.Type, Id = test1.Id, Data = new JObject() { ["testData"] = "test1" } }); + + // Create a new type + await client.AddOrUpdateOnChainWalletObject(admin.StoreId, "BTC", new AddOnChainWalletObjectRequest() { Type = "newtype", Id = test1.Id }); + + testObj = await client.GetOnChainWalletObject(admin.StoreId, "BTC", test); + Assert.Single(testObj.Links.Where(l => l.Id == "test1" && l.LinkData["testData"]?.Value() == "lol")); + Assert.Single(testObj.Links.Where(l => l.Id == "test1" && l.ObjectData["testData"]?.Value() == "test1")); + testObj = await client.GetOnChainWalletObject(admin.StoreId, "BTC", test, false); + Assert.Single(testObj.Links.Where(l => l.Id == "test1" && l.LinkData["testData"]?.Value() == "lol")); + Assert.Single(testObj.Links.Where(l => l.Id == "test1" && l.ObjectData is null)); + + async Task TestWalletRepository(bool useInefficient) + { + // We should have 4 nodes, two `test` type and one `newtype` + // Only the node `test` `test` is connected to `test1` + var wid = new WalletId(admin.StoreId, "BTC"); + var repo = tester.PayTester.GetService(); + var allObjects = await repo.GetWalletObjects((GetWalletObjectsQuery)(new(wid, null) { UseInefficientPath = useInefficient })); + var allTests = await repo.GetWalletObjects((GetWalletObjectsQuery)(new(wid, "test") { UseInefficientPath = useInefficient })); + var twoTests2 = await repo.GetWalletObjects((GetWalletObjectsQuery)(new(wid, "test", new[] { "test1", "test2", "test-unk" }) { UseInefficientPath = useInefficient })); + var oneTest = await repo.GetWalletObjects((GetWalletObjectsQuery)(new(wid, "test", new[] { "test" }) { UseInefficientPath = useInefficient })); + var oneTestWithoutData = await repo.GetWalletObjects((GetWalletObjectsQuery)(new(wid, "test", new[] { "test" }) { UseInefficientPath = useInefficient, IncludeNeighbours = false })); + + Assert.Equal(4, allObjects.Count); + Assert.Equal(3, allTests.Count); + Assert.Equal(2, twoTests2.Count); + Assert.Single(oneTest); + Assert.NotNull(oneTest.First().Value.GetNeighbours().Select(n => n.Data).FirstOrDefault()); + Assert.Single(oneTestWithoutData); + Assert.Null(oneTestWithoutData.First().Value.GetNeighbours().Select(n => n.Data).FirstOrDefault()); + } + await TestWalletRepository(false); + await TestWalletRepository(true); + + { + var allObjects = await client.GetOnChainWalletObjects(admin.StoreId, "BTC"); + var allTests = await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new GetWalletObjectsRequest() { Type = "test" }); + var twoTests2 = await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new GetWalletObjectsRequest() { Type = "test", Ids = new[] { "test1", "test2", "test-unk" } }); + var oneTest = await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new GetWalletObjectsRequest() { Type = "test", Ids=new[] { "test" } }); + var oneTestWithoutData = await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new GetWalletObjectsRequest() { Type = "test", Ids = new[] { "test" }, IncludeNeighbourData = false }); + + Assert.Equal(4, allObjects.Length); + Assert.Equal(3, allTests.Length); + Assert.Equal(2, twoTests2.Length); + Assert.Single(oneTest); + Assert.NotNull(oneTest.First().Links.Select(n => n.ObjectData).FirstOrDefault()); + Assert.Single(oneTestWithoutData); + Assert.Null(oneTestWithoutData.First().Links.Select(n => n.ObjectData).FirstOrDefault()); + } - Assert.Empty(await client.GetOnChainWalletObjects(admin.StoreId, "BTC", - new() {Parents = new OnChainWalletObjectId[] {new() {Id = "test-parent", Type = "test"}}}) - ); - await client.AddOrUpdateOnChainWalletLinks(admin.StoreId, "BTC", - new AddOnChainWalletObjectLinkRequest[] - { - new() - { - Parent = new OnChainWalletObjectId() {Id = "test-parent", Type = "test"}, - Child = new OnChainWalletObjectId() {Id = "test", Type = "test"} - } - }); - - Assert.Single(await client.GetOnChainWalletObjects(admin.StoreId, "BTC", - new() {Parents = new OnChainWalletObjectId[] {new() {Id = "test-parent", Type = "test"}}}) - ); } [Fact(Timeout = TestTimeout)] diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 8e4c644b1..d4b07b3a4 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -6,6 +6,10 @@ Exe true + + False + + False diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs index 5c275865d..9b6a85363 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs @@ -28,6 +28,7 @@ using NBXplorer; using NBXplorer.Models; using Newtonsoft.Json.Linq; using StoreData = BTCPayServer.Data.StoreData; +using Microsoft.EntityFrameworkCore; namespace BTCPayServer.Controllers.Greenfield { @@ -621,115 +622,114 @@ namespace BTCPayServer.Controllers.Greenfield [HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - public async Task GetOnChainWalletObjects(string storeId, string cryptoCode, - [FromBody] OnChainWalletObjectQuery query) + public async Task GetOnChainWalletObjects(string storeId, string cryptoCode, string? type = null, [FromQuery(Name = "ids")] string[]? ids = null, bool? includeNeighbourData = null) + { + if (ids?.Length is 0 && !Request.Query.ContainsKey("ids")) + ids = null; + if (type is null && ids is not null) + ModelState.AddModelError(nameof(ids), "If ids is specified, type should be specified"); + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + var walletId = new WalletId(storeId, cryptoCode); + return Ok((await _walletRepository.GetWalletObjects(new(walletId, type, ids) { IncludeNeighbours = includeNeighbourData ?? true })).Select(kv => kv.Value).Select(ToModel).ToArray()); + } + [HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task GetOnChainWalletObject(string storeId, string cryptoCode, + string objectType, string objectId, + bool? includeNeighbourData = null) { var walletId = new WalletId(storeId, cryptoCode); - return Ok((await _walletRepository.GetWalletObjects(walletId, query)).Select(ToModel).ToArray()); + var wo = await _walletRepository.GetWalletObject(new(walletId, objectType, objectId), includeNeighbourData ?? true); + if (wo is null) + return WalletObjectNotFound(); + return Ok(ToModel(wo)); } - [HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects")] + [HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - public async Task RemoveOnChainWalletObjects(string storeId, string cryptoCode, - [FromBody] OnChainWalletObjectQuery query) + public async Task RemoveOnChainWalletObject(string storeId, string cryptoCode, + string objectType, string objectId) { var walletId = new WalletId(storeId, cryptoCode); - await _walletRepository.RemoveWalletObjects(walletId, query); - return Ok(); + if (await _walletRepository.RemoveWalletObjects(new WalletObjectId(walletId, objectType, objectId))) + return Ok(); + else + return WalletObjectNotFound(); + } + + private IActionResult WalletObjectNotFound() + { + return this.CreateAPIError(404, "wallet-object-not-found", "This wallet object's can't be found"); } [HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - public async Task AddOrUpdateOnChainWalletObjects(string storeId, string cryptoCode, - [FromBody] OnChainWalletObjectData[] request) + public async Task AddOrUpdateOnChainWalletObject(string storeId, string cryptoCode, + [FromBody] AddOnChainWalletObjectRequest request) { + if (request?.Type is null) + ModelState.AddModelError(nameof(request.Type), "Type is required"); + if (request?.Id is null) + ModelState.AddModelError(nameof(request.Id), "Id is required"); + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + var walletId = new WalletId(storeId, cryptoCode); - foreach (OnChainWalletObjectData onChainWalletObjectData in request) + + try { await _walletRepository.SetWalletObject( - new WalletObjectId(walletId, onChainWalletObjectData.Type, onChainWalletObjectData.Id), - onChainWalletObjectData.Data); + new WalletObjectId(walletId, request!.Type, request.Id), request.Data); + return await GetOnChainWalletObject(storeId, cryptoCode, request!.Type, request.Id); } - - foreach (OnChainWalletObjectData onChainWalletObjectData in request) + catch (DbUpdateException) { - if (onChainWalletObjectData.Children?.Any() is true) - { - var parent = new WalletObjectId(walletId, onChainWalletObjectData.Type, - onChainWalletObjectData.Id); - - foreach (var onChainWalletObjectLink in onChainWalletObjectData.Children) - { - try - { - await _walletRepository.SetWalletObjectLink(parent, - new WalletObjectId(walletId, onChainWalletObjectLink.Type, onChainWalletObjectLink.Id), - onChainWalletObjectData.Data); - } - catch (Exception e) - { - // ignored - } - } - } - - if (onChainWalletObjectData.Parents?.Any() is true) - { - var child = new WalletObjectId(walletId, onChainWalletObjectData.Type, - onChainWalletObjectData.Id); - - foreach (var onChainWalletObjectLink in onChainWalletObjectData.Parents) - { - try - { - await _walletRepository.SetWalletObjectLink( - new WalletObjectId(walletId, onChainWalletObjectLink.Type, onChainWalletObjectLink.Id), - child, onChainWalletObjectData.Data); - } - catch (Exception e) - { - } - } - } + return WalletObjectNotFound(); } - - return Ok(); } - [HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/object-links")] + [HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}/links")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode, - [FromBody] AddOnChainWalletObjectLinkRequest[] request) + string objectType, string objectId, + [FromBody] AddOnChainWalletObjectLinkRequest request) { + if (request?.Type is null) + ModelState.AddModelError(nameof(request.Type), "Type is required"); + if (request?.Id is null) + ModelState.AddModelError(nameof(request.Id), "Id is required"); + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + var walletId = new WalletId(storeId, cryptoCode); - foreach (AddOnChainWalletObjectLinkRequest addOnChainWalletObjectLinkRequest in request) + try { await _walletRepository.SetWalletObjectLink( - new WalletObjectId(walletId, addOnChainWalletObjectLinkRequest.Parent.Type, - addOnChainWalletObjectLinkRequest.Parent.Id), - new WalletObjectId(walletId, addOnChainWalletObjectLinkRequest.Child.Type, - addOnChainWalletObjectLinkRequest.Child.Id), addOnChainWalletObjectLinkRequest.Data); + new WalletObjectId(walletId, objectType, objectId), + new WalletObjectId(walletId, request!.Type, request.Id), + request?.Data); + return Ok(); + } + catch (DbUpdateException) + { + return WalletObjectNotFound(); } - - return Ok(); } - [HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/object-links")] + [HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}/links/{linkType}/{linkId}")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - public async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode, - [FromBody] RemoveOnChainWalletObjectLinkRequest[] request) + public async Task RemoveOnChainWalletLink(string storeId, string cryptoCode, + string objectType, string objectId, + string linkType, string linkId) { var walletId = new WalletId(storeId, cryptoCode); - foreach (RemoveOnChainWalletObjectLinkRequest removeOnChainWalletObjectLinkRequest in request) - { - await _walletRepository.RemoveWalletObjectLink( - new WalletObjectId(walletId, removeOnChainWalletObjectLinkRequest.Parent.Type, - removeOnChainWalletObjectLinkRequest.Parent.Id), - new WalletObjectId(walletId, removeOnChainWalletObjectLinkRequest.Child.Type, - removeOnChainWalletObjectLinkRequest.Child.Id)); - } - - return Ok(); + if (await _walletRepository.RemoveWalletObjectLink( + new WalletObjectId(walletId, objectType, objectId), + new WalletObjectId(walletId, linkType, linkId))) + return Ok(); + else + return WalletObjectNotFound(); } private OnChainWalletObjectData ToModel(WalletObjectData data) @@ -739,18 +739,18 @@ namespace BTCPayServer.Controllers.Greenfield Data = string.IsNullOrEmpty(data.Data) ? null : JObject.Parse(data.Data), Type = data.Type, Id = data.Id, - Children = data.ChildLinks?.Select(linkData => ToModel(linkData, false)).ToArray(), - Parents = data.ParentLinks?.Select(linkData => ToModel(linkData, true)).ToArray() + Links = data.GetLinks().Select(linkData => ToModel(linkData)).ToArray() }; } - private OnChainWalletObjectData.OnChainWalletObjectLink ToModel(WalletObjectLinkData data, bool isParent) + private OnChainWalletObjectData.OnChainWalletObjectLink ToModel((string type, string id, string linkdata, string objectdata) data) { return new OnChainWalletObjectData.OnChainWalletObjectLink() { - LinkData = string.IsNullOrEmpty(data.Data) ? null : JObject.Parse(data.Data), - Type = isParent ? data.ParentType : data.ChildType, - Id = isParent ? data.ParentId : data.ChildId, + LinkData = string.IsNullOrEmpty(data.linkdata) ? null : JObject.Parse(data.linkdata), + ObjectData = string.IsNullOrEmpty(data.objectdata) ? null : JObject.Parse(data.objectdata), + Type = data.type, + Id = data.id, }; } diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index 8a39b2cf8..2948fdb06 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -222,36 +222,38 @@ namespace BTCPayServer.Controllers.Greenfield await GetController().MarketTradeCustodianAccountAsset(storeId, accountId, request, cancellationToken)); } - public override async Task GetOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query, + public override async Task GetOnChainWalletObjects(string storeId, string cryptoCode, + GetWalletObjectsRequest query = null, CancellationToken token = default) { return GetFromActionResult( - await GetController().GetOnChainWalletObjects(storeId, cryptoCode, query)); - + await GetController().GetOnChainWalletObjects(storeId, cryptoCode, query?.Type, query?.Ids, query?.IncludeNeighbourData)); } - public override async Task RemoveOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query, - CancellationToken token = default) + public override async Task GetOnChainWalletObject(string storeId, string cryptoCode, OnChainWalletObjectId objectId, bool? includeNeighbourData = null, CancellationToken token = default) { - HandleActionResult(await GetController().RemoveOnChainWalletObjects(storeId, cryptoCode, query)); + return GetFromActionResult( + await GetController().GetOnChainWalletObject(storeId, cryptoCode, objectId.Type, objectId.Id, includeNeighbourData)); + } + public override async Task AddOrUpdateOnChainWalletObject(string storeId, string cryptoCode, AddOnChainWalletObjectRequest request, CancellationToken token = default) + { + return GetFromActionResult( + await GetController().AddOrUpdateOnChainWalletObject(storeId, cryptoCode, request)); } - public override async Task AddOrUpdateOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectData[] request, - CancellationToken token = default) + public override async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode, OnChainWalletObjectId objectId, OnChainWalletObjectId link, CancellationToken token = default) { - HandleActionResult(await GetController().AddOrUpdateOnChainWalletObjects(storeId, cryptoCode, request)); + HandleActionResult(await GetController().RemoveOnChainWalletLink(storeId, cryptoCode, objectId.Type, objectId.Id, link.Type, link.Id)); } - public override async Task AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode, AddOnChainWalletObjectLinkRequest[] request, - CancellationToken token = default) + public override async Task RemoveOnChainWalletObject(string storeId, string cryptoCode, OnChainWalletObjectId objectId, CancellationToken token = default) { - HandleActionResult(await GetController().AddOrUpdateOnChainWalletLinks(storeId, cryptoCode, request)); + HandleActionResult(await GetController().RemoveOnChainWalletObject(storeId, cryptoCode, objectId.Type, objectId.Id)); } - public override async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode, RemoveOnChainWalletObjectLinkRequest[] request, - CancellationToken token = default) + public override async Task AddOrUpdateOnChainWalletLink(string storeId, string cryptoCode, OnChainWalletObjectId objectId, AddOnChainWalletObjectLinkRequest request = null, CancellationToken token = default) { - HandleActionResult(await GetController().RemoveOnChainWalletLinks(storeId, cryptoCode, request)); + HandleActionResult(await GetController().AddOrUpdateOnChainWalletLinks(storeId, cryptoCode, objectId.Type, objectId.Id, request)); } public override async Task CreateWebhook(string storeId, CreateStoreWebhookRequest create, diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs index 9d0b11f54..346af2479 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs @@ -429,8 +429,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler var storeWalletMatched = (await _explorerClientProvider.GetExplorerClient(newTransaction.CryptoCode) .GetTransactionAsync(derivationSchemeSettings, newTransaction.NewTransactionEvent.TransactionData.TransactionHash)); - //if the wallet related to the store related to the payout does not have the tx: it is external - var isInternal = storeWalletMatched is { }; + //if the wallet related to the store of the payout does not have the tx: it has been paid externally + var isInternal = storeWalletMatched is not null; var proof = ParseProof(payout) as PayoutTransactionOnChainBlob ?? new PayoutTransactionOnChainBlob() { Accounted = isInternal }; diff --git a/BTCPayServer/Data/Payouts/PayoutExtensions.cs b/BTCPayServer/Data/Payouts/PayoutExtensions.cs index 8cda19dc6..0e5a93e05 100644 --- a/BTCPayServer/Data/Payouts/PayoutExtensions.cs +++ b/BTCPayServer/Data/Payouts/PayoutExtensions.cs @@ -42,7 +42,7 @@ namespace BTCPayServer.Data data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode))); } - public static JObject? GetProofBlobJson(this PayoutData data) + public static JObject GetProofBlobJson(this PayoutData data) { return data?.Proof is null ? null : JObject.Parse(Encoding.UTF8.GetString(data.Proof)); } diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index c34d245d0..be135350f 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -763,7 +763,7 @@ namespace BTCPayServer.HostedServices } public string PayoutId { get; set; } - public JObject? Proof { get; set; } + public JObject Proof { get; set; } public PayoutState State { get; set; } public static string GetErrorMessage(PayoutPaidResult result) diff --git a/BTCPayServer/QueryableExtensions.cs b/BTCPayServer/QueryableExtensions.cs deleted file mode 100644 index 3ceb7f7d2..000000000 --- a/BTCPayServer/QueryableExtensions.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; - -namespace BTCPayServer; -//from https://stackoverflow.com/a/67666993/275504 -public static class QueryableExtensions -{ - public static IQueryable FilterByItems(this IQueryable query, IEnumerable items, - Expression> filterPattern, bool isOr) - { - Expression predicate = null; - foreach (var item in items) - { - var itemExpr = Expression.Constant(item); - var itemCondition = ExpressionReplacer.Replace(filterPattern.Body, filterPattern.Parameters[1], itemExpr); - if (predicate == null) - predicate = itemCondition; - else - { - predicate = Expression.MakeBinary(isOr ? ExpressionType.OrElse : ExpressionType.AndAlso, predicate, - itemCondition); - } - } - - predicate ??= Expression.Constant(false); - var filterLambda = Expression.Lambda>(predicate, filterPattern.Parameters[0]); - - return query.Where(filterLambda); - } - - class ExpressionReplacer : ExpressionVisitor - { - readonly IDictionary _replaceMap; - - public ExpressionReplacer(IDictionary replaceMap) - { - _replaceMap = replaceMap ?? throw new ArgumentNullException(nameof(replaceMap)); - } - - public override Expression Visit(Expression exp) - { - if (exp != null && _replaceMap.TryGetValue(exp, out var replacement)) - return replacement; - return base.Visit(exp); - } - - public static Expression Replace(Expression expr, Expression toReplace, Expression toExpr) - { - return new ExpressionReplacer(new Dictionary { { toReplace, toExpr } }).Visit(expr); - } - - public static Expression Replace(Expression expr, IDictionary replaceMap) - { - return new ExpressionReplacer(replaceMap).Visit(expr); - } - - public static Expression GetBody(LambdaExpression lambda, params Expression[] toReplace) - { - if (lambda.Parameters.Count != toReplace.Length) - throw new InvalidOperationException(); - - return new ExpressionReplacer(Enumerable.Range(0, lambda.Parameters.Count) - .ToDictionary(i => (Expression) lambda.Parameters[i], i => toReplace[i])).Visit(lambda.Body); - } - } -} diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs index ab647c7d6..d2d20260b 100644 --- a/BTCPayServer/Services/WalletRepository.cs +++ b/BTCPayServer/Services/WalletRepository.cs @@ -14,6 +14,32 @@ namespace BTCPayServer.Services { #nullable enable public record WalletObjectId(WalletId WalletId, string Type, string Id); + public class GetWalletObjectsQuery + { + public GetWalletObjectsQuery(WalletId walletId) : this(walletId, null, null) + { + } + public GetWalletObjectsQuery(WalletObjectId walletObjectId) : this(walletObjectId.WalletId, walletObjectId.Type, new[] { walletObjectId.Id }) + { + + } + public GetWalletObjectsQuery(WalletId walletId, string type) : this (walletId, type, null) + { + } + public GetWalletObjectsQuery(WalletId walletId, string? type, string[]? ids) + { + ArgumentNullException.ThrowIfNull(walletId); + WalletId = walletId; + Type = type; + Ids = ids; + } + + public WalletId WalletId { get; set; } + public string? Type { get; set; } + public string[]? Ids { get; set; } + public bool IncludeNeighbours { get; set; } = true; + public bool UseInefficientPath { get; set; } + } #nullable restore public class WalletRepository { @@ -23,157 +49,212 @@ namespace BTCPayServer.Services { _ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); } - - public async Task> GetWalletTransactionsInfo(WalletId walletId, string[] transactionIds = null) +#nullable enable + public async Task GetWalletObject(WalletObjectId walletObjectId, bool includeNeighbours = true) { - ArgumentNullException.ThrowIfNull(walletId); + var r = await GetWalletObjects(new(walletObjectId) { IncludeNeighbours = includeNeighbours }); + return r.Select(o => o.Value).FirstOrDefault(); + } + public async Task> GetWalletObjects(GetWalletObjectsQuery queryObject) + { + ArgumentNullException.ThrowIfNull(queryObject); + if (queryObject.Ids != null && queryObject.Type is null) + throw new ArgumentException("If \"Ids\" is not null, \"Type\" is mandatory"); using var ctx = _ContextFactory.CreateContext(); - - IQueryable wols; - IQueryable wos; - // If we are using postgres, the `transactionIds.Contains(w.ChildId)` result in a long query like `ANY(@txId1, @txId2, @txId3, @txId4)` // Such request isn't well optimized by postgres, and create different requests clogging up // pg_stat_statements output, making it impossible to analyze the performance impact of this query. - if (ctx.Database.IsNpgsql() && transactionIds is not null) + // On top of this, the entity version is doing 2 left join to satisfy the Include queries, resulting in n*m row returned for each transaction. + // n being the number of children, m the number of parents. + if (ctx.Database.IsNpgsql() && !queryObject.UseInefficientPath) { - wos = ctx.WalletObjects - .FromSqlInterpolated($"SELECT wos.* FROM unnest({transactionIds}) t JOIN \"WalletObjects\" wos ON wos.\"WalletId\"={walletId.ToString()} AND wos.\"Type\"={WalletObjectData.Types.Tx} AND wos.\"Id\"=t") - .AsNoTracking(); - wols = ctx.WalletObjectLinks - .FromSqlInterpolated($"SELECT wol.* FROM unnest({transactionIds}) t JOIN \"WalletObjectLinks\" wol ON wol.\"WalletId\"={walletId.ToString()} AND wol.\"ChildType\"={WalletObjectData.Types.Tx} AND wol.\"ChildId\"=t") - .AsNoTracking(); + var connection = ctx.Database.GetDbConnection(); + if (connection.State != System.Data.ConnectionState.Open) + await connection.OpenAsync(); + + string typeFilter = queryObject.Type is not null ? "AND wos.\"Type\"=@type " : ""; + var cmd = connection.CreateCommand(); + var selectWalletObjects = + queryObject.Ids is null ? + $"SELECT wos.* FROM \"WalletObjects\" wos WHERE wos.\"WalletId\"=@walletId {typeFilter}" : + queryObject.Ids.Length == 1 ? + "SELECT wos.* FROM \"WalletObjects\" wos WHERE wos.\"WalletId\"=@walletId AND wos.\"Type\"=@type AND wos.\"Id\"=@id" : + "SELECT wos.* FROM unnest(@ids) t JOIN \"WalletObjects\" wos ON wos.\"WalletId\"=@walletId AND wos.\"Type\"=@type AND wos.\"Id\"=t"; + + var includeNeighbourSelect = queryObject.IncludeNeighbours ? ", wos2.\"Data\" AS \"Data2\"" : ""; + var includeNeighbourJoin = queryObject.IncludeNeighbours ? "LEFT JOIN \"WalletObjects\" wos2 ON wos.\"WalletId\"=wos2.\"WalletId\" AND wol.\"Type2\"=wos2.\"Type\" AND wol.\"Id2\"=wos2.\"Id\"" : ""; + var query = + $"SELECT wos.\"Id\", wos.\"Type\", wos.\"Data\", wol.\"LinkData\", wol.\"Type2\", wol.\"Id2\"{includeNeighbourSelect} FROM ({selectWalletObjects}) wos " + + $"LEFT JOIN LATERAL ( " + + "SELECT \"ParentType\" AS \"Type2\", \"ParentId\" AS \"Id2\", \"Data\" AS \"LinkData\" FROM \"WalletObjectLinks\" WHERE \"WalletId\"=wos.\"WalletId\" AND \"ChildType\"=wos.\"Type\" AND \"ChildId\"=wos.\"Id\" " + + "UNION " + + "SELECT \"ChildType\" AS \"Type2\", \"ChildId\" AS \"Id2\", \"Data\" AS \"LinkData\" FROM \"WalletObjectLinks\" WHERE \"WalletId\"=wos.\"WalletId\" AND \"ParentType\"=wos.\"Type\" AND \"ParentId\"=wos.\"Id\"" + + $" ) wol ON true " + includeNeighbourJoin; + cmd.CommandText = query; + var walletIdParam = cmd.CreateParameter(); + walletIdParam.ParameterName = "walletId"; + walletIdParam.Value = queryObject.WalletId.ToString(); + walletIdParam.DbType = System.Data.DbType.String; + cmd.Parameters.Add(walletIdParam); + + if (queryObject.Type != null) + { + var typeParam = cmd.CreateParameter(); + typeParam.ParameterName = "type"; + typeParam.Value = queryObject.Type; + typeParam.DbType = System.Data.DbType.String; + cmd.Parameters.Add(typeParam); + } + + if (queryObject.Ids != null) + { + if (queryObject.Ids.Length == 1) + { + var txIdParam = cmd.CreateParameter(); + txIdParam.ParameterName = "id"; + txIdParam.Value = queryObject.Ids[0]; + txIdParam.DbType = System.Data.DbType.String; + cmd.Parameters.Add(txIdParam); + } + else + { + var txIdsParam = cmd.CreateParameter(); + txIdsParam.ParameterName = "ids"; + txIdsParam.Value = queryObject.Ids.ToHashSet().ToList(); + txIdsParam.DbType = System.Data.DbType.Object; + cmd.Parameters.Add(txIdsParam); + } + } + await using var reader = await cmd.ExecuteReaderAsync(); + var wosById = new Dictionary(); + while (await reader.ReadAsync()) + { + WalletObjectData wo = new WalletObjectData(); + wo.Type = (string)reader["Type"]; + wo.Id = (string)reader["Id"]; + var id = new WalletObjectId(queryObject.WalletId, wo.Type, wo.Id); + wo.Data = reader["Data"] is DBNull ? null : (string)reader["Data"]; + if (wosById.TryGetValue(id, out var wo2)) + wo = wo2; + else + { + wosById.Add(id, wo); + wo.ChildLinks = new List(); + } + if (reader["Type2"] is not DBNull) + { + var l = new WalletObjectLinkData() + { + ChildType = (string)reader["Type2"], + ChildId = (string)reader["Id2"], + Data = reader["LinkData"] is DBNull ? null : (string)reader["LinkData"] + }; + wo.ChildLinks.Add(l); + l.Child = new WalletObjectData() + { + Type = l.ChildType, + Id = l.ChildId, + Data = (!queryObject.IncludeNeighbours || reader["Data2"] is DBNull) ? null : (string)reader["Data2"] + }; + } + } + return wosById; } else // Unefficient path { - wos = ctx.WalletObjects - .AsNoTracking() - .Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Tx && (transactionIds == null || transactionIds.Contains(w.Id))); - wols = ctx.WalletObjectLinks - .AsNoTracking() - .Where(w => w.WalletId == walletId.ToString() && w.ChildType == WalletObjectData.Types.Tx && (transactionIds == null || transactionIds.Contains(w.ChildId))); - } - var links = await wols - .Select(tx => - new + var q = ctx.WalletObjects + .Where(w => w.WalletId == queryObject.WalletId.ToString() && (queryObject.Type == null || w.Type == queryObject.Type) && (queryObject.Ids == null || queryObject.Ids.Contains(w.Id))); + if (queryObject.IncludeNeighbours) { - TxId = tx.ChildId, - AssociatedDataId = tx.ParentId, - AssociatedDataType = tx.ParentType, - AssociatedData = tx.Parent.Data - }) - .ToArrayAsync(); - var objs = await wos - .Select(tx => - new - { - TxId = tx.Id, - Data = tx.Data - }) - .ToArrayAsync(); + q = q.Include(o => o.ChildLinks).ThenInclude(o => o.Child) + .Include(o => o.ParentLinks).ThenInclude(o => o.Parent); + } + q = q.AsNoTracking(); - var result = new Dictionary(objs.Length); - foreach (var obj in objs) + var wosById = new Dictionary(); + foreach (var row in await q.ToListAsync()) + { + var id = new WalletObjectId(queryObject.WalletId, row.Type, row.Id); + wosById.TryAdd(id, row); + } + return wosById; + } + } +#nullable restore + public async Task> GetWalletTransactionsInfo(WalletId walletId, string[] transactionIds = null) + { + var wos = await GetWalletObjects((GetWalletObjectsQuery)(new(walletId, WalletObjectData.Types.Tx, transactionIds))); + var result = new Dictionary(wos.Count); + foreach (var obj in wos.Values) { var data = obj.Data is null ? null : JObject.Parse(obj.Data); - result.Add(obj.TxId, new WalletTransactionInfo(walletId) + var info = new WalletTransactionInfo(walletId) { Comment = data?["comment"]?.Value() - }); - } - - - foreach (var row in links) - { - JObject data = row.AssociatedData is null ? null : JObject.Parse(row.AssociatedData); - var info = result[row.TxId]; - - if (row.AssociatedDataType == WalletObjectData.Types.Label) + }; + result.Add(obj.Id, info); + foreach (var neighbour in obj.GetNeighbours()) { - info.LabelColors.TryAdd(row.AssociatedDataId, data["color"]?.Value() ?? "#000"); - } - else - { - info.Attachments.Add(new Attachment(row.AssociatedDataType, row.AssociatedDataId, row.AssociatedData is null ? null : JObject.Parse(row.AssociatedData))); + var neighbourData = neighbour.Data is null ? null : JObject.Parse(neighbour.Data); + if (neighbour.Type == WalletObjectData.Types.Label) + { + info.LabelColors.TryAdd(neighbour.Id, neighbourData?["color"]?.Value() ?? "#000"); + } + else + { + info.Attachments.Add(new Attachment(neighbour.Type, neighbour.Id, neighbourData)); + } } } return result; } + #nullable enable public async Task<(string Label, string Color)[]> GetWalletLabels(WalletId walletId) { await using var ctx = _ContextFactory.CreateContext(); - return (await (await GetWalletObjects(ctx, ctx.WalletObjects - .AsNoTracking(), walletId, new OnChainWalletObjectQuery() {Types = new[] {WalletObjectData.Types.Label}})).ToArrayAsync()) - .Select(o => (o.Id, JObject.Parse(o.Data)["color"]!.Value()!)) - .ToArray(); + return (await + ctx.WalletObjects.AsNoTracking().Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Label) + .ToArrayAsync()) + .Select(o => (o.Id, JObject.Parse(o.Data)["color"]!.Value()!)).ToArray(); } - public async Task GetWalletObjects(WalletId walletId, OnChainWalletObjectQuery query) + public async Task RemoveWalletObjects(WalletObjectId walletObjectId) { await using var ctx = _ContextFactory.CreateContext(); - return await (await GetWalletObjects(ctx,ctx.WalletObjects.AsNoTracking(), walletId, query)).ToArrayAsync(); - } - - public async Task RemoveWalletObjects( WalletId walletId, OnChainWalletObjectQuery query) - { - query.IncludeLinks = false; - await using var ctx = _ContextFactory.CreateContext(); - ctx.WalletObjects.RemoveRange(await GetWalletObjects(ctx,ctx.WalletObjects, walletId, query)); - await ctx.SaveChangesAsync(); + var entity = new WalletObjectData() + { + WalletId = walletObjectId.WalletId.ToString(), + Type = walletObjectId.Type, + Id = walletObjectId.Id + }; + ctx.WalletObjects.Add(entity); + ctx.Entry(entity).State = EntityState.Deleted; + try + { + await ctx.SaveChangesAsync(); + return true; + } + catch (DbUpdateException) // doesn't exists + { + return false; + } } - private async Task> GetWalletObjects(ApplicationDbContext applicationDbContext, - IQueryable queryable, WalletId walletId, OnChainWalletObjectQuery query) - { - var result = queryable.AsQueryable(); - result = result.Where(w => w.WalletId == walletId.ToString()); - if (query.IncludeLinks) - { - result = result - .Include(data => data.ChildLinks) - .Include(data => data.ParentLinks); - } - if (query.Types is not null) - { - result = result.Where(w => query.Types.Contains(w.Type)); - } - - if (query.Parents is not null) - { - var allowedChildren = await applicationDbContext.WalletObjectLinks - .Where(data => data.WalletId == walletId.ToString()) - .FilterByItems(query.Parents, (data, id) => data.ParentId == id.Id && data.ParentType == id.Type, - true).Select(data => new WalletObjectId(walletId, data.ChildType, data.ChildId)).ToArrayAsync(); - - result = result.FilterByItems(allowedChildren,(data, id) => data.Id == id.Id && data.Type == id.Type, true); - } - if (query.Children is not null) - { - var allowedParents = await applicationDbContext.WalletObjectLinks - .Where(data => data.WalletId == walletId.ToString()) - .FilterByItems(query.Children, (data, id) => data.ChildId == id.Id && data.ChildType == id.Type, - true).Select(data => new WalletObjectId(walletId, data.ParentType, data.ParentId)).ToArrayAsync(); - - result = result.FilterByItems(allowedParents,(data, id) => data.Id == id.Id && data.Type == id.Type, true); - } - - return result; - } - - public async Task EnsureWalletObjectLink(WalletObjectId parent, WalletObjectId child, JObject? data = null) + public async Task EnsureWalletObjectLink(WalletObjectId a, WalletObjectId b, JObject? data = null) { + SortWalletObjectLinks(ref a, ref b); await using var ctx = _ContextFactory.CreateContext(); var l = new WalletObjectLinkData() { - WalletId = parent.WalletId.ToString(), - ChildType = child.Type, - ChildId = child.Id, - ParentType = parent.Type, - ParentId = parent.Id, + WalletId = a.WalletId.ToString(), + ParentType = a.Type, + ParentId = a.Id, + ChildType = b.Type, + ChildId = b.Id, Data = data?.ToString(Formatting.None) }; ctx.WalletObjectLinks.Add(l); @@ -186,16 +267,40 @@ namespace BTCPayServer.Services } } - public async Task SetWalletObjectLink(WalletObjectId parent, WalletObjectId child, JObject? data = null) + class WalletObjectIdComparer : IComparer { + public static readonly WalletObjectIdComparer Instance = new WalletObjectIdComparer(); + public int Compare(WalletObjectId? x, WalletObjectId? y) + { + var c = StringComparer.InvariantCulture.Compare(x?.Type, y?.Type); + if (c == 0) + c = StringComparer.InvariantCulture.Compare(x?.Id, y?.Id); + return c; + } + } + + private void SortWalletObjectLinks(ref WalletObjectId a, ref WalletObjectId b) + { + if (a.WalletId != b.WalletId) + throw new ArgumentException("It shouldn't be possible to set a link between different wallets"); + var ab = new[] { a, b }; + Array.Sort(ab, WalletObjectIdComparer.Instance); + a = ab[0]; + b = ab[1]; + } + public async Task SetWalletObjectLink(WalletObjectId a, WalletObjectId b, JObject? data = null) + { + SortWalletObjectLinks(ref a, ref b); + + await using var ctx = _ContextFactory.CreateContext(); var l = new WalletObjectLinkData() { - WalletId = parent.WalletId.ToString(), - ChildType = child.Type, - ChildId = child.Id, - ParentType = parent.Type, - ParentId = parent.Id, + WalletId = a.WalletId.ToString(), + ParentType = a.Type, + ParentId = a.Id, + ChildType = b.Type, + ChildId = b.Id, Data = data?.ToString(Formatting.None) }; var e = ctx.WalletObjectLinks.Add(l); @@ -293,23 +398,26 @@ namespace BTCPayServer.Services } } - public async Task RemoveWalletObjectLink(WalletObjectId parent, WalletObjectId child) + public async Task RemoveWalletObjectLink(WalletObjectId a, WalletObjectId b) { + SortWalletObjectLinks(ref a, ref b); await using var ctx = _ContextFactory.CreateContext(); ctx.WalletObjectLinks.Remove(new WalletObjectLinkData() { - WalletId = parent.WalletId.ToString(), - ChildId = child.Id, - ChildType = child.Type, - ParentId = parent.Id, - ParentType = parent.Type + WalletId = a.WalletId.ToString(), + ParentId = a.Id, + ParentType = a.Type, + ChildId = b.Id, + ChildType = b.Type }); try { await ctx.SaveChangesAsync(); + return true; } catch (DbUpdateException) // Already deleted, do nothing { + return false; } } public async Task RemoveWalletObjectLabels(WalletObjectId id, params string[] labels) diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json index 8496cf8a5..f91b9d1ee 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json @@ -3,7 +3,7 @@ "/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects": { "get": { "tags": [ - "Store Wallet (On Chain) Objects" + "Store Wallet (On Chain)" ], "summary": "Get store on-chain wallet objects", "parameters": [ @@ -25,23 +25,39 @@ "type": "string" }, "example": "BTC" + }, + { + "name": "type", + "in": "query", + "required": false, + "description": "The type of object to fetch", + "schema": { "type": "string" }, + "example": "tx" + }, + { + "name": "ids", + "in": "query", + "required": false, + "description": "The ids of objects to fetch, if used, type should be specified", + "schema": { + "type": "array", + "items": { "type": "string" } + }, + "example": "03abcde..." + }, + { + "name": "includeNeighbourData", + "in": "query", + "required": false, + "description": "Whether or not you should include neighbour's node data in the result (ie, `links.objectData`)", + "schema": { "type": "boolean", "default": true } } ], "description": "View wallet objects", "operationId": "StoreOnChainWallets_GetOnChainWalletObjects", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OnChainWalletObjectQuery" - } - } - } - }, "responses": { "200": { - "description": "specified wallet", + "description": "Selected objects and their links", "content": { "application/json": { "schema": { @@ -69,67 +85,9 @@ } ] }, - "delete": { - "tags": [ - "Store Wallet (On Chain) Objects" - ], - "summary": "Remove store on-chain wallet objects", - "parameters": [ - { - "name": "storeId", - "in": "path", - "required": true, - "description": "The store to fetch", - "schema": { - "type": "string" - } - }, - { - "name": "cryptoCode", - "in": "path", - "required": true, - "description": "The crypto code of the payment method to fetch", - "schema": { - "type": "string" - }, - "example": "BTC" - } - ], - "description": "Remove wallet objects", - "operationId": "StoreOnChainWallets_RemoveOnChainWalletObjects", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/OnChainWalletObjectQuery" - } - } - } - }, - "responses": { - "200": { - "description": "successful removal of filtered objects" - }, - "403": { - "description": "If you are authenticated but forbidden to view the specified store" - }, - "404": { - "description": "The key is not found for this store/wallet" - } - }, - "security": [ - { - "API_Key": [ - "btcpay.store.canmodifystoresettings" - ], - "Basic": [] - } - ] - }, "post": { "tags": [ - "Store Wallet (On Chain) Objects" + "Store Wallet (On Chain)" ], "summary": "Add/Update store on-chain wallet objects", "parameters": [ @@ -160,12 +118,251 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { + "$ref": "#/components/schemas/OnChainWalletObjectData" + } + } + } + }, + "responses": { + "200": { + "description": "Wallet object's data and its links", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/OnChainWalletObjectData" } } } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}": { + "get": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Get store on-chain wallet object", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the payment method to fetch", + "schema": { + "type": "string" + }, + "example": "BTC" + }, + { + "name": "objectType", + "in": "path", + "required": true, + "description": "The object type to fetch", + "schema": { + "type": "string" + }, + "example": "tx" + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "The object id to fetch", + "schema": { + "type": "string" + }, + "example": "abc392..." + }, + { + "name": "includeNeighbourData", + "in": "query", + "required": false, + "description": "Whether or not you should include neighbour's node data in the result (ie, `links.objectData`)", + "schema": { + "type": "boolean", + "default": true + } + } + ], + "description": "View wallet object", + "operationId": "StoreOnChainWallets_GetOnChainWalletObject", + "responses": { + "200": { + "description": "Wallet object's data and its links", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnChainWalletObjectData" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + }, + "delete": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Remove store on-chain wallet objects", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the payment method to fetch", + "schema": { + "type": "string" + }, + "example": "BTC" + }, + { + "name": "objectType", + "in": "path", + "required": true, + "description": "The object type to fetch", + "schema": { + "type": "string" + }, + "example": "tx" + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "The object id to fetch", + "schema": { + "type": "string" + }, + "example": "abc392..." + } + ], + "description": "Remove wallet object", + "operationId": "StoreOnChainWallets_RemoveOnChainWalletObject", + "responses": { + "200": { + "description": "successful removal of filtered object" + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}/links": { + "post": { + "tags": [ + "Store Wallet (On Chain)" + ], + "summary": "Add/Update store on-chain wallet object link", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "cryptoCode", + "in": "path", + "required": true, + "description": "The crypto code of the payment method to fetch", + "schema": { + "type": "string" + }, + "example": "BTC" + }, + { + "name": "objectType", + "in": "path", + "required": true, + "description": "The object type to fetch", + "schema": { + "type": "string" + }, + "example": "tx" + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "The object id to fetch", + "schema": { + "type": "string" + }, + "example": "abc392..." + } + ], + "description": "Add/Update wallet object link", + "operationId": "StoreOnChainWallets_AddOrUpdateOnChainWalletLink", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddOnChainWalletObjectLinkRequest" + } + } } }, "responses": { @@ -189,10 +386,10 @@ ] } }, - "/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/object-links": { + "/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}/links/{linkType}/{linkId}": { "delete": { "tags": [ - "Store Wallet (On Chain) Objects" + "Store Wallet (On Chain)" ], "summary": "Remove store on-chain wallet object links", "parameters": [ @@ -214,87 +411,53 @@ "type": "string" }, "example": "BTC" - } - ], - "description": "Remove wallet object links", - "operationId": "StoreOnChainWallets_RemoveOnChainWalletLinks", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/RemoveOnChainWalletObjectLinkRequest" - } - } - } - } - }, - "responses": { - "200": { - "description": "successful removal of filtered object links" }, - "403": { - "description": "If you are authenticated but forbidden to view the specified store" - }, - "404": { - "description": "The key is not found for this store/wallet" - } - }, - "security": [ { - "API_Key": [ - "btcpay.store.canmodifystoresettings" - ], - "Basic": [] - } - ] - }, - "post": { - "tags": [ - "Store Wallet (On Chain) Objects" - ], - "summary": "Add/Update store on-chain wallet object links", - "parameters": [ - { - "name": "storeId", + "name": "objectType", "in": "path", "required": true, - "description": "The store to fetch", - "schema": { - "type": "string" - } - }, - { - "name": "cryptoCode", - "in": "path", - "required": true, - "description": "The crypto code of the payment method to fetch", + "description": "The object type to fetch", "schema": { "type": "string" }, - "example": "BTC" + "example": "tx" + }, + { + "name": "objectId", + "in": "path", + "required": true, + "description": "The object id to fetch", + "schema": { + "type": "string" + }, + "example": "abc392..." + }, + { + "name": "linkType", + "in": "path", + "required": true, + "description": "The object type of the linked neighbour", + "schema": { + "type": "string" + }, + "example": "tx" + }, + { + "name": "linkId", + "in": "path", + "required": true, + "description": "The object id of the linked neighbour", + "schema": { + "type": "string" + }, + "example": "abc392..." } ], - "description": "Add/Update wallet object links", - "operationId": "StoreOnChainWallets_AddOrUpdateOnChainWalletLinks", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AddOnChainWalletObjectLinkRequest" - } - } - } - } - }, + "description": "Remove wallet object link", + "operationId": "StoreOnChainWallets_RemoveOnChainWalletLink", "responses": { "200": { - "description": "action completed" + "description": "successful removal of filtered object link" }, "403": { "description": "If you are authenticated but forbidden to view the specified store" @@ -330,69 +493,7 @@ } } }, - "OnChainWalletObjectQuery": { - "type": "object", - "additionalProperties": false, - "properties": { - "types": { - "nullable": true, - "type": "array", - "items": { - "type": "string" - }, - "description": "The types of wallet objects you want to query" - }, - "parents": { - "nullable": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/OnChainWalletObjectId" - }, - "description": "Filter out objects which have these parents" - }, - "children": { - "nullable": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/OnChainWalletObjectId" - }, - "description": "Filter out objects which have these children" - }, - "includeLinks": { - "type": "boolean", - "description": "Whether to include the links in the object results. Note that if `Parents` or `Children` are used, this setting is implicitly true" - } - } - }, - "RemoveOnChainWalletObjectLinkRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "parent": { - "$ref": "#/components/schemas/OnChainWalletObjectId" - }, - "child": { - "$ref": "#/components/schemas/OnChainWalletObjectId" - } - } - }, "AddOnChainWalletObjectLinkRequest": { - "type": "object", - "additionalProperties": false, - "properties": { - "parent": { - "$ref": "#/components/schemas/OnChainWalletObjectId" - }, - "child": { - "$ref": "#/components/schemas/OnChainWalletObjectId" - }, - "data": { - "type": "object", - "additionalProperties": true - } - } - }, - "OnChainWalletObjectLink": { "type": "object", "additionalProperties": false, "allOf": [ @@ -401,9 +502,34 @@ } ], "properties": { + "data": { + "type": "object", + "additionalProperties": true, + "description": "The data of the link" + } + } + }, + "OnChainWalletObjectLink": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "description": "The type of wallet object" + }, + "id": { + "type": "string", + "description": "The identifier of the wallet object (unique per type, per wallet)" + }, "linkData": { "type": "object", - "additionalProperties": true + "additionalProperties": true, + "description": "The data of the link" + }, + "objectData": { + "type": "object", + "additionalProperties": true, + "description": "The data of the neighbour's node (`null` if there isn't any data or `includeNeighbourData` is `false`)" } } }, @@ -420,30 +546,16 @@ "type": "object", "additionalProperties": true }, - "parents": { + "links": { "nullable": true, "type": "array", "items": { "$ref": "#/components/schemas/OnChainWalletObjectLink" }, - "description": "objects which are parent to this object" - }, - "children": { - "nullable": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/OnChainWalletObjectLink" - }, - "description": "objects which are children to this object" + "description": "Links of this object" } } } } - }, - "tags": [ - { - "name": "Store Wallet (On Chain) Objects", - "description": "Store Wallet (On Chain) operations" - } - ] + } }