Greenfield: Store Rates Config (#3931)

* Greenfield: Store Rates Config

* FIX SWAGGER

* rebase fix

* Apply suggestions from code review

Co-authored-by: d11n <mail@dennisreimann.de>

* Update BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-rates-config.json

Co-authored-by: d11n <mail@dennisreimann.de>

* Fix: Spread isn't converted from/to percentage, rename some fields, and move some routes

* Fix error handling

Co-authored-by: d11n <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2022-10-12 15:19:33 +02:00 committed by GitHub
parent a2fa688cde
commit 434298cba6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 693 additions and 1 deletions

View file

@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<StoreRateConfiguration> GetStoreRateConfiguration(string storeId,
CancellationToken token = default)
{
using var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/rates/configuration", method: HttpMethod.Get),
token);
return await HandleResponse<StoreRateConfiguration>(response);
}
public virtual async Task<List<RateSource>> GetRateSources(
CancellationToken token = default)
{
using var response = await _httpClient.SendAsync(
CreateHttpRequest($"misc/rate-sources", method: HttpMethod.Get),
token);
return await HandleResponse<List<RateSource>>(response);
}
public virtual async Task<StoreRateConfiguration> UpdateStoreRateConfiguration(string storeId,
StoreRateConfiguration request,
CancellationToken token = default)
{
using var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/rates/configuration", bodyPayload: request,
method: HttpMethod.Put),
token);
return await HandleResponse<StoreRateConfiguration>(response);
}
public virtual async Task<List<StoreRatePreviewResult>> PreviewUpdateStoreRateConfiguration(string storeId,
StoreRateConfiguration request,
string[] currencyPair,
CancellationToken token = default)
{
using var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/rates/configuration/preview", bodyPayload: request,
queryPayload: new Dictionary<string, object>() {{"currencyPair", currencyPair}},
method: HttpMethod.Post),
token);
return await HandleResponse<List<StoreRatePreviewResult>>(response);
}
}
}

View file

@ -0,0 +1,7 @@
namespace BTCPayServer.Client.Models;
public class RateSource
{
public string Id { get; set; }
public string Name { get; set; }
}

View file

@ -0,0 +1,10 @@
namespace BTCPayServer.Client.Models
{
public class StoreRateConfiguration
{
public decimal Spread { get; set; }
public bool IsCustomScript { get; set; }
public string EffectiveScript { get; set; }
public string PreferredSource { get; set; }
}
}

