Greenfield: Wallet Objects (#4274)

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2022-11-16 04:11:17 +01:00 committed by GitHub
parent 324112b73b
commit 2740dfea87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 1072 additions and 63 deletions

View file

@ -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<OnChainWalletObjectData[]> 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<OnChainWalletObjectData[]>(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);
}
}
}

View file

@ -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))]

View file

@ -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")]

View file

@ -92,7 +92,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> GetOnChainWalletReceiveAddress(string storeId, string cryptoCode, bool forceGenerate = false)
public async Task<IActionResult> 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<IActionResult> 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<TransactionHistoryLine>(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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<BTCPayNetwork>(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"));
}

View file

@ -215,7 +215,6 @@ namespace BTCPayServer.Controllers.Greenfield
throw new NotSupportedException("This method is not supported by the LocalBTCPayServerClient.");
}
public override async Task<MarketTradeResponseData> MarketTradeCustodianAccountAsset(string storeId, string accountId,
TradeRequestData request, CancellationToken cancellationToken = default)
{
@ -223,6 +222,38 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldCustodianAccountController>().MarketTradeCustodianAccountAsset(storeId, accountId, request, cancellationToken));
}
public override async Task<OnChainWalletObjectData[]> GetOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query,
CancellationToken token = default)
{
return GetFromActionResult<OnChainWalletObjectData[]>(
await GetController<GreenfieldStoreOnChainWalletsController>().GetOnChainWalletObjects(storeId, cryptoCode, query));
}
public override async Task RemoveOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectQuery query,
CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldStoreOnChainWalletsController>().RemoveOnChainWalletObjects(storeId, cryptoCode, query));
}
public override async Task AddOrUpdateOnChainWalletObjects(string storeId, string cryptoCode, OnChainWalletObjectData[] request,
CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldStoreOnChainWalletsController>().AddOrUpdateOnChainWalletObjects(storeId, cryptoCode, request));
}
public override async Task AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode, AddOnChainWalletObjectLinkRequest[] request,
CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldStoreOnChainWalletsController>().AddOrUpdateOnChainWalletLinks(storeId, cryptoCode, request));
}
public override async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode, RemoveOnChainWalletObjectLinkRequest[] request,
CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldStoreOnChainWalletsController>().RemoveOnChainWalletLinks(storeId, cryptoCode, request));
}
public override async Task<StoreWebhookData> CreateWebhook(string storeId, CreateStoreWebhookRequest create,
CancellationToken token = default)
{
@ -1167,7 +1198,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
return GetFromActionResult<StoreRateConfiguration>(await GetController<GreenfieldStoreRateConfigurationController>().UpdateStoreRateConfiguration(request));
}
public override async Task MarkPayoutPaid(string storeId, string payoutId, CancellationToken cancellationToken = default)
{
HandleActionResult(await GetController<GreenfieldPullPaymentController>().MarkPayoutPaid(storeId, payoutId, cancellationToken));

View file

@ -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<T> FilterByItems<T, TItem>(this IQueryable<T> query, IEnumerable<TItem> items,
Expression<Func<T, TItem, bool>> 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<Func<T, bool>>(predicate, filterPattern.Parameters[0]);
return query.Where(filterLambda);
}
class ExpressionReplacer : ExpressionVisitor
{
readonly IDictionary<Expression, Expression> _replaceMap;
public ExpressionReplacer(IDictionary<Expression, Expression> 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<Expression, Expression> { { toReplace, toExpr } }).Visit(expr);
}
public static Expression Replace(Expression expr, IDictionary<Expression, Expression> 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);
}
}
}

View file

@ -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<string>()!))
.ToArray();
}
public async Task EnsureWalletObjectLink(WalletObjectId parent, WalletObjectId child)
public async Task<WalletObjectData[]> 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<IQueryable<WalletObjectData>> GetWalletObjects(ApplicationDbContext applicationDbContext,
IQueryable<WalletObjectData> 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
{

View file

@ -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"
}
]
}