diff --git a/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.Objects.cs b/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.Objects.cs new file mode 100644 index 000000000..979d064d7 --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.OnChainWallet.Objects.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using NBitcoin; + +namespace BTCPayServer.Client +{ + public partial class BTCPayServerClient + { + public virtual async Task GetOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query, + CancellationToken token = default) + { + var response = + await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects", method:HttpMethod.Get, bodyPayload: query), token); + return await HandleResponse(response); + } + public virtual async Task RemoveOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query, + 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); + await HandleResponse(response); + } + public virtual async Task AddOrUpdateOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectData[] 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); + } + public virtual async Task AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode, AddOnChainWalletObjectLinkRequest[] request, + 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); + await HandleResponse(response); + } + public virtual async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode, + RemoveOnChainWalletObjectLinkRequest[] request, + 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); + await HandleResponse(response); + } + } +} diff --git a/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs b/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs index f2a179b77..b44d8f82e 100644 --- a/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs +++ b/BTCPayServer.Client/Models/OnChainWalletTransactionData.cs @@ -4,9 +4,52 @@ using BTCPayServer.JsonConverters; using NBitcoin; using NBitcoin.JsonConverters; using Newtonsoft.Json; +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 string Type { get; set; } + public string Id { get; set; } + } + + public class RemoveOnChainWalletObjectLinkRequest + { + 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 JObject? LinkData { get; set; } + } + public JObject? Data { get; set; } + public OnChainWalletObjectLink[]? Parents { get; set; } + public OnChainWalletObjectLink[]? Children { get; set; } + } + public class OnChainWalletTransactionData { [JsonConverter(typeof(UInt256JsonConverter))] diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index f8c161e4d..c8df6fe1f 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -2878,7 +2878,121 @@ namespace BTCPayServer.Tests Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress)); }); } - + + [Fact(Timeout = 60 * 2 * 1000)] + [Trait("Integration", "Integration")] + public async Task CanUseWalletObjectsAPI() + { + 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", + + },} + }}); + + var objs = await client.GetOnChainWalletObjects(admin.StoreId, "BTC", new() {Types = new[] {"test"}, IncludeLinks = true}); + 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); + + await client.RemoveOnChainWalletLinks(admin.StoreId, "BTC", + new[] + { + new RemoveOnChainWalletObjectLinkRequest() + { + Parent = new OnChainWalletObjectId() {Id = "test-parent", Type = "test"}, + Child = new OnChainWalletObjectId() {Id = "test", Type = "test"} + } + }); + + 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)] [Trait("Integration", "Integration")] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs index c04e37358..5c275865d 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreOnChainWalletsController.cs @@ -92,7 +92,7 @@ namespace BTCPayServer.Controllers.Greenfield public async Task ShowOnChainWalletOverview(string storeId, string cryptoCode) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; var wallet = _btcPayWalletProvider.GetWallet(network); @@ -112,7 +112,7 @@ namespace BTCPayServer.Controllers.Greenfield public async Task GetOnChainFeeRate(string storeId, string cryptoCode, int? blockTarget = null) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; var feeRateTarget = blockTarget ?? Store.GetStoreBlob().RecommendedFeeBlockTarget; @@ -125,10 +125,11 @@ namespace BTCPayServer.Controllers.Greenfield [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address")] - public async Task GetOnChainWalletReceiveAddress(string storeId, string cryptoCode, bool forceGenerate = false) + public async Task GetOnChainWalletReceiveAddress(string storeId, string cryptoCode, + bool forceGenerate = false) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; var kpi = await _walletReceiveService.GetOrGenerate(new WalletId(storeId, cryptoCode), forceGenerate); @@ -141,13 +142,14 @@ namespace BTCPayServer.Controllers.Greenfield var allowedPayjoin = derivationScheme.IsHotWallet && Store.GetStoreBlob().PayJoinEnabled; if (allowedPayjoin) { - bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint", new { cryptoCode }))); + bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, + Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint", + new {cryptoCode}))); } + return Ok(new OnChainWalletAddressData() { - Address = kpi.Address?.ToString(), - PaymentLink = bip21.ToString(), - KeyPath = kpi.KeyPath + Address = kpi.Address?.ToString(), PaymentLink = bip21.ToString(), KeyPath = kpi.KeyPath }); } @@ -156,7 +158,7 @@ namespace BTCPayServer.Controllers.Greenfield public async Task UnReserveOnChainWalletReceiveAddress(string storeId, string cryptoCode) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; var addr = await _walletReceiveService.UnReserveAddress(new WalletId(storeId, cryptoCode)); @@ -165,6 +167,7 @@ namespace BTCPayServer.Controllers.Greenfield return this.CreateAPIError("no-reserved-address", $"There was no reserved address for {cryptoCode} on this store."); } + return Ok(); } @@ -180,7 +183,7 @@ namespace BTCPayServer.Controllers.Greenfield ) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; var wallet = _btcPayWalletProvider.GetWallet(network); @@ -190,7 +193,8 @@ namespace BTCPayServer.Controllers.Greenfield var preFiltering = true; if (statusFilter?.Any() is true || !string.IsNullOrWhiteSpace(labelFilter)) preFiltering = false; - var txs = await wallet.FetchTransactionHistory(derivationScheme.AccountDerivation, preFiltering ? skip : 0, preFiltering ? limit : int.MaxValue); + var txs = await wallet.FetchTransactionHistory(derivationScheme.AccountDerivation, preFiltering ? skip : 0, + preFiltering ? limit : int.MaxValue); if (!preFiltering) { var filteredList = new List(txs.Count); @@ -202,6 +206,7 @@ namespace BTCPayServer.Controllers.Greenfield if (transactionInfo?.LabelColors.ContainsKey(labelFilter) is true) filteredList.Add(t); } + if (statusFilter?.Any() is true) { if (statusFilter.Contains(TransactionStatus.Confirmed) && t.Confirmations != 0) @@ -210,6 +215,7 @@ namespace BTCPayServer.Controllers.Greenfield filteredList.Add(t); } } + txs = filteredList; } @@ -227,7 +233,7 @@ namespace BTCPayServer.Controllers.Greenfield string transactionId) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; var wallet = _btcPayWalletProvider.GetWallet(network); @@ -239,16 +245,17 @@ namespace BTCPayServer.Controllers.Greenfield var walletId = new WalletId(storeId, cryptoCode); var walletTransactionsInfoAsync = - (await _walletRepository.GetWalletTransactionsInfo(walletId, new[] { transactionId })).Values + (await _walletRepository.GetWalletTransactionsInfo(walletId, new[] {transactionId})).Values .FirstOrDefault(); return Ok(ToModel(walletTransactionsInfoAsync, tx, wallet)); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - [HttpPatch("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}")] + [HttpPatch( + "~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}")] public async Task PatchOnChainWalletTransaction( - string storeId, + string storeId, string cryptoCode, string transactionId, [FromBody] PatchOnChainTransactionRequest request, @@ -256,7 +263,7 @@ namespace BTCPayServer.Controllers.Greenfield ) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; var wallet = _btcPayWalletProvider.GetWallet(network); @@ -280,7 +287,7 @@ namespace BTCPayServer.Controllers.Greenfield } var walletTransactionsInfo = - (await _walletRepository.GetWalletTransactionsInfo(walletId, new[] { transactionId })) + (await _walletRepository.GetWalletTransactionsInfo(walletId, new[] {transactionId})) .Values .FirstOrDefault(); @@ -292,14 +299,15 @@ namespace BTCPayServer.Controllers.Greenfield public async Task GetOnChainWalletUTXOs(string storeId, string cryptoCode) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; var wallet = _btcPayWalletProvider.GetWallet(network); var walletId = new WalletId(storeId, cryptoCode); var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation); - var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId, utxos.Select(u => u.OutPoint.Hash.ToString()).ToHashSet().ToArray()); + var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId, + utxos.Select(u => u.OutPoint.Hash.ToString()).ToHashSet().ToArray()); return Ok(utxos.Select(coin => { walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info); @@ -317,7 +325,9 @@ namespace BTCPayServer.Controllers.Greenfield Timestamp = coin.Timestamp, KeyPath = coin.KeyPath, Confirmations = coin.Confirmations, - Address = network.NBXplorerNetwork.CreateAddress(derivationScheme.AccountDerivation, coin.KeyPath, coin.ScriptPubKey).ToString() + Address = network.NBXplorerNetwork + .CreateAddress(derivationScheme.AccountDerivation, coin.KeyPath, coin.ScriptPubKey) + .ToString() }; }).ToList() ); @@ -329,7 +339,7 @@ namespace BTCPayServer.Controllers.Greenfield [FromBody] CreateOnChainTransactionRequest request) { if (IsInvalidWalletRequest(cryptoCode, out var network, - out var derivationScheme, out var actionResult)) + out var derivationScheme, out var actionResult)) return actionResult; if (network.ReadonlyWallet) { @@ -340,7 +350,8 @@ namespace BTCPayServer.Controllers.Greenfield //This API is only meant for hot wallet usage for now. We can expand later when we allow PSBT manipulation. if (!(await CanUseHotWallet()).HotWallet) { - return this.CreateAPIError(503, "not-available", $"You need to allow non-admins to use hotwallets for their stores (in /server/policies)"); + return this.CreateAPIError(503, "not-available", + $"You need to allow non-admins to use hotwallets for their stores (in /server/policies)"); } if (request.Destinations == null || !request.Destinations.Any()) @@ -401,6 +412,7 @@ namespace BTCPayServer.Controllers.Greenfield { amount = null; } + var address = string.Empty; try { @@ -433,9 +445,12 @@ namespace BTCPayServer.Controllers.Greenfield if (amount is null || amount <= 0) { request.AddModelError(transactionRequest => transactionRequest.Destinations[index], - "Amount must be specified or destination must be a BIP21 payment link, and greater than 0", this); + "Amount must be specified or destination must be a BIP21 payment link, and greater than 0", + this); } - if (request.ProceedWithPayjoin && bip21?.UnknownParameters?.ContainsKey(PayjoinClient.BIP21EndpointKey) is true) + + if (request.ProceedWithPayjoin && + bip21?.UnknownParameters?.ContainsKey(PayjoinClient.BIP21EndpointKey) is true) { payjoinOutputIndex = index; } @@ -565,14 +580,17 @@ namespace BTCPayServer.Controllers.Greenfield await _delayedTransactionBroadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), transaction, network); var payjoinPSBT = await _payjoinClient.RequestPayjoin( - new BitcoinUrlBuilder(signingContext.PayJoinBIP21, network.NBitcoinNetwork), new PayjoinWallet(derivationScheme), + new BitcoinUrlBuilder(signingContext.PayJoinBIP21, network.NBitcoinNetwork), + new PayjoinWallet(derivationScheme), psbt.PSBT, CancellationToken.None); - psbt.PSBT.Settings.SigningOptions = new SigningOptions() { EnforceLowR = !(signingContext?.EnforceLowR is false) }; + psbt.PSBT.Settings.SigningOptions = + new SigningOptions() {EnforceLowR = !(signingContext?.EnforceLowR is false)}; payjoinPSBT = psbt.PSBT.SignAll(derivationScheme.AccountDerivation, accountKey, rootedKeyPath); payjoinPSBT.Finalize(); var payjoinTransaction = payjoinPSBT.ExtractTransaction(); var hash = payjoinTransaction.GetHash(); - await this._walletRepository.AddWalletTransactionAttachment(new WalletId(Store.Id, cryptoCode), hash, Attachment.Payjoin()); + await this._walletRepository.AddWalletTransactionAttachment(new WalletId(Store.Id, cryptoCode), + hash, Attachment.Payjoin()); broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction); if (broadcastResult.Success) { @@ -601,19 +619,157 @@ 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) + { + var walletId = new WalletId(storeId, cryptoCode); + return Ok((await _walletRepository.GetWalletObjects(walletId, query)).Select(ToModel).ToArray()); + } + + [HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task RemoveOnChainWalletObjects(string storeId, string cryptoCode, + [FromBody] OnChainWalletObjectQuery query) + { + var walletId = new WalletId(storeId, cryptoCode); + await _walletRepository.RemoveWalletObjects(walletId, query); + return Ok(); + } + + [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) + { + var walletId = new WalletId(storeId, cryptoCode); + foreach (OnChainWalletObjectData onChainWalletObjectData in request) + { + await _walletRepository.SetWalletObject( + new WalletObjectId(walletId, onChainWalletObjectData.Type, onChainWalletObjectData.Id), + onChainWalletObjectData.Data); + } + + foreach (OnChainWalletObjectData onChainWalletObjectData in request) + { + 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 Ok(); + } + + [HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/object-links")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode, + [FromBody] AddOnChainWalletObjectLinkRequest[] request) + { + var walletId = new WalletId(storeId, cryptoCode); + foreach (AddOnChainWalletObjectLinkRequest addOnChainWalletObjectLinkRequest in request) + { + await _walletRepository.SetWalletObjectLink( + new WalletObjectId(walletId, addOnChainWalletObjectLinkRequest.Parent.Type, + addOnChainWalletObjectLinkRequest.Parent.Id), + new WalletObjectId(walletId, addOnChainWalletObjectLinkRequest.Child.Type, + addOnChainWalletObjectLinkRequest.Child.Id), addOnChainWalletObjectLinkRequest.Data); + } + + return Ok(); + } + + [HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/object-links")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode, + [FromBody] RemoveOnChainWalletObjectLinkRequest[] request) + { + 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(); + } + + private OnChainWalletObjectData ToModel(WalletObjectData data) + { + return new OnChainWalletObjectData() + { + 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() + }; + } + + private OnChainWalletObjectData.OnChainWalletObjectLink ToModel(WalletObjectLinkData data, bool isParent) + { + 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, + }; + } + + private async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet() { return await _authorizationService.CanUseHotWallet(PoliciesSettings, User); } private bool IsInvalidWalletRequest(string cryptoCode, [MaybeNullWhen(true)] out BTCPayNetwork network, - [MaybeNullWhen(true)] out DerivationSchemeSettings derivationScheme, [MaybeNullWhen(false)] out IActionResult actionResult) + [MaybeNullWhen(true)] out DerivationSchemeSettings derivationScheme, + [MaybeNullWhen(false)] out IActionResult actionResult) { derivationScheme = null; network = _btcPayNetworkProvider.GetNetwork(cryptoCode); if (network is null) { - throw new JsonHttpException(this.CreateAPIError(404, "unknown-cryptocode", "This crypto code isn't set up in this BTCPay Server instance")); + throw new JsonHttpException(this.CreateAPIError(404, "unknown-cryptocode", + "This crypto code isn't set up in this BTCPay Server instance")); } diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index ce674f12d..a5f2d0677 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -215,7 +215,6 @@ namespace BTCPayServer.Controllers.Greenfield throw new NotSupportedException("This method is not supported by the LocalBTCPayServerClient."); } - public override async Task MarketTradeCustodianAccountAsset(string storeId, string accountId, TradeRequestData request, CancellationToken cancellationToken = default) { @@ -223,6 +222,38 @@ namespace BTCPayServer.Controllers.Greenfield await GetController().MarketTradeCustodianAccountAsset(storeId, accountId, request, cancellationToken)); } + public override async Task GetOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query, + CancellationToken token = default) + { + return GetFromActionResult( + await GetController().GetOnChainWalletObjects(storeId, cryptoCode, query)); + + } + + public override async Task RemoveOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query, + CancellationToken token = default) + { + HandleActionResult(await GetController().RemoveOnChainWalletObjects(storeId, cryptoCode, query)); + } + + public override async Task AddOrUpdateOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectData[] request, + CancellationToken token = default) + { + HandleActionResult(await GetController().AddOrUpdateOnChainWalletObjects(storeId, cryptoCode, request)); + } + + public override async Task AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode, AddOnChainWalletObjectLinkRequest[] request, + CancellationToken token = default) + { + HandleActionResult(await GetController().AddOrUpdateOnChainWalletLinks(storeId, cryptoCode, request)); + } + + public override async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode, RemoveOnChainWalletObjectLinkRequest[] request, + CancellationToken token = default) + { + HandleActionResult(await GetController().RemoveOnChainWalletLinks(storeId, cryptoCode, request)); + } + public override async Task CreateWebhook(string storeId, CreateStoreWebhookRequest create, CancellationToken token = default) { @@ -1167,7 +1198,7 @@ namespace BTCPayServer.Controllers.Greenfield { return GetFromActionResult(await GetController().UpdateStoreRateConfiguration(request)); } - + public override async Task MarkPayoutPaid(string storeId, string payoutId, CancellationToken cancellationToken = default) { HandleActionResult(await GetController().MarkPayoutPaid(storeId, payoutId, cancellationToken)); diff --git a/BTCPayServer/QueryableExtensions.cs b/BTCPayServer/QueryableExtensions.cs new file mode 100644 index 000000000..3ceb7f7d2 --- /dev/null +++ b/BTCPayServer/QueryableExtensions.cs @@ -0,0 +1,68 @@ +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 0dd064cf9..7f5c3ef0a 100644 --- a/BTCPayServer/Services/WalletRepository.cs +++ b/BTCPayServer/Services/WalletRepository.cs @@ -1,15 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Client.Models; using BTCPayServer.Data; -using BTCPayServer.HostedServices; -using BTCPayServer.Services.Labels; using Microsoft.EntityFrameworkCore; using NBitcoin; -using NBitcoin.Crypto; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace BTCPayServer.Services @@ -107,26 +105,76 @@ namespace BTCPayServer.Services public async Task<(string Label, string Color)[]> GetWalletLabels(WalletId walletId) { - using var ctx = _ContextFactory.CreateContext(); - return (await ctx.WalletObjects - .AsNoTracking() - .Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Label) - .Select(o => new { o.Id, o.Data }) - .ToArrayAsync()) + 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(); } - public async Task EnsureWalletObjectLink(WalletObjectId parent, WalletObjectId child) + public async Task GetWalletObjects(WalletId walletId, OnChainWalletObjectQuery query) { - using var ctx = _ContextFactory.CreateContext(); + 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(); + } + + 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) + { + 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 + ParentId = parent.Id, + Data = data?.ToString(Formatting.None) }; ctx.WalletObjectLinks.Add(l); try @@ -138,6 +186,30 @@ namespace BTCPayServer.Services } } + public async Task SetWalletObjectLink(WalletObjectId parent, WalletObjectId child, JObject? data = null) + { + 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, + Data = data?.ToString(Formatting.None) + }; + var e = ctx.WalletObjectLinks.Add(l); + try + { + await ctx.SaveChangesAsync(); + } + catch (DbUpdateException) // already exists + { + e.State = EntityState.Modified; + await ctx.SaveChangesAsync(); + } + } + public static int MaxCommentSize = 200; public async Task SetWalletObjectComment(WalletObjectId id, string comment) { @@ -194,6 +266,21 @@ namespace BTCPayServer.Services await EnsureWalletObjectLink(labelObjId, id); } } + + public async Task AddWalletObjects(WalletObjectId id, params string[] labels) + { + ArgumentNullException.ThrowIfNull(id); + await EnsureWalletObject(id); + foreach (var l in labels.Select(l => l.Trim().Truncate(MaxLabelSize))) + { + var labelObjId = new WalletObjectId(id.WalletId, WalletObjectData.Types.Label, l); + await EnsureWalletObject(labelObjId, new JObject() + { + ["color"] = ColorPalette.Default.DeterministicColor(l) + }); + await EnsureWalletObjectLink(labelObjId, id); + } + } public Task AddWalletTransactionAttachment(WalletId walletId, uint256 txId, Attachment attachment) { @@ -221,35 +308,40 @@ namespace BTCPayServer.Services } } } + + public async Task RemoveWalletObjectLink(WalletObjectId parent, WalletObjectId child) + { + 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 + }); + try + { + await ctx.SaveChangesAsync(); + } + catch (DbUpdateException) // Already deleted, do nothing + { + } + } public async Task RemoveWalletObjectLabels(WalletObjectId id, params string[] labels) { ArgumentNullException.ThrowIfNull(id); foreach (var l in labels.Select(l => l.Trim())) { var labelObjId = new WalletObjectId(id.WalletId, WalletObjectData.Types.Label, l); - using var ctx = _ContextFactory.CreateContext(); - ctx.WalletObjectLinks.Remove(new WalletObjectLinkData() - { - WalletId = id.WalletId.ToString(), - ChildId = id.Id, - ChildType = id.Type, - ParentId = labelObjId.Id, - ParentType = labelObjId.Type - }); - try - { - await ctx.SaveChangesAsync(); - } - catch (DbUpdateException) // Already deleted, do nothing - { - } + await RemoveWalletObjectLink(labelObjId, id); } } public async Task SetWalletObject(WalletObjectId id, JObject? data) { ArgumentNullException.ThrowIfNull(id); - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); var o = NewWalletObjectData(id, data); ctx.WalletObjects.Add(o); try @@ -266,7 +358,7 @@ namespace BTCPayServer.Services public async Task EnsureWalletObject(WalletObjectId id, JObject? data = null) { ArgumentNullException.ThrowIfNull(id); - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); ctx.WalletObjects.Add(NewWalletObjectData(id, data)); try { 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 new file mode 100644 index 000000000..8496cf8a5 --- /dev/null +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-wallet.on-chain.objects.json @@ -0,0 +1,449 @@ +{ + "paths": { + "/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects": { + "get": { + "tags": [ + "Store Wallet (On Chain) Objects" + ], + "summary": "Get 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": "View wallet objects", + "operationId": "StoreOnChainWallets_GetOnChainWalletObjects", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnChainWalletObjectQuery" + } + } + } + }, + "responses": { + "200": { + "description": "specified wallet", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$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) 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" + ], + "summary": "Add/Update 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": "Add/Update wallet objects", + "operationId": "StoreOnChainWallets_AddOrUpdateOnChainWalletObjects", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OnChainWalletObjectData" + } + } + } + } + }, + "responses": { + "200": { + "description": "action completed" + }, + "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/object-links": { + "delete": { + "tags": [ + "Store Wallet (On Chain) Objects" + ], + "summary": "Remove store on-chain wallet object links", + "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 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", + "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": "Add/Update wallet object links", + "operationId": "StoreOnChainWallets_AddOrUpdateOnChainWalletLinks", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddOnChainWalletObjectLinkRequest" + } + } + } + } + }, + "responses": { + "200": { + "description": "action completed" + }, + "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": [] + } + ] + } + } + }, + "components": { + "schemas": { + "OnChainWalletObjectId": { + "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)" + } + } + }, + "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": [ + { + "$ref": "#/components/schemas/OnChainWalletObjectId" + } + ], + "properties": { + "linkData": { + "type": "object", + "additionalProperties": true + } + } + }, + "OnChainWalletObjectData": { + "type": "object", + "additionalProperties": false, + "allOf": [ + { + "$ref": "#/components/schemas/OnChainWalletObjectId" + } + ], + "properties": { + "data": { + "type": "object", + "additionalProperties": true + }, + "parents": { + "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" + } + } + } + } + }, + "tags": [ + { + "name": "Store Wallet (On Chain) Objects", + "description": "Store Wallet (On Chain) operations" + } + ] +}