View file

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace BTCPayServer.Client.Models;
public class StoreRatePreviewResult
{
public string CurrencyPair { get; set; }
public decimal? Rate { get; set; }
public List<string> Errors { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace BTCPayServer.Client.Models;
public class StoreRateResult
{
public string CurrencyPair { get; set; }
public decimal Rate { get; set; }
}

View file

@ -2701,8 +2701,67 @@ namespace BTCPayServer.Tests
Assert.NotNull(custodians);
Assert.NotEmpty(custodians);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task StoreRateConfigTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var unauthClient = new BTCPayServerClient(tester.PayTester.ServerUri);
await AssertHttpError(401, async () => await unauthClient.GetRateSources());
var user = tester.NewAccount();
await user.GrantAccessAsync();
var clientBasic = await user.CreateClient();
Assert.NotEmpty(await clientBasic.GetRateSources());
var config = await clientBasic.GetStoreRateConfiguration(user.StoreId);
Assert.NotNull(config);
Assert.False(config.IsCustomScript);
Assert.Equal("X_X = coingecko(X_X);", config.EffectiveScript);
Assert.Equal("coingecko", config.PreferredSource);
Assert.Equal(0.9m,
Assert.Single(await clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId,
new StoreRateConfiguration() {IsCustomScript = true, EffectiveScript = "BTC_XYZ = 1;", Spread = 10m,},
new[] {"BTC_XYZ"})).Rate);
Assert.True((await clientBasic.UpdateStoreRateConfiguration(user.StoreId,
new StoreRateConfiguration() { IsCustomScript = true, EffectiveScript = "BTC_XYZ = 1", Spread = 10m,}))
.IsCustomScript);
config = await clientBasic.GetStoreRateConfiguration(user.StoreId);
Assert.NotNull(config);
Assert.NotNull(config.EffectiveScript);
Assert.Equal("BTC_XYZ = 1;", config.EffectiveScript);
Assert.Equal(10m, config.Spread);
Assert.Null(config.PreferredSource);
Assert.NotNull((await clientBasic.GetStoreRateConfiguration(user.StoreId)).EffectiveScript);
Assert.NotNull((await clientBasic.UpdateStoreRateConfiguration(user.StoreId,
new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingecko"}))
.PreferredSource);
config = await clientBasic.GetStoreRateConfiguration(user.StoreId);
Assert.Equal("X_X = coingecko(X_X);", config.EffectiveScript);
await AssertValidationError(new[] { "EffectiveScript", "PreferredSource" }, () =>
clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, EffectiveScript = "BTC_XYZ = 1;" }));
await AssertValidationError(new[] { "EffectiveScript" }, () =>
clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = true, EffectiveScript = "BTC_XYZ rg8w*# 1;" }));
await AssertValidationError(new[] { "PreferredSource" }, () =>
clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = true, EffectiveScript = "", PreferredSource = "coingecko" }));
await AssertValidationError(new[] { "PreferredSource", "Spread" }, () =>
clientBasic.UpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingeckoOOO", Spread = -1m }));
await AssertValidationError(new[] { "currencyPair" }, () =>
clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingecko" }, new[] { "BTC_USD_USD_BTC" }));
await AssertValidationError(new[] { "PreferredSource", "currencyPair" }, () =>
clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfiguration() { IsCustomScript = false, PreferredSource = "coingeckoOOO" }, new[] { "BTC_USD_USD_BTC" }));
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CustodianAccountControllerTests()

View file

@ -0,0 +1,204 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RateSource = BTCPayServer.Client.Models.RateSource;
namespace BTCPayServer.Controllers.GreenField
{
[ApiController]
[Route("api/v1/stores/{storeId}/rates/configuration")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public class GreenfieldStoreRateConfigurationController : ControllerBase
{
private readonly RateFetcher _rateProviderFactory;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly StoreRepository _storeRepository;
public GreenfieldStoreRateConfigurationController(
RateFetcher rateProviderFactory,
BTCPayNetworkProvider btcPayNetworkProvider,
StoreRepository storeRepository)
{
_rateProviderFactory = rateProviderFactory;
_btcPayNetworkProvider = btcPayNetworkProvider;
_storeRepository = storeRepository;
}
[HttpGet("")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public IActionResult GetStoreRateConfiguration()
{
var data = HttpContext.GetStoreData();
var blob = data.GetStoreBlob();
return Ok(new StoreRateConfiguration()
{
EffectiveScript = blob.GetRateRules(_btcPayNetworkProvider, out var preferredExchange).ToString(),
Spread = blob.Spread * 100.0m,
IsCustomScript = blob.RateScripting,
PreferredSource = preferredExchange ? blob.PreferredExchange : null
});
}
[HttpGet("/misc/rate-sources")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie + "," + AuthenticationSchemes.Greenfield)]
public ActionResult<List<RateSource>> GetRateSources()
{
return Ok(_rateProviderFactory.RateProviderFactory.GetSupportedExchanges().Select(provider =>
new RateSource() {Id = provider.Id, Name = provider.DisplayName}));
}
[HttpPut("")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> UpdateStoreRateConfiguration(
StoreRateConfiguration configuration)
{
var storeData = HttpContext.GetStoreData();
var blob = storeData.GetStoreBlob();
ValidateAndSanitizeConfiguration(configuration, blob);
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
PopulateBlob(configuration, blob);
storeData.SetStoreBlob(blob);
await _storeRepository.UpdateStore(storeData);
return GetStoreRateConfiguration();
}
[HttpPost("preview")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> PreviewUpdateStoreRateConfiguration(
StoreRateConfiguration configuration, [FromQuery] string[] currencyPair)
{
var data = HttpContext.GetStoreData();
var blob = data.GetStoreBlob();
var parsedCurrencyPairs = new HashSet<CurrencyPair>();
foreach (var pair in currencyPair ?? Array.Empty<string>())
{
if (!CurrencyPair.TryParse(pair, out var currencyPairParsed))
{
ModelState.AddModelError(nameof(currencyPair),
$"Invalid currency pair '{pair}' (it should be formatted like BTC_USD,BTC_CAD)");
break;
}
parsedCurrencyPairs.Add(currencyPairParsed);
}
ValidateAndSanitizeConfiguration(configuration, blob);
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
PopulateBlob(configuration, blob);
var rules = blob.GetRateRules(_btcPayNetworkProvider);
var rateTasks = _rateProviderFactory.FetchRates(parsedCurrencyPairs, rules, CancellationToken.None);
await Task.WhenAll(rateTasks.Values);
var result = new List<StoreRatePreviewResult>();
foreach (var rateTask in rateTasks)
{
var rateTaskResult = rateTask.Value.Result;
result.Add(new StoreRatePreviewResult()
{
CurrencyPair = rateTask.Key.ToString(),
Errors = rateTaskResult.Errors.Select(errors => errors.ToString()).ToList(),
Rate = rateTaskResult.Errors.Any() ? (decimal?)null : rateTaskResult.BidAsk.Bid
});
}
return Ok(result);
}
private void ValidateAndSanitizeConfiguration(StoreRateConfiguration? configuration, StoreBlob storeBlob)
{
if (configuration is null)
{
ModelState.AddModelError("", "Body required");
return;
}
if (configuration.Spread < 0 || configuration.Spread > 100)
{
ModelState.AddModelError(nameof(configuration.Spread),
$"Spread value must be in %, between 0 and 100");
}
if (configuration.IsCustomScript)
{
if (string.IsNullOrEmpty(configuration.EffectiveScript))
{
configuration.EffectiveScript = storeBlob.GetDefaultRateRules(_btcPayNetworkProvider).ToString();
}
if (!RateRules.TryParse(configuration.EffectiveScript, out var r))
{
ModelState.AddModelError(nameof(configuration.EffectiveScript),
$"Script syntax is invalid");
}
else
{
configuration.EffectiveScript = r.ToString();
}
if (!string.IsNullOrEmpty(configuration.PreferredSource))
{
ModelState.AddModelError(nameof(configuration.PreferredSource),
$"You can't set the preferredSource if you are using custom scripts");
}
}
else
{
if (!string.IsNullOrEmpty(configuration.EffectiveScript))
{
ModelState.AddModelError(nameof(configuration.EffectiveScript),
$"You can't set the effectiveScript if you aren't using custom scripts");
}
if (string.IsNullOrEmpty(configuration.PreferredSource))
{
ModelState.AddModelError(nameof(configuration.PreferredSource),
$"The preferredSource is required if you aren't using custom scripts");
}
configuration.PreferredSource = _rateProviderFactory
.RateProviderFactory
.GetSupportedExchanges()
.FirstOrDefault(s =>
s.Id.Equals(configuration.PreferredSource,
StringComparison.InvariantCultureIgnoreCase))?.Id;
if (string.IsNullOrEmpty(configuration.PreferredSource))
{
ModelState.AddModelError(nameof(configuration.PreferredSource),
$"Unsupported source, please check /misc/rate-sources to see valid values ({configuration.PreferredSource})");
}
}
}
private static void PopulateBlob(StoreRateConfiguration configuration, StoreBlob storeBlob)
{
storeBlob.PreferredExchange = configuration.PreferredSource;
storeBlob.Spread = configuration.Spread / 100.0m;
storeBlob.RateScripting = configuration.IsCustomScript;
storeBlob.RateScript = configuration.EffectiveScript;
}
}
}

View file

@ -1121,5 +1121,31 @@ namespace BTCPayServer.Controllers.Greenfield
{
HandleActionResult(await GetController<GreenfieldAppsController>().DeleteApp(appId));
}
public override Task<List<RateSource>> GetRateSources(CancellationToken token = default)
{
return Task.FromResult(GetFromActionResult(GetController<GreenfieldStoreRateConfigurationController>().GetRateSources()));
}
public override Task<StoreRateConfiguration> GetStoreRateConfiguration(string storeId, CancellationToken token = default)
{
return Task.FromResult(GetFromActionResult<StoreRateConfiguration>(GetController<GreenfieldStoreRateConfigurationController>().GetStoreRateConfiguration()));
}
public override async Task<List<StoreRatePreviewResult>> PreviewUpdateStoreRateConfiguration(string storeId,
StoreRateConfiguration request,
string[] currencyPair,
CancellationToken token = default)
{
return GetFromActionResult<List<StoreRatePreviewResult>>(
await GetController<GreenfieldStoreRateConfigurationController>().PreviewUpdateStoreRateConfiguration(request,
currencyPair));
}
public override async Task<StoreRateConfiguration> UpdateStoreRateConfiguration(string storeId, StoreRateConfiguration request, CancellationToken token = default)
{
return GetFromActionResult<StoreRateConfiguration>(await GetController<GreenfieldStoreRateConfigurationController>().UpdateStoreRateConfiguration(request));
}
}
}

View file

@ -131,15 +131,21 @@ namespace BTCPayServer.Data
public double PaymentTolerance { get; set; }
public BTCPayServer.Rating.RateRules GetRateRules(BTCPayNetworkProvider networkProvider)
{
return GetRateRules(networkProvider, out _);
}
public BTCPayServer.Rating.RateRules GetRateRules(BTCPayNetworkProvider networkProvider, out bool preferredSource)
{
if (!RateScripting ||
string.IsNullOrEmpty(RateScript) ||
!BTCPayServer.Rating.RateRules.TryParse(RateScript, out var rules))
{
preferredSource = true;
return GetDefaultRateRules(networkProvider);
}
else
{
preferredSource = false;
rules.Spread = Spread;
return rules;
}

View file

@ -1,3 +1,5 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
@ -9,6 +11,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace BTCPayServer.Security.Greenfield
{
@ -31,7 +34,25 @@ namespace BTCPayServer.Security.Greenfield
_identityOptions = identityOptions;
_userManager = userManager;
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
// This one deserve some explanation...
// Some routes have this authorization.
// [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie + "," + AuthenticationSchemes.Greenfield)]
// This is meant for API routes that we wish to access by greenfield but also via the browser for documentation purpose (say /misc/rate-sources)
// Now, if we aren't logged nor authenticated via greenfield, the AuthenticationHandlers get challenged.
// The last handler to be challenged is the CookieAuthenticationHandler, which instruct to handle the challenge as a redirection to
// the login page.
// But this isn't what we want when we call the API programmatically, instead we want an error 401 with a json error message.
// This hack modify a request's header to trick the CookieAuthenticationHandler to not do a redirection.
if (!Request.Headers.Accept.Any(s => s.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)))
Request.Headers.XRequestedWith = new Microsoft.Extensions.Primitives.StringValues("XMLHttpRequest");
return base.HandleChallengeAsync(properties);
}
private bool IsJson(string contentType)
{
return contentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) is true;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Context.Request.HttpContext.GetAPIKey(out var apiKey) || string.IsNullOrEmpty(apiKey))

View file

@ -1,5 +1,42 @@
{
"paths": {
"/misc/rate-sources": {
"get": {
"tags": [
"Miscelleneous"
],
"summary": "Get available rate sources",
"description": "View available rate providers that you can use in stores",
"operationId": "GetRateSources",
"responses": {
"200": {
"description": "rate providers array",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The id of the rate provider"
},
"name": {
"type": "string",
"description": "The name of the rate provider"
}
}
}
}
}
}
}
},
"security": []
}
},
"/misc/permissions": {
"get": {
"tags": [

View file

@ -0,0 +1,252 @@
{
"paths": {
"/api/v1/stores/{storeId}/rates/configuration": {
"get": {
"tags": [
"Stores (Rates Config)"
],
"summary": "Get store rate settings",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
}
],
"description": "View rate settings of the specified store",
"operationId": "Stores_GetStoreRateConfiguration",
"responses": {
"200": {
"description": "specified store rate settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StoreRateConfiguration"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
},
"put": {
"tags": [
"Stores (Rates Config)"
],
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
}
],
"summary": "Update store rate settings",
"description": "Update a store's rate settings",
"operationId": "Stores_UpdateStoreRateConfiguration",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StoreRateConfiguration"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "The settings were updated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StoreRateConfiguration"
}
}
}
},
"400": {
"description": "A list of errors that occurred when updating the settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to modify the store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/rates/configuration/preview": {
"post": {
"tags": [
"Stores (Rates Config)"
],
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
},
{
"name": "currencyPair",
"description": "The currency pairs to preview",
"in": "query",
"style": "form",
"explode": true,
"schema": {
"type": "array",
"nullable": true,
"items": {
"type": "string"
}
},
"x-position": 1
}
],
"summary": "Preview rate configuration results",
"description": "Preview rate configuration results before you set it on the store",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StoreRateConfiguration"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "The settings were executed and a preview was returned",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StoreRatePreviewResult"
}
}
}
}
},
"400": {
"description": "A list of errors that occurred when previewing the settings",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to modify the store"
}
},
"security": [
{
"API Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"StoreRateConfiguration": {
"type": "object",
"additionalProperties": false,
"properties": {
"spread": {
"type": "string",
"description": "A spread applies to the rate fetched in `%`. Must be `>= 0` or `<= 100`"
},
"preferredSource": {
"type": "string",
"description": "If `isCustomerScript` is `false` affect use this source in the default's `effectiveScript`, if `isCustomerScript` is `false`, this setting is set to `null`. (See /misc/rate-sources for the available sources)"
},
"isCustomScript": {
"type": "boolean",
"description": "Whether to use `preferredSource` with default script or a custom `effectiveScript`."
},
"effectiveScript": {
"type": "string",
"description": "When `isCustomScript` is `true`, this represent the custom script used to calculate a currency pair's exchange rate. Else, it represent the script generated by the default rules and `preferredSource`."
}
}
},
"StoreRatePreviewResult": {
"type": "object",
"additionalProperties": false,
"properties": {
"currencyPair": {
"type": "string",
"description": "Currency pair in the format of BTC_USD"
},
"errors": {
"type": "array",
"nullable": true,
"items": {
"type": "string"
},
"description": "Errors relating to this currency pair fetching based on your config"
},
"rate": {
"type": "string",
"description": "the rate fetched based on th currency pair"
}
}
}
}
},
"tags": [
{
"name": "Stores (Rates Config)"
}
]
}