Compare commits

...

50 Commits

Author SHA1 Message Date
72e66aa576 update hook names 2023-03-29 11:37:30 +02:00
6388057806 Hooks for Zaps 2023-03-28 14:35:20 +02:00
1f197f6688 Merge pull request from dennisreimann/nfc 2023-03-28 09:19:58 +02:00
1055e61bb4 NFC improvements
Two changes which fix :

- Once permissions are granted we start scanning immediately, no need to ask for permissions or have the user click the button again
- We don't abort the scan, which gets rid of the cases in which the OS took over after the scan, because the user left the card on the device

Also adds feedback for the NFC states scanning and submitting.
2023-03-27 18:28:53 +02:00
d3f5576570 Remove store integrations list page ()
Co-authored-by: d11n <mail@dennisreimann.de>
2023-03-27 16:40:50 +02:00
45141d1391 Checkout v2: Payment processing state () 2023-03-27 12:12:11 +02:00
de9ac9fd43 Receipt: Add payment proof ()
* Receipt: Add payment proof

Closes .

* shice

* Add truncate-center component

* Improve view

* Hide button and link when printed

* Describe component

* Remove transaction ID from UI

* Remove modification to interface

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-03-27 14:07:12 +09:00
c53d5272d6 Wallet Transactions Export: Add BIP-329 support ()
* Wallet Transactions Export: Add BIP-329 support

* Adjust wording

* Export one line per label

* Join labels, fix type

* Rewrite the ProcessBip329 function to be more performant

* Add nullable on all TransactionsExport

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-03-27 13:59:33 +09:00
18c78192ec Reconstruct issue template ()
* reconstruct issue template

* provide a direct link for filing a tech question
2023-03-27 13:59:07 +09:00
632d67eef4 Fix casing in template example for forms 2023-03-27 12:54:12 +09:00
c23aa48688 Optimize invoice print view ()
Closes .
2023-03-26 20:44:05 +09:00
95f3e429b4 Wallet transactions: Add label manager ()
* Wallet transactions: Add label manager

* Update BTCPayServer/Views/UIWallets/WalletTransactions.cshtml

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>

* Add rich label info

* Fixes

* support labels in wallet send

* add labels to tx info page

* Remove noscript parts

* Allow click on transaction label info

* update psbt info labelstyling

* revert red pixel fix as it broke all

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
2023-03-26 20:42:38 +09:00
8635fcfe84 UI: Redesign Recovery Seed view ()
* Improve recovery seed backup page

* Fix errors from Selenium tests (Sequence contains no elements)

* Revert previous commit

* Improve recovery seed backup page

* Recovery phrase UI update

* recovery seed UI format

* Improve word order

* One column version

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-03-24 16:09:53 +01:00
d861537d9a Merge pull request from dennisreimann/gf-ln-array-fix
Greenfield: Fix Lightning transaction list return types
2023-03-23 18:59:36 +01:00
631ee99f60 Greenfield: Fix Lightning transaction list return types
The LocalBTCPayServerClient deserializes the results as arrays (`LightningPaymentData[]` and `LightningInvoiceData[]`) — if they are `IEnumerable` the `GetFromActionResult` does not return the data but null.
2023-03-23 17:42:10 +01:00
ffa1441ccd Delete code detecting whether the running version of nbx fixed a bug
The reason to delete this is that any version of NBX with this bug
wouldn't be able to run nowadays because of another bug which would
prevent NBXplorer from synching (Array size too big)
2023-03-23 13:45:40 +09:00
2f3e947027 Merge pull request from Kukks/lnurl-disable-if-no-node 2023-03-22 09:02:52 +01:00
a62aecfdfe Merge pull request from dennisreimann/fix-4794 2023-03-22 09:02:35 +01:00
5f829c68f2 Merge pull request from dennisreimann/fix-4790 2023-03-22 09:02:10 +01:00
0290d74aeb POS: Fix escaped HTML entities in item title
Properly escapes and the sanitized values. Fixes .
2023-03-21 15:31:54 +01:00
f6bc16007d Label tooltips: Use plain text instead of HTML
Fixes .
2023-03-21 15:21:24 +01:00
ad5752f09b Reuse LightningTimeout constant 2023-03-21 14:22:10 +01:00
55565f1718 Do not provide lnurl method if ln node is dead
fixes 
2023-03-21 13:48:25 +01:00
5f96d17b8c Update lang 2023-03-20 19:30:56 +09:00
fd22406e0a Fix PullTransifexTranslationsCore 2023-03-20 19:21:35 +09:00
64fe542c1e Update lang 2023-03-20 19:20:46 +09:00
fae1dc8dbb Adapt cookie auth to work with same API permission system ()
* Adapt cookie auth to work with same API permission system

* Handle unscoped store permission case

* Do not consider Unscoped as a valid policy

* Add tests

* Refactor permissions scopes

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-03-20 10:46:46 +09:00
6f2b673021 Custodian withdrawal support + Some refactoring and cleanup ()
* Renamed "WithdrawAsync" to "WithdrawToStoreWalletAsync"

* WIP

* WIP withdrawal + Refactored Form saving to JObject

* WIP

* Form to fix bad values during withdrawing appears correctly

* WIP

* Lots of cleanup and refactoring + Password field and toggle password view

* Cleanup + Finishing touches on withdrawals

* Added "Destination" dummy text as this is always the destination.

* Fixed broken test

* Added support for withdrawing using qty as a percentage if it ends with "%". Needs more testing.

* Fixed broken build

* Fixed broken build (2)

* Update BTCPayServer/wwwroot/swagger/v1/swagger.template.custodians.json

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

* Update BTCPayServer/wwwroot/swagger/v1/swagger.template.custodians.json

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

* Improved unit tests

* Fixed swagger bug

* Test improvements

Make string conversion of quantity explicitely.

* Fix build warnings

* Swagger: Add missing operationId

* Made change Dennis requested

* Removed unused file

* Removed incorrect comment

* Extra contructor

* Renamed client methods

* Cleanup config before saving

* Fixed broken controller

* Refactor custodian

* Fix build

* Make decimal fields strings to match the rest of Greenfield

* Improve parsing of % quantities

---------

Co-authored-by: d11n <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-03-20 10:45:32 +09:00
b26679ca14 Prevent people from starting with --sqlitefile or --mysql () 2023-03-20 10:40:48 +09:00
04ba1430ca Refactor plugin apps ()
* Refactor plugins

* Add missing names to view models

* Cleanups

* Replace SalesAppBaseType by two interfaces

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-03-20 10:39:26 +09:00
53f3758abc Replace text in copy buttons with icon ()
Closes .
2023-03-19 21:43:38 +01:00
c6742f5533 Language selector: Ensure correct font-size ()
* Language selector: Ensure correct font-size

Fixes the cut-off text on iOS, because somehow iOS uses a larger font-size by default.

* Fix select background color

Webkit-based browsers displayed transparent in a weird way.
2023-03-19 08:44:23 +01:00
cb44591a47 Derivation scheme parsing incorporates fingerprint and key path () 2023-03-17 14:35:30 +01:00
e02abb509f Allow plugins to do something before and after automatic payouts () 2023-03-17 13:50:37 +01:00
eff6be9643 Remove mention of LNUrl-Withdraw when paying by NFC () 2023-03-17 12:24:27 +01:00
348dbd7107 Support Form Select option ()
* Support Form Select option

* Add country select
2023-03-17 14:37:37 +09:00
f74ea14d8b Plugins can now build apps ()
* Plugins can now build apps

* fix tests

* fixup

* pluginize existing apps

* Test fixes part 1

* Test fixes part 2

* Fix Crowdfund namespace

* Syntax

* More namespace fixes

* Markup

* Test fix

* upstream fixes

* Add plugin icon

* Fix nullable build warnings

* allow pre popualting app creation

* Fixes after merge

* Make link methods async

* Use AppData as parameter for ConfigureLink

* GetApps by AppType

* Use ConfigureLink on dashboard

* Rename method

* Add properties to indicate stats support

* Property updates

* Test fixes

* Clean up imports

* Fixes after merge

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-03-17 11:56:32 +09:00
a671632fde Dashboard: Fix app stats tiles ()
* Dashboard: Fix app stats tiles

They broke with , because they contain script blocks that are loaded asynchronuosly and need to get run once the chart data is added to the page.

* Refactor PoS dashboard component

* Collocate the component JS files in separate files

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-03-16 15:51:24 +09:00
e344622c9e Form quick fixes () 2023-03-15 10:23:33 +01:00
06d7483ca3 Remove obsolete cli argument 'plugin-remote' () 2023-03-15 09:06:06 +01:00
7fe041fc2c Changelog 1.8.4 2023-03-15 10:45:49 +09:00
3f18e5476a Error when indexing invoices with some field that are too long (Fix ) 2023-03-15 09:31:38 +09:00
2a31613fe8 Fix invoice paid after expiration icon 2023-03-14 14:54:01 +01:00
ded0c8a3bc Update price display ()
* Update price display

As proposed by @dstrukt in .

* Update format

* Unify price display across the app

* Add DisplayFormatter

* Replace DisplayFormatCurrency method

* Use symbol currency format for invoice

* Unify currency formats on backend pages

* Revert recent changes

* Do not show exchange rate and fiat order amount for crypto denominations

* Fix test and add test cases
2023-03-13 10:12:58 +09:00
f3d9e07c5e Checkout v2: Celebrate payment with confetti ()
* Checkout v2: Celebrate payment with confetti

Have a colorful celebration for successful payments.

* Make it default and add test
2023-03-13 10:09:56 +09:00
eb3ba95114 Make CanUsePullPaymentsViaUI more robust ()
Fixes this nasty flaky test failure:

```
Failed CanUsePullPaymentsViaUI [17 s]
  Error Message:
   Assert.Equal() Failure
           ↓ (pos 1)
Expected: payout
Actual:   pull-payment
           ↑ (pos 1)
  Stack Trace:
     at BTCPayServer.Tests.ChromeTests.CanUsePullPaymentsViaUI() in /source/BTCPayServer.Tests/SeleniumTests.cs:line 1622
```

Because there are actually two labels, the previous selector was dependent on the correct ordern, because it always chose the first one …
2023-03-13 10:02:07 +09:00
7951dcada6 make sure we have cors for all of greenfield ()
fixes 
2023-03-10 15:20:11 +01:00
06951a39c6 fix API breaking changefor payout processors ()
fixes 
2023-03-10 17:57:33 +09:00
abe29f21f0 Checkout v2: Option to display amount in Sats in BIP21 case () 2023-03-09 21:36:11 +01:00
f57eab3008 Store branding: Add complementing text and accent colors () 2023-03-09 21:34:15 +01:00
270 changed files with 4964 additions and 2481 deletions
.github/ISSUE_TEMPLATE
BTCPayServer.Abstractions
BTCPayServer.Client
BTCPayServer.Data
BTCPayServer.Rating
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csprojColorPalette.cs
Components
Configuration
Controllers
Data
DerivationSchemeSettings.cs
Filters
Forms
HostedServices
Hosting
Models
PaymentRequest
Payments
PayoutProcessors
Plugins
Security
Services
Views
wwwroot
Build
Changelog.md
Plugins/BTCPayServer.Plugins.Custodians.FakeCustodian

@ -1,8 +1,14 @@
blank_issues_enabled: true
contact_links:
- name: 🚀 Discussions
url: https://github.com/btcpayserver/btcpayserver/discussions
about: Technical discussions, questions and feature requests
- name: 💡 Request a feature
url: https://github.com/btcpayserver/btcpayserver/discussions/categories/ideas-feature-requests
about: Submit a feature request or vote on ideas posted by others. Features with most upvotes become roadmap candidates
- name: 🧑‍💻 Ask a technical question
url: https://github.com/btcpayserver/btcpayserver/discussions/new?category=technical-support
about: If you're experiencing a technical problem post it to our community support forum
- name: 🔌 Report a problem with a plugin
url: https://github.com/btcpayserver/btcpayserver/discussions/new?category=plugins-integrations
about: Experiencing a problem with a third-party plugin? Post it here and we will tag their developers to assist
- name: 📝 Official Documentation
url: https://docs.btcpayserver.org
about: Check our documentation for answers to common questions

@ -5,4 +5,8 @@ public class AssetBalancesUnavailableException : CustodianApiException
public AssetBalancesUnavailableException(System.Exception e) : base(500, "asset-balances-unavailable", $"Cannot fetch the asset balances: {e.Message}", e)
{
}
public AssetBalancesUnavailableException(string errorMsg) : base(500, "asset-balances-unavailable", $"Cannot fetch the asset balances: {errorMsg}")
{
}
}

@ -0,0 +1,28 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.JsonConverters;
namespace BTCPayServer.Abstractions.Custodians.Client;
public class SimulateWithdrawalResult
{
public string PaymentMethod { get; }
public string Asset { get; }
public decimal MinQty { get; }
public decimal MaxQty { get; }
public List<LedgerEntryData> LedgerEntries { get; }
// Fee can be NULL if unknown.
public decimal? Fee { get; }
public SimulateWithdrawalResult(string paymentMethod, string asset, List<LedgerEntryData> ledgerEntries,
decimal minQty, decimal maxQty)
{
PaymentMethod = paymentMethod;
Asset = asset;
LedgerEntries = ledgerEntries;
MinQty = minQty;
MaxQty = maxQty;
}
}

@ -5,9 +5,14 @@ using Newtonsoft.Json.Linq;
namespace BTCPayServer.Abstractions.Custodians;
/// <summary>
/// Interface for custodians that can move funds to the store wallet.
/// </summary>
public interface ICanWithdraw
{
public Task<WithdrawResult> WithdrawAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken);
public Task<WithdrawResult> WithdrawToStoreWalletAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken);
public Task<SimulateWithdrawalResult> SimulateWithdrawalAsync(string paymentMethod, decimal qty, JObject config, CancellationToken cancellationToken);
public Task<WithdrawResult> GetWithdrawalInfoAsync(string paymentMethod, string withdrawalId, JObject config, CancellationToken cancellationToken);

@ -20,7 +20,6 @@ public interface ICustodian
*/
Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken);
public Task<Form.Form> GetConfigForm(JObject config, string locale,
CancellationToken cancellationToken = default);
public Task<Form.Form> GetConfigForm(CancellationToken cancellationToken = default);
}

@ -28,7 +28,7 @@ public class Field
public bool Constant;
// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
// HTML5 compatible type string like "text", "textarea", "email", "password", etc.
public string Type;
public static Field CreateFieldset()

@ -50,7 +50,7 @@ public class Form
HashSet<string> nameReturned = new();
foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
{
var fullName = string.Join('_', f.Path);
var fullName = string.Join('_', f.Path.Where(s => !string.IsNullOrEmpty(s)));
if (!nameReturned.Add(fullName))
continue;
yield return (fullName, f.Path, f.Field);
@ -63,7 +63,7 @@ public class Form
HashSet<string> nameReturned = new();
foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
{
var fullName = string.Join('_', f.Path);
var fullName = string.Join('_', f.Path.Where(s => !string.IsNullOrEmpty(s)));
if (!nameReturned.Add(fullName))
{
errors.Add($"Form contains duplicate field names '{fullName}'");
@ -128,8 +128,8 @@ public class Form
}
else if (prop.Value.Type == JTokenType.String)
{
var fullname = String.Join('_', propPath);
if (fields.TryGetValue(fullname, out var f) && !f.Constant)
var fullName = string.Join('_', propPath.Where(s => !string.IsNullOrEmpty(s)));
if (fields.TryGetValue(fullName, out var f) && !f.Constant)
f.Value = prop.Value.Value<string>();
}
}

@ -50,7 +50,7 @@ namespace BTCPayServer.Client
await HandleResponse(response);
}
public virtual async Task<DepositAddressData> GetDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken token = default)
public virtual async Task<DepositAddressData> GetCustodianAccountDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/addresses/{paymentMethod}"), token);
return await HandleResponse<DepositAddressData>(response);
@ -58,7 +58,6 @@ namespace BTCPayServer.Client
public virtual async Task<MarketTradeResponseData> MarketTradeCustodianAccountAsset(string storeId, string accountId, TradeRequestData request, CancellationToken token = default)
{
//var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/users", null, request, HttpMethod.Post), token);
//return await HandleResponse<ApplicationUserData>(response);
var internalRequest = CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/market", null,
@ -67,13 +66,13 @@ namespace BTCPayServer.Client
return await HandleResponse<MarketTradeResponseData>(response);
}
public virtual async Task<MarketTradeResponseData> GetTradeInfo(string storeId, string accountId, string tradeId, CancellationToken token = default)
public virtual async Task<MarketTradeResponseData> GetCustodianAccountTradeInfo(string storeId, string accountId, string tradeId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/{tradeId}", method: HttpMethod.Get), token);
return await HandleResponse<MarketTradeResponseData>(response);
}
public virtual async Task<TradeQuoteResponseData> GetTradeQuote(string storeId, string accountId, string fromAsset, string toAsset, CancellationToken token = default)
public virtual async Task<TradeQuoteResponseData> GetCustodianAccountTradeQuote(string storeId, string accountId, string fromAsset, string toAsset, CancellationToken token = default)
{
var queryPayload = new Dictionary<string, object>();
queryPayload.Add("fromAsset", fromAsset);
@ -81,14 +80,20 @@ namespace BTCPayServer.Client
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/trades/quote", queryPayload), token);
return await HandleResponse<TradeQuoteResponseData>(response);
}
public virtual async Task<WithdrawalResponseData> CreateWithdrawal(string storeId, string accountId, WithdrawRequestData request, CancellationToken token = default)
public virtual async Task<WithdrawalResponseData> CreateCustodianAccountWithdrawal(string storeId, string accountId, WithdrawRequestData request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals", bodyPayload: request, method: HttpMethod.Post), token);
return await HandleResponse<WithdrawalResponseData>(response);
}
public virtual async Task<WithdrawalSimulationResponseData> SimulateCustodianAccountWithdrawal(string storeId, string accountId, WithdrawRequestData request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/simulation", bodyPayload: request, method: HttpMethod.Post), token);
return await HandleResponse<WithdrawalSimulationResponseData>(response);
}
public virtual async Task<WithdrawalResponseData> GetWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken token = default)
public virtual async Task<WithdrawalResponseData> GetCustodianAccountWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/{paymentMethod}/{withdrawalId}", method: HttpMethod.Get), token);
return await HandleResponse<WithdrawalResponseData>(response);

@ -0,0 +1,29 @@
using System;
using System.Globalization;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.JsonConverters
{
public class TradeQuantityJsonConverter : JsonConverter<TradeQuantity>
{
public override TradeQuantity ReadJson(JsonReader reader, Type objectType, TradeQuantity existingValue, bool hasExistingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
return null;
if (reader.TokenType != JsonToken.String)
throw new JsonObjectException("Invalid TradeQuantity, expected string. Expected: \"1.50\" or \"50%\"", reader);
if (TradeQuantity.TryParse((string)reader.Value, out var q))
return q;
throw new JsonObjectException("Invalid format for TradeQuantity. Expected: \"1.50\" or \"50%\"", reader);
}
public override void WriteJson(JsonWriter writer, TradeQuantity value, JsonSerializer serializer)
{
if (value is not null)
writer.WriteValue(value.ToString());
}
}
}

@ -1,3 +1,4 @@
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@ -6,6 +7,7 @@ namespace BTCPayServer.Client.Models;
public class LedgerEntryData
{
public string Asset { get; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Qty { get; }
[JsonConverter(typeof(StringEnumConverter))]

@ -1,8 +1,13 @@
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class TradeQuoteResponseData
{
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Bid { get; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Ask { get; }
public string ToAsset { get; }
public string FromAsset { get; }

@ -1,13 +1,85 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System;
using Newtonsoft.Json;
using System.Net.Http.Headers;
namespace BTCPayServer.Client.Models;
public class WithdrawRequestData
{
public string PaymentMethod { set; get; }
public decimal Qty { set; get; }
[JsonConverter(typeof(JsonConverters.TradeQuantityJsonConverter))]
public TradeQuantity Qty { set; get; }
public WithdrawRequestData(string paymentMethod, decimal qty)
public WithdrawRequestData()
{
}
public WithdrawRequestData(string paymentMethod, TradeQuantity qty)
{
PaymentMethod = paymentMethod;
Qty = qty;
}
}
#nullable enable
public record TradeQuantity
{
public TradeQuantity(decimal value, ValueType type)
{
Type = type;
Value = value;
}
public enum ValueType
{
Exact,
Percent
}
public ValueType Type { get; }
public decimal Value { get; set; }
public override string ToString()
{
if (Type == ValueType.Exact)
return Value.ToString(CultureInfo.InvariantCulture);
else
return Value.ToString(CultureInfo.InvariantCulture) + "%";
}
public static TradeQuantity Parse(string str)
{
if (!TryParse(str, out var r))
throw new FormatException("Invalid TradeQuantity");
return r;
}
public static bool TryParse(string str, [MaybeNullWhen(false)] out TradeQuantity quantity)
{
if (str is null)
throw new ArgumentNullException(nameof(str));
quantity = null;
str = str.Trim();
str = str.Replace(" ", "");
if (str.Length == 0)
return false;
if (str[^1] == '%')
{
if (!decimal.TryParse(str[..^1], NumberStyles.Any, CultureInfo.InvariantCulture, out var r))
return false;
if (r < 0.0m)
return false;
quantity = new TradeQuantity(r, TradeQuantity.ValueType.Percent);
}
else
{
if (!decimal.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var r))
return false;
if (r < 0.0m)
return false;
quantity = new TradeQuantity(r, TradeQuantity.ValueType.Exact);
}
return true;
}
}

@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace BTCPayServer.Client.Models;
public abstract class WithdrawalBaseResponseData
{
public string Asset { get; }
public string PaymentMethod { get; }
public List<LedgerEntryData> LedgerEntries { get; }
public string AccountId { get; }
public string CustodianCode { get; }
public WithdrawalBaseResponseData(string paymentMethod, string asset, List<LedgerEntryData> ledgerEntries, string accountId,
string custodianCode)
{
PaymentMethod = paymentMethod;
Asset = asset;
LedgerEntries = ledgerEntries;
AccountId = accountId;
CustodianCode = custodianCode;
}
}

@ -5,18 +5,13 @@ using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models;
public class WithdrawalResponseData
public class WithdrawalResponseData : WithdrawalBaseResponseData
{
public string Asset { get; }
public string PaymentMethod { get; }
public List<LedgerEntryData> LedgerEntries { get; }
public string WithdrawalId { get; }
public string AccountId { get; }
public string CustodianCode { get; }
[JsonConverter(typeof(StringEnumConverter))]
public WithdrawalStatus Status { get; }
public string WithdrawalId { get; }
public DateTimeOffset CreatedTime { get; }
public string TransactionId { get; }
@ -24,14 +19,10 @@ public class WithdrawalResponseData
public string TargetAddress { get; }
public WithdrawalResponseData(string paymentMethod, string asset, List<LedgerEntryData> ledgerEntries, string withdrawalId, string accountId,
string custodianCode, WithdrawalStatus status, DateTimeOffset createdTime, string targetAddress, string transactionId)
string custodianCode, WithdrawalStatus status, DateTimeOffset createdTime, string targetAddress, string transactionId) : base(paymentMethod, asset, ledgerEntries, accountId,
custodianCode)
{
PaymentMethod = paymentMethod;
Asset = asset;
LedgerEntries = ledgerEntries;
WithdrawalId = withdrawalId;
AccountId = accountId;
CustodianCode = custodianCode;
TargetAddress = targetAddress;
TransactionId = transactionId;
Status = status;

@ -0,0 +1,21 @@
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class WithdrawalSimulationResponseData : WithdrawalBaseResponseData
{
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? MinQty { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? MaxQty { get; set; }
public WithdrawalSimulationResponseData(string paymentMethod, string asset, string accountId,
string custodianCode, List<LedgerEntryData> ledgerEntries, decimal? minQty, decimal? maxQty) : base(paymentMethod,
asset, ledgerEntries, accountId, custodianCode)
{
MinQty = minQty;
MaxQty = maxQty;
}
}

@ -98,6 +98,37 @@ namespace BTCPayServer.Client
{
return policy.StartsWith("btcpay.plugin", StringComparison.OrdinalIgnoreCase);
}
public static bool IsUserPolicy(string policy)
{
return policy.StartsWith("btcpay.user", StringComparison.OrdinalIgnoreCase);
}
}
public class PermissionSet
{
public PermissionSet() : this(Array.Empty<Permission>())
{
}
public PermissionSet(Permission[] permissions)
{
Permissions = permissions;
}
public Permission[] Permissions { get; }
public bool Contains(Permission requestedPermission)
{
return Permissions.Any(p => p.Contains(requestedPermission));
}
public bool Contains(string permission, string store)
{
if (permission is null)
throw new ArgumentNullException(nameof(permission));
if (store is null)
throw new ArgumentNullException(nameof(store));
return Contains(Permission.Create(permission, store));
}
}
public class Permission
{
@ -105,7 +136,7 @@ namespace BTCPayServer.Client
{
Init();
}
public static Permission Create(string policy, string scope = null)
{
if (TryCreatePermission(policy, scope, out var r))
@ -121,7 +152,7 @@ namespace BTCPayServer.Client
policy = policy.Trim().ToLowerInvariant();
if (!Policies.IsValidPolicy(policy))
return false;
if (scope != null && !Policies.IsStorePolicy(policy))
if (!string.IsNullOrEmpty(scope) && !Policies.IsStorePolicy(policy))
return false;
permission = new Permission(policy, scope);
return true;
@ -174,7 +205,7 @@ namespace BTCPayServer.Client
}
if (!Policies.IsStorePolicy(subpermission.Policy))
return true;
return Scope == null || subpermission.Scope == this.Scope;
return Scope == null || subpermission.Scope == Scope;
}
public static IEnumerable<Permission> ToPermissions(string[] permissions)
@ -199,7 +230,8 @@ namespace BTCPayServer.Client
return true;
if (policy == subpolicy)
return true;
if (!PolicyMap.TryGetValue(policy, out var subPolicies)) return false;
if (!PolicyMap.TryGetValue(policy, out var subPolicies))
return false;
return subPolicies.Contains(subpolicy) || subPolicies.Any(s => ContainsPolicy(s, subpolicy));
}
@ -213,23 +245,23 @@ namespace BTCPayServer.Client
Policies.CanModifyInvoices,
Policies.CanViewStoreSettings,
Policies.CanModifyStoreWebhooks,
Policies.CanModifyPaymentRequests);
Policies.CanModifyPaymentRequests,
Policies.CanUseLightningNodeInStore);
PolicyHasChild(Policies.CanManageUsers, Policies.CanCreateUser);
PolicyHasChild(Policies.CanManagePullPayments, Policies.CanCreatePullPayments );
PolicyHasChild(Policies.CanCreatePullPayments, Policies.CanCreateNonApprovedPullPayments );
PolicyHasChild(Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests );
PolicyHasChild(Policies.CanModifyProfile, Policies.CanViewProfile );
PolicyHasChild(Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore );
PolicyHasChild(Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser );
PolicyHasChild(Policies.CanManagePullPayments, Policies.CanCreatePullPayments);
PolicyHasChild(Policies.CanCreatePullPayments, Policies.CanCreateNonApprovedPullPayments);
PolicyHasChild(Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
PolicyHasChild(Policies.CanModifyProfile, Policies.CanViewProfile);
PolicyHasChild(Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
PolicyHasChild(Policies.CanModifyServerSettings,
Policies.CanUseInternalLightningNode,
Policies.CanManageUsers);
PolicyHasChild(Policies.CanUseInternalLightningNode, Policies.CanCreateLightningInvoiceInternalNode,Policies.CanViewLightningInvoiceInternalNode );
PolicyHasChild(Policies.CanManageCustodianAccounts, Policies.CanViewCustodianAccounts );
PolicyHasChild(Policies.CanModifyInvoices, Policies.CanViewInvoices, Policies.CanCreateInvoice );
PolicyHasChild(Policies.CanViewStoreSettings, Policies.CanViewInvoices, Policies.CanViewPaymentRequests );
PolicyHasChild(Policies.CanUseInternalLightningNode, Policies.CanCreateLightningInvoiceInternalNode, Policies.CanViewLightningInvoiceInternalNode);
PolicyHasChild(Policies.CanManageCustodianAccounts, Policies.CanViewCustodianAccounts);
PolicyHasChild(Policies.CanModifyInvoices, Policies.CanViewInvoices, Policies.CanCreateInvoice, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(Policies.CanViewStoreSettings, Policies.CanViewInvoices, Policies.CanViewPaymentRequests);
}
private static void PolicyHasChild(string policy, params string[] subPolicies)
@ -243,33 +275,26 @@ namespace BTCPayServer.Client
}
else
{
PolicyMap.Add(policy,subPolicies.ToHashSet());
PolicyMap.Add(policy, subPolicies.ToHashSet());
}
}
public string Scope { get; }
public string Policy { get; }
public override string ToString()
{
if (Scope != null)
{
return $"{Policy}:{Scope}";
}
return Policy;
return Scope != null ? $"{Policy}:{Scope}" : Policy;
}
public override bool Equals(object obj)
{
Permission item = obj as Permission;
if (item == null)
return false;
return ToString().Equals(item.ToString());
return item != null && ToString().Equals(item.ToString());
}
public static bool operator ==(Permission a, Permission b)
{
if (System.Object.ReferenceEquals(a, b))
if (ReferenceEquals(a, b))
return true;
if (((object)a == null) || ((object)b == null))
return false;

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;

@ -0,0 +1,31 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20230315062447_fixmaxlength")]
public partial class fixmaxlength : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder.IsNpgsql())
{
migrationBuilder.Sql("ALTER TABLE \"InvoiceSearches\" ALTER COLUMN \"Value\" TYPE TEXT USING \"Value\"::TEXT;");
migrationBuilder.Sql("ALTER TABLE \"Invoices\" ALTER COLUMN \"OrderId\" TYPE TEXT USING \"OrderId\"::TEXT;");
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Not supported
}
}
}

@ -1305,7 +1305,7 @@
"name":"Satoshis",
"code":"SATS",
"divisibility":0,
"symbol":"Sats",
"symbol":"sats",
"crypto":true
},
{

@ -5,7 +5,6 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using BTCPayServer.Rating;
using NBitcoin;
using Newtonsoft.Json;
@ -28,14 +27,6 @@ namespace BTCPayServer.Services.Rates
}
static readonly Dictionary<string, IFormatProvider> _CurrencyProviders = new Dictionary<string, IFormatProvider>();
public string FormatCurrency(string price, string currency)
{
return FormatCurrency(decimal.Parse(price, CultureInfo.InvariantCulture), currency);
}
public string FormatCurrency(decimal price, string currency)
{
return price.ToString("C", GetCurrencyProvider(currency));
}
public NumberFormatInfo GetNumberFormatInfo(string currency, bool useFallback)
{
@ -56,6 +47,7 @@ namespace BTCPayServer.Services.Rates
currencyInfo.CurrencySymbol = currency;
return currencyInfo;
}
public NumberFormatInfo GetNumberFormatInfo(string currency)
{
var curr = GetCurrencyProvider(currency);
@ -65,6 +57,7 @@ namespace BTCPayServer.Services.Rates
return ni;
return null;
}
public IFormatProvider GetCurrencyProvider(string currency)
{
lock (_CurrencyProviders)
@ -104,30 +97,6 @@ namespace BTCPayServer.Services.Rates
currencyProviders.TryAdd(code, number);
}
/// <summary>
/// Format a currency like "0.004 $ (USD)", round to significant divisibility
/// </summary>
/// <param name="value">The value</param>
/// <param name="currency">Currency code</param>
/// <returns></returns>
public string DisplayFormatCurrency(decimal value, string currency)
{
var provider = GetNumberFormatInfo(currency, true);
var currencyData = GetCurrencyData(currency, true);
var divisibility = currencyData.Divisibility;
value = value.RoundToSignificant(ref divisibility);
if (divisibility != provider.CurrencyDecimalDigits)
{
provider = (NumberFormatInfo)provider.Clone();
provider.CurrencyDecimalDigits = divisibility;
}
if (currencyData.Crypto)
return value.ToString("C", provider);
else
return value.ToString("C", provider) + $" ({currency})";
}
readonly Dictionary<string, CurrencyData> _Currencies;
static CurrencyData[] LoadCurrency()

@ -11,6 +11,7 @@ using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
@ -386,7 +387,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("BOLT11Expiration")).SendKeys("5" + Keys.Enter);
s.GoToInvoice(invoice.Id);
s.Driver.FindElement(By.Id("IssueRefund")).Click();
if (multiCurrency)
{
s.Driver.WaitUntilAvailable(By.Id("RefundForm"), TimeSpan.FromSeconds(1));
@ -396,21 +397,21 @@ namespace BTCPayServer.Tests
}
s.Driver.WaitUntilAvailable(By.Id("RefundForm"), TimeSpan.FromSeconds(1));
Assert.Contains("$5,500.00", s.Driver.PageSource); // Should propose reimburse in fiat
Assert.Contains("1.10000000 ", s.Driver.PageSource); // Should propose reimburse in BTC at the rate of before
Assert.Contains("2.20000000 ", s.Driver.PageSource); // Should propose reimburse in BTC at the current rate
Assert.Contains("5,500.00 USD", s.Driver.PageSource); // Should propose reimburse in fiat
Assert.Contains("1.10000000 BTC", s.Driver.PageSource); // Should propose reimburse in BTC at the rate of before
Assert.Contains("2.20000000 BTC", s.Driver.PageSource); // Should propose reimburse in BTC at the current rate
s.Driver.WaitForAndClick(By.Id(rateSelection));
s.Driver.FindElement(By.Id("ok")).Click();
s.Driver.WaitUntilAvailable(By.Id("Destination"), TimeSpan.FromSeconds(1));
Assert.Contains("pull-payments", s.Driver.Url);
if (rateSelection == "FiatOption")
Assert.Contains("$5,500.00", s.Driver.PageSource);
Assert.Contains("5,500.00 USD", s.Driver.PageSource);
if (rateSelection == "CurrentOption")
Assert.Contains("2.20000000 ", s.Driver.PageSource);
Assert.Contains("2.20000000 BTC", s.Driver.PageSource);
if (rateSelection == "RateThenOption")
Assert.Contains("1.10000000 ", s.Driver.PageSource);
Assert.Contains("1.10000000 BTC", s.Driver.PageSource);
s.GoToInvoice(invoice.Id);
s.Driver.FindElement(By.Id("IssueRefund")).Click();
s.Driver.WaitUntilAvailable(By.Id("Destination"), TimeSpan.FromSeconds(1));
@ -584,7 +585,7 @@ namespace BTCPayServer.Tests
Assert.True(invoice.SupportedTransactionCurrencies["LTC"].Enabled);
Assert.True(invoice.PaymentSubtotals.ContainsKey("LTC"));
Assert.True(invoice.PaymentTotals.ContainsKey("LTC"));
// Check if we can disable LTC
invoice = await user.BitPay.CreateInvoiceAsync(
new Invoice
@ -622,10 +623,11 @@ namespace BTCPayServer.Tests
var apps = user.GetController<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
var appType = PointOfSaleAppType.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
@ -680,7 +682,7 @@ donation:
var appleInvoice = invoices.SingleOrDefault(invoice => invoice.ItemCode.Equals("apple"));
Assert.NotNull(appleInvoice);
Assert.Equal("good apple", appleInvoice.ItemDesc);
// testing custom amount
var action = Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 6.6m, null, null, null, null, "donation").Result);
@ -735,7 +737,7 @@ donation:
Assert.Equal(test.ExpectedDivisibility, vmview.CurrencyInfo.Divisibility);
Assert.Equal(test.ExpectedSymbolSpace, vmview.CurrencyInfo.SymbolSpace);
}
//test inventory related features
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
vmpos.Title = "hello";
@ -756,7 +758,7 @@ noninventoryitem:
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "inventoryitem").Result);
return Task.CompletedTask;
});
//we already bought all available stock so this should fail
await Task.Delay(100);
Assert.IsType<RedirectToActionResult>(publicApps
@ -819,13 +821,13 @@ normal:
normalInvoice.CryptoInfo,
s => PaymentTypes.BTCLike.ToString() == s.PaymentType && new[] { "BTC", "LTC" }.Contains(
s.CryptoCode));
//test topup option
vmpos.Template = @"
a:
price: 1000.0
title: good apple
b:
price: 10.0
custom: false
@ -843,7 +845,7 @@ f:
g:
custom: topup
";
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.DoesNotContain("custom", vmpos.Template);
@ -855,7 +857,7 @@ g:
Assert.Contains(items, item => item.Id == "e" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "f" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "g" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Static, null, null, null, null, null, "g").Result);
invoices = user.BitPay.GetInvoices();

@ -182,7 +182,7 @@ namespace BTCPayServer.Tests
var invoiceId = s.CreateInvoice(10, "USD", "a@g.com");
s.GoToInvoiceCheckout(invoiceId);
Assert.Contains("Sats", s.Driver.FindElement(By.ClassName("payment__currencies_noborder")).Text);
Assert.Contains("sats", s.Driver.FindElement(By.ClassName("payment__currencies_noborder")).Text);
}
[Fact(Timeout = TestTimeout)]

@ -73,6 +73,14 @@ namespace BTCPayServer.Tests
s.Driver.ElementDoesNotExist(By.Id("Lightning_BTC"));
s.Driver.ElementDoesNotExist(By.Id("PayByLNURL"));
// Details should show exchange rate
s.Driver.ToggleCollapse("PaymentDetails");
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-TotalPrice"));
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-TotalFiat"));
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-AmountDue"));
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("sat/byte", s.Driver.FindElement(By.Id("PaymentDetails-RecommendedFee")).Text);
// Switch to LNURL
s.Driver.FindElement(By.CssSelector(".payment-method:nth-child(2)")).Click();
TestUtils.Eventually(() =>
@ -86,7 +94,7 @@ namespace BTCPayServer.Tests
// Default payment method
s.GoToHome();
invoiceId = s.CreateInvoice(defaultPaymentMethod: "BTC_LightningLike");
invoiceId = s.CreateInvoice(21000, "SATS", defaultPaymentMethod: "BTC_LightningLike");
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count);
@ -102,7 +110,7 @@ namespace BTCPayServer.Tests
s.Driver.ElementDoesNotExist(By.Id("Address_BTC"));
s.Driver.FindElement(By.Id("PayByLNURL"));
// Lightning amount in Sats
// Lightning amount in sats
Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text);
s.GoToHome();
s.GoToLightningSettings();
@ -111,7 +119,15 @@ namespace BTCPayServer.Tests
Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text);
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Contains("Sats", s.Driver.FindElement(By.Id("AmountDue")).Text);
Assert.Contains("sats", s.Driver.FindElement(By.Id("AmountDue")).Text);
// Details should not show exchange rate
s.Driver.ToggleCollapse("PaymentDetails");
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-ExchangeRate"));
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-TotalFiat"));
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-RecommendedFee"));
Assert.Contains("21 000 sats", s.Driver.FindElement(By.Id("PaymentDetails-AmountDue")).Text);
Assert.Contains("21 000 sats", s.Driver.FindElement(By.Id("PaymentDetails-TotalPrice")).Text);
// Expire
var expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));
@ -124,7 +140,7 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("Please send", paymentInfo.Text);
TestUtils.Eventually(() =>
{
var expiredSection = s.Driver.FindElement(By.Id("expired"));
var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
Assert.True(expiredSection.Displayed);
Assert.Contains("Invoice Expired", expiredSection.Text);
});
@ -145,6 +161,10 @@ namespace BTCPayServer.Tests
Assert.Contains("Exchange Rate", details.Text);
Assert.Contains("Amount Due", details.Text);
Assert.Contains("Recommended Fee", details.Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
Assert.Contains("BTC", s.Driver.FindElement(By.Id("PaymentDetails-AmountDue")).Text);
Assert.Contains("BTC", s.Driver.FindElement(By.Id("PaymentDetails-TotalPrice")).Text);
// Pay partial amount
await Task.Delay(200);
@ -161,12 +181,27 @@ namespace BTCPayServer.Tests
{
Assert.Contains("Created transaction",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
s.Server.ExplorerNode.Generate(1);
s.Server.ExplorerNode.Generate(2);
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("The invoice hasn't been paid in full", paymentInfo.Text);
Assert.Contains("Please send", paymentInfo.Text);
});
// Pay full amount
var amountDue = s.Driver.FindElement(By.Id("AmountDue")).GetAttribute("data-amount-due");
s.Driver.FindElement(By.Id("FakePayAmount")).FillIn(amountDue);
s.Driver.FindElement(By.Id("FakePay")).Click();
// Processing
TestUtils.Eventually(() =>
{
var processingSection = s.Driver.WaitForElement(By.Id("processing"));
Assert.True(processingSection.Displayed);
Assert.Contains("Payment Sent", processingSection.Text);
Assert.Contains("Your payment has been received and is now processing", processingSection.Text);
Assert.True(s.Driver.ElementDoesNotExist(By.Id("confetti")));
});
// Mine
s.Driver.FindElement(By.Id("Mine")).Click();
TestUtils.Eventually(() =>
@ -174,18 +209,15 @@ namespace BTCPayServer.Tests
Assert.Contains("Mined 1 block",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
});
// Pay full amount
var amountDue = s.Driver.FindElement(By.Id("AmountDue")).GetAttribute("data-amount-due");
s.Driver.FindElement(By.Id("FakePayAmount")).FillIn(amountDue);
s.Driver.FindElement(By.Id("FakePay")).Click();
// Settled
TestUtils.Eventually(() =>
{
s.Server.ExplorerNode.Generate(1);
var paidSection = s.Driver.WaitForElement(By.Id("paid"));
Assert.True(paidSection.Displayed);
Assert.Contains("Invoice Paid", paidSection.Text);
var settledSection = s.Driver.WaitForElement(By.Id("settled"));
Assert.True(settledSection.Displayed);
Assert.Contains("Invoice Paid", settledSection.Text);
});
s.Driver.FindElement(By.Id("confetti"));
s.Driver.FindElement(By.Id("ReceiptLink"));
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
@ -193,6 +225,7 @@ namespace BTCPayServer.Tests
s.GoToHome();
s.GoToStore(StoreNavPages.CheckoutAppearance);
s.Driver.SetCheckbox(By.Id("OnChainWithLnInvoiceFallback"), true);
s.Driver.SetCheckbox(By.Id("LightningAmountInSatoshi"), false);
s.Driver.FindElement(By.Id("Save")).Click();
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
@ -200,6 +233,7 @@ namespace BTCPayServer.Tests
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method")));
Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text);
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
@ -214,7 +248,33 @@ namespace BTCPayServer.Tests
Assert.StartsWith($"bitcoin:{address.ToUpperInvariant()}?amount=", qrValue);
Assert.Contains("&lightning=LNBCRT", qrValue);
s.Driver.FindElement(By.Id("PayByLNURL"));
// Check details
s.Driver.ToggleCollapse("PaymentDetails");
Assert.Contains("1 BTC = ", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
Assert.Contains("BTC", s.Driver.FindElement(By.Id("PaymentDetails-AmountDue")).Text);
Assert.Contains("BTC", s.Driver.FindElement(By.Id("PaymentDetails-TotalPrice")).Text);
// Switch to amount displayed in sats
s.GoToHome();
s.GoToStore(StoreNavPages.CheckoutAppearance);
s.Driver.SetCheckbox(By.Id("LightningAmountInSatoshi"), true);
s.Driver.FindElement(By.Id("Save")).Click();
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
Assert.Contains("sats", s.Driver.FindElement(By.Id("AmountDue")).Text);
// Check details
s.Driver.ToggleCollapse("PaymentDetails");
Assert.Contains("1 sat = ", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
Assert.Contains("sats", s.Driver.FindElement(By.Id("PaymentDetails-AmountDue")).Text);
Assert.Contains("sats", s.Driver.FindElement(By.Id("PaymentDetails-TotalPrice")).Text);
// BIP21 with LN as default payment method
s.GoToHome();
invoiceId = s.CreateInvoice(defaultPaymentMethod: "BTC_LightningLike");
@ -225,6 +285,14 @@ namespace BTCPayServer.Tests
Assert.StartsWith("bitcoin:", payUrl);
Assert.Contains("&lightning=lnbcrt", payUrl);
s.Driver.FindElement(By.Id("PayByLNURL"));
// Check details
s.Driver.ToggleCollapse("PaymentDetails");
Assert.Contains("1 sat = ", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
Assert.Contains("sats", s.Driver.FindElement(By.Id("PaymentDetails-AmountDue")).Text);
Assert.Contains("sats", s.Driver.FindElement(By.Id("PaymentDetails-TotalPrice")).Text);
// Ensure LNURL is enabled
s.GoToHome();
@ -250,6 +318,14 @@ namespace BTCPayServer.Tests
Assert.StartsWith("lnurl", copyAddressLightning);
Assert.StartsWith($"bitcoin:{address.ToUpperInvariant()}?lightning=LNURL", qrValue);
s.Driver.FindElement(By.Id("PayByLNURL"));
// Check details
s.Driver.ToggleCollapse("PaymentDetails");
Assert.Contains("1 sat = ", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
Assert.Contains("$", s.Driver.FindElement(By.Id("PaymentDetails-ExchangeRate")).Text);
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-TotalFiat"));
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-AmountDue"));
s.Driver.ElementDoesNotExist(By.Id("PaymentDetails-TotalPrice"));
// Expiry message should not show amount for top-up invoice
expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));

@ -4,11 +4,11 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.Crowdfund.Controllers;
using BTCPayServer.Plugins.Crowdfund.Models;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitpayClient;
@ -34,18 +34,16 @@ namespace BTCPayServer.Tests
await user.GrantAccessAsync();
var user2 = tester.NewAccount();
await user2.GrantAccessAsync();
var stores = user.GetController<UIStoresController>();
var apps = user.GetController<UIAppsController>();
var apps2 = user2.GetController<UIAppsController>();
var crowdfund = user.GetController<UICrowdfundController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.Crowdfund.ToString();
Assert.NotNull(vm.SelectedAppType);
var appType = CrowdfundAppType.AppType;
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId, appType)).Model);
Assert.Equal(appType, vm.SelectedAppType);
Assert.Null(vm.AppName);
vm.AppName = "test";
vm.SelectedAppType = appType;
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.Equal(nameof(crowdfund.UpdateCrowdfund), redirectToAction.ActionName);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/crowdfund", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
@ -61,8 +59,8 @@ namespace BTCPayServer.Tests
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));
redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
Assert.Equal(nameof(stores.Dashboard), redirectToAction.ActionName);
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
Assert.Equal(nameof(UIStoresController.Dashboard), redirectToAction.ActionName);
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
Assert.Empty(appList.Apps);
}
@ -79,10 +77,11 @@ namespace BTCPayServer.Tests
var apps = user.GetController<UIAppsController>();
var crowdfund = user.GetController<UICrowdfundController>();
var vm = apps.CreateApp(user.StoreId).AssertViewModel<CreateAppViewModel>();
var appType = AppType.Crowdfund.ToString();
var appType = CrowdfundAppType.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/crowdfund", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
@ -105,7 +104,7 @@ namespace BTCPayServer.Tests
Amount = new decimal(0.01)
}, default));
Assert.IsType<NotFoundResult>(await anonAppPubsController.ViewCrowdfund(app.Id, string.Empty));
Assert.IsType<NotFoundResult>(await anonAppPubsController.ViewCrowdfund(app.Id));
//Scenario 2: Not Enabled But Admin - Allowed
Assert.IsType<OkObjectResult>(await crowdfundController.ContributeToCrowdfund(app.Id, new ContributeToCrowdfund()
@ -113,8 +112,8 @@ namespace BTCPayServer.Tests
RedirectToCheckout = false,
Amount = new decimal(0.01)
}, default));
Assert.IsType<ViewResult>(await crowdfundController.ViewCrowdfund(app.Id, string.Empty));
Assert.IsType<NotFoundResult>(await anonAppPubsController.ViewCrowdfund(app.Id, string.Empty));
Assert.IsType<ViewResult>(await crowdfundController.ViewCrowdfund(app.Id));
Assert.IsType<NotFoundResult>(await anonAppPubsController.ViewCrowdfund(app.Id));
//Scenario 3: Enabled But Start Date > Now - Not Allowed
crowdfundViewModel.StartDate = DateTime.Today.AddDays(2);
@ -170,10 +169,10 @@ namespace BTCPayServer.Tests
var apps = user.GetController<UIAppsController>();
var crowdfund = user.GetController<UICrowdfundController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.Crowdfund.ToString();
var appType = CrowdfundAppType.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
@ -193,7 +192,7 @@ namespace BTCPayServer.Tests
var publicApps = user.GetController<UICrowdfundController>();
var model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id, String.Empty).Result).Model);
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id).Result).Model);
Assert.Equal(crowdfundViewModel.TargetAmount, model.TargetAmount);
Assert.Equal(crowdfundViewModel.EndDate, model.EndDate);
@ -217,7 +216,7 @@ namespace BTCPayServer.Tests
}, Facade.Merchant);
model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id, string.Empty).Result).Model);
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id).Result).Model);
Assert.Equal(0m, model.Info.CurrentAmount);
Assert.Equal(1m, model.Info.CurrentPendingAmount);
@ -226,12 +225,12 @@ namespace BTCPayServer.Tests
TestLogs.LogInformation("Let's check current amount change once payment is confirmed");
var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, tester.ExplorerNode.Network);
tester.ExplorerNode.SendToAddress(invoiceAddress, invoice.BtcDue);
tester.ExplorerNode.Generate(1); // By default invoice confirmed at 1 block
await tester.ExplorerNode.SendToAddressAsync(invoiceAddress, invoice.BtcDue);
await tester.ExplorerNode.GenerateAsync(1); // By default invoice confirmed at 1 block
TestUtils.Eventually(() =>
{
model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id, String.Empty).Result).Model);
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id).Result).Model);
Assert.Equal(1m, model.Info.CurrentAmount);
Assert.Equal(0m, model.Info.CurrentPendingAmount);
});
@ -279,7 +278,7 @@ namespace BTCPayServer.Tests
TestUtils.Eventually(() =>
{
model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id, string.Empty).Result).Model);
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id).Result).Model);
Assert.Equal(0.7m, model.Info.CurrentPendingAmount);
});
}

@ -51,7 +51,6 @@ namespace BTCPayServer.Tests
{
public FastTests(ITestOutputHelper helper) : base(helper)
{
}
class DockerImage
{
@ -326,7 +325,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(ChainName.Regtest);
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
{
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null, null),
new LightningLikePaymentHandler(null, null, networkProvider, null, null, null),
});
var entity = new InvoiceEntity();
@ -512,7 +511,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(ChainName.Regtest);
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
{
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null, null),
new LightningLikePaymentHandler(null, null, networkProvider, null, null, null),
});
var entity = new InvoiceEntity();
@ -600,15 +599,16 @@ namespace BTCPayServer.Tests
[Fact]
public void RoundupCurrenciesCorrectly()
{
DisplayFormatter displayFormatter = new (CurrencyNameTable.Instance);
foreach (var test in new[]
{
(0.0005m, "$0.0005 (USD)", "USD"), (0.001m, "$0.001 (USD)", "USD"), (0.01m, "$0.01 (USD)", "USD"),
(0.1m, "$0.10 (USD)", "USD"), (0.1m, "0,10 € (EUR)", "EUR"), (1000m, "¥1,000 (JPY)", "JPY"),
(1000.0001m, "1,000.00 (INR)", "INR"),
(0.0m, "$0.00 (USD)", "USD")
(0.0005m, "0.0005 USD", "USD"), (0.001m, "0.001 USD", "USD"), (0.01m, "0.01 USD", "USD"),
(0.1m, "0.10 USD", "USD"), (0.1m, "0,10 EUR", "EUR"), (1000m, "1,000 JPY", "JPY"),
(1000.0001m, "1,000.00 INR", "INR"),
(0.0m, "0.00 USD", "USD")
})
{
var actual = CurrencyNameTable.Instance.DisplayFormatCurrency(test.Item1, test.Item3);
var actual = displayFormatter.Currency(test.Item1, test.Item3);
actual = actual.Replace("¥", "¥"); // Hack so JPY test pass on linux as well
Assert.Equal(test.Item2, actual);
}
@ -706,22 +706,69 @@ namespace BTCPayServer.Tests
Assert.Throws<ParsingException>(() => { parser.ParseOutputDescriptor("wpkh([8b60afd1/49h/0h/0h]xpub661MyMwAFXkMnyoBjyHndD3QwRbcGVBsTGeNZN6QGVHcfz4MPzBUxjSevweNFQx7SqmMHLdSA4FteGsRrEriu4pnVZMZWnruFFAYZATtcDw/0/*)#9x4vkw48"); }); // invalid checksum
}
[Fact]
public void ParseTradeQuantity()
{
Assert.Throws<FormatException>(() => TradeQuantity.Parse("1.2345o"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("o"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse(""));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("1.353%%"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("1.353 %%"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("-1.353%"));
Assert.Throws<FormatException>(() => TradeQuantity.Parse("-1.353"));
var qty = TradeQuantity.Parse("1.3%");
Assert.Equal(1.3m, qty.Value);
Assert.Equal(TradeQuantity.ValueType.Percent, qty.Type);
var qty2 = TradeQuantity.Parse("1.3");
Assert.Equal(1.3m, qty2.Value);
Assert.Equal(TradeQuantity.ValueType.Exact, qty2.Type);
Assert.NotEqual(qty, qty2);
Assert.Equal(qty, TradeQuantity.Parse("1.3%"));
Assert.Equal(qty2, TradeQuantity.Parse("1.3"));
Assert.Equal(TradeQuantity.Parse(qty.ToString()), TradeQuantity.Parse("1.3%"));
Assert.Equal(TradeQuantity.Parse(qty2.ToString()), TradeQuantity.Parse("1.3"));
Assert.Equal(TradeQuantity.Parse(qty2.ToString()), TradeQuantity.Parse(" 1.3 "));
}
[Fact]
public void ParseDerivationSchemeSettings()
{
var testnet = new BTCPayNetworkProvider(ChainName.Testnet).GetNetwork<BTCPayNetwork>("BTC");
var mainnet = new BTCPayNetworkProvider(ChainName.Mainnet).GetNetwork<BTCPayNetwork>("BTC");
var root = new Mnemonic(
"usage fever hen zero slide mammal silent heavy donate budget pulse say brain thank sausage brand craft about save attract muffin advance illegal cabbage")
.DeriveExtKey();
// xpub
var tpub = "tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS";
Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(tpub, testnet, out var settings, out var error));
Assert.Null(error);
Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: false });
Assert.Equal($"{tpub}-[legacy]", ((DirectDerivationStrategy)settings.AccountDerivation).ToString());
// xpub with fingerprint and account
tpub = "tpubDCXK98mNrPWuoWweaoUkqwxQF5NMWpQLy7n7XJgDCpwYfoZRXGafPaVM7mYqD7UKhsbMxkN864JY2PniMkt1Uk4dNuAMnWFVqdquyvZNyca";
var vpub = "vpub5YVA1ZbrqkUVq8NZTtvRDrS2a1yoeBvHbG9NbxqJ6uRtpKGFwjQT11WEqKYsgoDF6gpqrDf8ddmPZe4yXWCjzqF8ad2Cw9xHiE8DSi3X3ik";
var fingerprint = "e5746fd9";
var account = "84'/1'/0'";
var str = $"[{fingerprint}/{account}]{vpub}";
Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(str, testnet, out settings, out error));
Assert.Null(error);
Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: true });
Assert.Equal(vpub, settings.AccountOriginal);
Assert.Equal(tpub, ((DirectDerivationStrategy)settings.AccountDerivation).ToString());
Assert.Equal(HDFingerprint.TryParse(fingerprint, out var hd) ? hd : default, settings.AccountKeySettings[0].RootFingerprint);
Assert.Equal(account, settings.AccountKeySettings[0].AccountKeyPath.ToString());
// ColdCard
Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(
"{\"keystore\": {\"ckcc_xpub\": \"xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw\", \"xpub\": \"ypub6WWc2gWwHbdnAAyJDnR4SPL1phRh7REqrPBfZeizaQ1EmTshieRXJC3Z5YoU4wkcdKHEjQGkh6AYEzCQC1Kz3DNaWSwdc1pc8416hAjzqyD\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/0'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}",
mainnet, out var settings, out var error));
mainnet, out settings, out error));
Assert.Null(error);
Assert.Equal(root.GetPublicKey().GetHDFingerPrint(), settings.AccountKeySettings[0].RootFingerprint);
Assert.Equal(settings.AccountKeySettings[0].RootFingerprint,
HDFingerprint.TryParse("8bafd160", out var hd) ? hd : default);
HDFingerprint.TryParse("8bafd160", out hd) ? hd : default);
Assert.Equal("Coldcard Import 0x60d1af8b", settings.Label);
Assert.Equal("49'/0'/0'", settings.AccountKeySettings[0].AccountKeyPath.ToString());
Assert.Equal(
@ -729,28 +776,26 @@ namespace BTCPayServer.Tests
settings.AccountOriginal);
Assert.Equal(root.Derive(new KeyPath("m/49'/0'/0'")).Neuter().PubKey.WitHash.ScriptPubKey.Hash.ScriptPubKey,
settings.AccountDerivation.GetDerivation().ScriptPubKey);
var testnet = new BTCPayNetworkProvider(ChainName.Testnet).GetNetwork<BTCPayNetwork>("BTC");
// Should be legacy
Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(
"{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"tpubDDWYqT3P24znfsaGX7kZcQhNc5LAjnQiKQvUCHF2jS6dsgJBRtymopEU5uGpMaR5YChjuiExZG1X2aTbqXkp82KqH5qnqwWHp6EWis9ZvKr\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/44'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}",
testnet, out settings, out error));
Assert.True(settings.AccountDerivation is DirectDerivationStrategy s && !s.Segwit);
Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: false });
Assert.Null(error);
// Should be segwit p2sh
Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(
"{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"upub5DSddA9NoRUyJrQ4p86nsCiTSY7kLHrSxx3joEJXjHd4HPARhdXUATuk585FdWPVC2GdjsMePHb6BMDmf7c6KG4K4RPX6LVqBLtDcWpQJmh\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}",
testnet, out settings, out error));
Assert.True(settings.AccountDerivation is P2SHDerivationStrategy p &&
p.Inner is DirectDerivationStrategy s2 && s2.Segwit);
Assert.True(settings.AccountDerivation is P2SHDerivationStrategy { Inner: DirectDerivationStrategy { Segwit: true } });
Assert.Null(error);
// Should be segwit
Assert.True(DerivationSchemeSettings.TryParseFromWalletFile(
"{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"vpub5YjYxTemJ39tFRnuAhwduyxG2tKGjoEpmvqVQRPqdYrqa6YGoeSzBtHXaJUYB19zDbXs3JjbEcVWERjQBPf9bEfUUMZNMv1QnMyHV8JPqyf\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/84'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}",
testnet, out settings, out error));
Assert.True(settings.AccountDerivation is DirectDerivationStrategy s3 && s3.Segwit);
Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: true });
Assert.Null(error);
// Specter
@ -1466,14 +1511,14 @@ namespace BTCPayServer.Tests
Assert.Equal(1m / 0.000061m, rule2.BidAsk.Bid);
// testing rounding
rule2 = rules.GetRuleFor(CurrencyPair.Parse("Sats_EUR"));
rule2 = rules.GetRuleFor(CurrencyPair.Parse("SATS_EUR"));
rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_EUR"), new BidAsk(1.23m, 2.34m));
Assert.True(rule2.Reevaluate());
Assert.Equal("0.00000001 * (1.23, 2.34)", rule2.ToString(true));
Assert.Equal(0.0000000234m, rule2.BidAsk.Ask);
Assert.Equal(0.0000000123m, rule2.BidAsk.Bid);
rule2 = rules.GetRuleFor(CurrencyPair.Parse("EUR_Sats"));
rule2 = rules.GetRuleFor(CurrencyPair.Parse("EUR_SATS"));
rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_EUR"), new BidAsk(1.23m, 2.34m));
Assert.True(rule2.Reevaluate());
Assert.Equal("1 / (0.00000001 * (1.23, 2.34))", rule2.ToString(true));
@ -1715,7 +1760,7 @@ namespace BTCPayServer.Tests
var networkProvider = new BTCPayNetworkProvider(ChainName.Regtest);
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
{
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null),
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null, null),
new LightningLikePaymentHandler(null, null, networkProvider, null, null, null),
});
var networkBTC = networkProvider.GetNetwork("BTC");

@ -3940,8 +3940,7 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
var withdrawalClient = await admin.CreateClient(Policies.CanWithdrawFromCustodianAccounts);
var depositClient = await admin.CreateClient(Policies.CanDepositToCustodianAccounts);
var tradeClient = await admin.CreateClient(Policies.CanTradeCustodianAccount);
var store = await adminClient.GetStore(admin.StoreId);
var storeId = store.Id;
@ -3981,22 +3980,22 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
// Test: GetDepositAddress, unauth
await AssertHttpError(401, async () => await unauthClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccountDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
await AssertHttpError(403, async () => await managerClient.GetCustodianAccountDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, wrong payment method
await AssertHttpError(400, async () => await depositClient.GetDepositAddress(storeId, accountId, "WRONG-PaymentMethod"));
await AssertApiError( 400, "unsupported-payment-method", async () => await depositClient.GetCustodianAccountDepositAddress(storeId, accountId, "WRONG-PaymentMethod"));
// Test: GetDepositAddress, wrong store ID
await AssertHttpError(403, async () => await depositClient.GetDepositAddress("WRONG-STORE", accountId, MockCustodian.DepositPaymentMethod));
await AssertHttpError(403, async () => await depositClient.GetCustodianAccountDepositAddress("WRONG-STORE", accountId, MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, wrong account ID
await AssertHttpError(404, async () => await depositClient.GetDepositAddress(storeId, "WRONG-ACCOUNT-ID", MockCustodian.DepositPaymentMethod));
await AssertHttpError(404, async () => await depositClient.GetCustodianAccountDepositAddress(storeId, "WRONG-ACCOUNT-ID", MockCustodian.DepositPaymentMethod));
// Test: GetDepositAddress, correct payment method
var depositAddress = await depositClient.GetDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod);
var depositAddress = await depositClient.GetCustodianAccountDepositAddress(storeId, accountId, MockCustodian.DepositPaymentMethod);
Assert.NotNull(depositAddress);
Assert.Equal(MockCustodian.DepositAddress, depositAddress.Address);
@ -4054,13 +4053,13 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
// Test: GetTradeQuote, unauth
await AssertHttpError(401, async () => await unauthClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
// Test: GetTradeQuote, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
await AssertHttpError(403, async () => await managerClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
// Test: GetTradeQuote, auth, correct permission
var tradeQuote = await tradeClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset);
var tradeQuote = await tradeClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset);
Assert.NotNull(tradeQuote);
Assert.Equal(MockCustodian.TradeFromAsset, tradeQuote.FromAsset);
Assert.Equal(MockCustodian.TradeToAsset, tradeQuote.ToAsset);
@ -4068,30 +4067,30 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
Assert.Equal(MockCustodian.BtcPriceInEuro, tradeQuote.Ask);
// Test: GetTradeQuote, SATS
await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, "SATS"));
await AssertApiError(400, "use-asset-synonym", async () => await tradeClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, "SATS"));
// Test: GetTradeQuote, wrong asset
await AssertHttpError(404, async () => await tradeClient.GetTradeQuote(storeId, accountId, "WRONG-ASSET", MockCustodian.TradeToAsset));
await AssertHttpError(404, async () => await tradeClient.GetTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset, "WRONG-ASSET"));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeQuote(storeId, accountId, "WRONG-ASSET", MockCustodian.TradeToAsset));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeQuote(storeId, accountId, MockCustodian.TradeFromAsset , "WRONG-ASSET"));
// Test: wrong account ID
await AssertHttpError(404, async () => await tradeClient.GetTradeQuote(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeQuote(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
// Test: wrong store ID
await AssertHttpError(403, async () => await tradeClient.GetTradeQuote("WRONG-STORE-ID", accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
await AssertHttpError(403, async () => await tradeClient.GetCustodianAccountTradeQuote("WRONG-STORE-ID", accountId, MockCustodian.TradeFromAsset, MockCustodian.TradeToAsset));
// Test: GetTradeInfo, unauth
await AssertHttpError(401, async () => await unauthClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId));
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccountTradeInfo(storeId, accountId, MockCustodian.TradeId));
// Test: GetTradeInfo, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId));
await AssertHttpError(403, async () => await managerClient.GetCustodianAccountTradeInfo(storeId, accountId, MockCustodian.TradeId));
// Test: GetTradeInfo, auth, correct permission
var tradeResult = await tradeClient.GetTradeInfo(storeId, accountId, MockCustodian.TradeId);
var tradeResult = await tradeClient.GetCustodianAccountTradeInfo(storeId, accountId, MockCustodian.TradeId);
Assert.NotNull(tradeResult);
Assert.Equal(accountId, tradeResult.AccountId);
Assert.Equal(mockCustodian.Code, tradeResult.CustodianCode);
@ -4111,66 +4110,93 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
Assert.Equal(LedgerEntryData.LedgerEntryType.Fee, tradeResult.LedgerEntries[2].Type);
// Test: GetTradeInfo, wrong trade ID
await AssertHttpError(404, async () => await tradeClient.GetTradeInfo(storeId, accountId, "WRONG-TRADE-ID"));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeInfo(storeId, accountId, "WRONG-TRADE-ID"));
// Test: wrong account ID
await AssertHttpError(404, async () => await tradeClient.GetTradeInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeId));
await AssertHttpError(404, async () => await tradeClient.GetCustodianAccountTradeInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.TradeId));
// Test: wrong store ID
await AssertHttpError(403, async () => await tradeClient.GetTradeInfo("WRONG-STORE-ID", accountId, MockCustodian.TradeId));
await AssertHttpError(403, async () => await tradeClient.GetCustodianAccountTradeInfo("WRONG-STORE-ID", accountId, MockCustodian.TradeId));
var qty = new TradeQuantity(MockCustodian.WithdrawalAmount, TradeQuantity.ValueType.Exact);
// Test: SimulateWithdrawal, unauth
var simulateWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, qty);
await AssertHttpError(401, async () => await unauthClient.SimulateCustodianAccountWithdrawal(storeId, accountId, simulateWithdrawalRequest));
// Test: SimulateWithdrawal, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.SimulateCustodianAccountWithdrawal(storeId, accountId, simulateWithdrawalRequest));
// Test: SimulateWithdrawal, correct payment method, correct amount
var simulateWithdrawResponse = await withdrawalClient.SimulateCustodianAccountWithdrawal(storeId, accountId, simulateWithdrawalRequest);
AssertMockWithdrawal(simulateWithdrawResponse, custodianAccountData);
// Test: SimulateWithdrawal, wrong payment method
var wrongPaymentMethodSimulateWithdrawalRequest = new WithdrawRequestData("WRONG-PAYMENT-METHOD", qty);
await AssertApiError( 400, "unsupported-payment-method", async () => await withdrawalClient.SimulateCustodianAccountWithdrawal(storeId, accountId, wrongPaymentMethodSimulateWithdrawalRequest));
// Test: SimulateWithdrawal, wrong account ID
await AssertHttpError(404, async () => await withdrawalClient.SimulateCustodianAccountWithdrawal(storeId, "WRONG-ACCOUNT-ID", simulateWithdrawalRequest));
// Test: SimulateWithdrawal, wrong store ID
// TODO it is wierd that 403 is considered normal, but it is like this for all calls where the store is wrong... I'd have preferred a 404 error, because the store cannot be found.
await AssertHttpError(403, async () => await withdrawalClient.SimulateCustodianAccountWithdrawal( "WRONG-STORE-ID",accountId, simulateWithdrawalRequest));
// Test: SimulateWithdrawal, correct payment method, wrong amount
var wrongAmountSimulateWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, TradeQuantity.Parse("0.666"));
await AssertHttpError(400, async () => await withdrawalClient.SimulateCustodianAccountWithdrawal(storeId, accountId, wrongAmountSimulateWithdrawalRequest));
// Test: CreateWithdrawal, unauth
var createWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalAmount);
await AssertHttpError(401, async () => await unauthClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest));
var createWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, qty);
var createWithdrawalRequestPercentage = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, qty);
await AssertHttpError(401, async () => await unauthClient.CreateCustodianAccountWithdrawal(storeId, accountId, createWithdrawalRequest));
// Test: CreateWithdrawal, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest));
await AssertHttpError(403, async () => await managerClient.CreateCustodianAccountWithdrawal(storeId, accountId, createWithdrawalRequest));
// Test: CreateWithdrawal, correct payment method, correct amount
var withdrawResponse = await withdrawalClient.CreateWithdrawal(storeId, accountId, createWithdrawalRequest);
var withdrawResponse = await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, accountId, createWithdrawalRequest);
AssertMockWithdrawal(withdrawResponse, custodianAccountData);
// Test: CreateWithdrawal, correct payment method, correct amount, but as a percentage
var withdrawWithPercentageResponse = await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, accountId, createWithdrawalRequestPercentage);
AssertMockWithdrawal(withdrawWithPercentageResponse, custodianAccountData);
// Test: CreateWithdrawal, wrong payment method
var wrongPaymentMethodCreateWithdrawalRequest = new WithdrawRequestData("WRONG-PAYMENT-METHOD", MockCustodian.WithdrawalAmount);
await AssertHttpError(403, async () => await withdrawalClient.CreateWithdrawal(storeId, accountId, wrongPaymentMethodCreateWithdrawalRequest));
var wrongPaymentMethodCreateWithdrawalRequest = new WithdrawRequestData("WRONG-PAYMENT-METHOD", qty);
await AssertApiError( 400, "unsupported-payment-method", async () => await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, accountId, wrongPaymentMethodCreateWithdrawalRequest));
// Test: CreateWithdrawal, wrong account ID
await AssertHttpError(404, async () => await withdrawalClient.CreateWithdrawal(storeId, "WRONG-ACCOUNT-ID", createWithdrawalRequest));
await AssertHttpError(404, async () => await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, "WRONG-ACCOUNT-ID", createWithdrawalRequest));
// Test: CreateWithdrawal, wrong store ID
// TODO it is wierd that 403 is considered normal, but it is like this for all calls where the store is wrong... I'd have preferred a 404 error, because the store cannot be found.
await AssertHttpError(403, async () => await withdrawalClient.CreateWithdrawal("WRONG-STORE-ID", accountId, createWithdrawalRequest));
await AssertHttpError(403, async () => await withdrawalClient.CreateCustodianAccountWithdrawal( "WRONG-STORE-ID",accountId, createWithdrawalRequest));
// Test: CreateWithdrawal, correct payment method, wrong amount
var wrongAmountCreateWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, new decimal(0.666));
await AssertHttpError(400, async () => await withdrawalClient.CreateWithdrawal(storeId, accountId, wrongAmountCreateWithdrawalRequest));
var wrongAmountCreateWithdrawalRequest = new WithdrawRequestData(MockCustodian.WithdrawalPaymentMethod, TradeQuantity.Parse("0.666"));
await AssertHttpError(400, async () => await withdrawalClient.CreateCustodianAccountWithdrawal(storeId, accountId, wrongAmountCreateWithdrawalRequest));
// Test: GetWithdrawalInfo, unauth
await AssertHttpError(401, async () => await unauthClient.GetWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
await AssertHttpError(401, async () => await unauthClient.GetCustodianAccountWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
// Test: GetWithdrawalInfo, auth, but wrong permission
await AssertHttpError(403, async () => await managerClient.GetWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
await AssertHttpError(403, async () => await managerClient.GetCustodianAccountWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
// Test: GetWithdrawalInfo, auth, correct permission
var withdrawalInfo = await withdrawalClient.GetWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId);
var withdrawalInfo = await withdrawalClient.GetCustodianAccountWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId);
AssertMockWithdrawal(withdrawalInfo, custodianAccountData);
// Test: GetWithdrawalInfo, wrong withdrawal ID
await AssertHttpError(404, async () => await withdrawalClient.GetWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, "WRONG-WITHDRAWAL-ID"));
await AssertHttpError(404, async () => await withdrawalClient.GetCustodianAccountWithdrawalInfo(storeId, accountId, MockCustodian.WithdrawalPaymentMethod, "WRONG-WITHDRAWAL-ID"));
// Test: wrong account ID
await AssertHttpError(404, async () => await withdrawalClient.GetWithdrawalInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
await AssertHttpError(404, async () => await withdrawalClient.GetCustodianAccountWithdrawalInfo(storeId, "WRONG-ACCOUNT-ID", MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
// Test: wrong store ID
// TODO shouldn't this be 404? I cannot change this without bigger impact, as it would affect all API endpoints that are store centered
await AssertHttpError(403, async () => await withdrawalClient.GetWithdrawalInfo("WRONG-STORE-ID", accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
await AssertHttpError(403, async () => await withdrawalClient.GetCustodianAccountWithdrawalInfo("WRONG-STORE-ID", accountId, MockCustodian.WithdrawalPaymentMethod, MockCustodian.WithdrawalId));
// TODO assert API error codes, not just status codes by using AssertCustodianApiError()
// TODO also test withdrawals for the various "Status" (Queued, Complete, Failed)
// TODO create a mock custodian with only ICustodian
// TODO create a mock custodian with only ICustodian + ICanWithdraw
@ -4178,12 +4204,11 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
// TODO create a mock custodian with only ICustodian + ICanDeposit
}
private void AssertMockWithdrawal(WithdrawalResponseData withdrawResponse, CustodianAccountData account)
private void AssertMockWithdrawal(WithdrawalBaseResponseData withdrawResponse, CustodianAccountData account)
{
Assert.NotNull(withdrawResponse);
Assert.Equal(MockCustodian.WithdrawalAsset, withdrawResponse.Asset);
Assert.Equal(MockCustodian.WithdrawalPaymentMethod, withdrawResponse.PaymentMethod);
Assert.Equal(MockCustodian.WithdrawalStatus, withdrawResponse.Status);
Assert.Equal(account.Id, withdrawResponse.AccountId);
Assert.Equal(account.CustodianCode, withdrawResponse.CustodianCode);
@ -4197,10 +4222,20 @@ clientBasic.PreviewUpdateStoreRateConfiguration(user.StoreId, new StoreRateConfi
Assert.Equal(MockCustodian.WithdrawalFee, withdrawResponse.LedgerEntries[1].Qty);
Assert.Equal(LedgerEntryData.LedgerEntryType.Fee, withdrawResponse.LedgerEntries[1].Type);
Assert.Equal(MockCustodian.WithdrawalTargetAddress, withdrawResponse.TargetAddress);
Assert.Equal(MockCustodian.WithdrawalTransactionId, withdrawResponse.TransactionId);
Assert.Equal(MockCustodian.WithdrawalId, withdrawResponse.WithdrawalId);
Assert.NotEqual(default, withdrawResponse.CreatedTime);
if (withdrawResponse is WithdrawalResponseData withdrawalResponseData)
{
Assert.Equal(MockCustodian.WithdrawalStatus, withdrawalResponseData.Status);
Assert.Equal(MockCustodian.WithdrawalTargetAddress, withdrawalResponseData.TargetAddress);
Assert.Equal(MockCustodian.WithdrawalTransactionId, withdrawalResponseData.TransactionId);
Assert.Equal(MockCustodian.WithdrawalId, withdrawalResponseData.WithdrawalId);
Assert.NotEqual(default, withdrawalResponseData.CreatedTime);
}
if (withdrawResponse is WithdrawalSimulationResponseData withdrawalSimulationResponseData)
{
Assert.Equal(MockCustodian.WithdrawalMinAmount, withdrawalSimulationResponseData.MinQty);
Assert.Equal(MockCustodian.WithdrawalMaxAmount, withdrawalSimulationResponseData.MaxQty);
}
}
}
}

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians;
@ -24,6 +25,9 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
public const string WithdrawalAsset = "BTC";
public const string WithdrawalId = "WITHDRAWAL-ID-001";
public static readonly decimal WithdrawalAmount = new decimal(0.5);
public static readonly string WithdrawalAmountPercentage = "12.5%";
public static readonly decimal WithdrawalMinAmount = new decimal(0.001);
public static readonly decimal WithdrawalMaxAmount = new decimal(0.6);
public static readonly decimal WithdrawalFee = new decimal(0.0005);
public const string WithdrawalTransactionId = "yyy";
public const string WithdrawalTargetAddress = "bc1qyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy";
@ -52,7 +56,7 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
return Task.FromResult(r);
}
public Task<Form> GetConfigForm(JObject config, string locale, CancellationToken cancellationToken = default)
public Task<Form> GetConfigForm(CancellationToken cancellationToken = default)
{
return null;
}
@ -135,14 +139,38 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
var r = new WithdrawResult(WithdrawalPaymentMethod, WithdrawalAsset, ledgerEntries, WithdrawalId, WithdrawalStatus, createdTime, WithdrawalTargetAddress, WithdrawalTransactionId);
return r;
}
private SimulateWithdrawalResult CreateWithdrawSimulationResult()
{
var ledgerEntries = new List<LedgerEntryData>();
ledgerEntries.Add(new LedgerEntryData(WithdrawalAsset, WithdrawalAmount - WithdrawalFee, LedgerEntryData.LedgerEntryType.Withdrawal));
ledgerEntries.Add(new LedgerEntryData(WithdrawalAsset, WithdrawalFee, LedgerEntryData.LedgerEntryType.Fee));
var r = new SimulateWithdrawalResult(WithdrawalPaymentMethod, WithdrawalAsset, ledgerEntries, WithdrawalMinAmount, WithdrawalMaxAmount);
return r;
}
public Task<WithdrawResult> WithdrawAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken)
public Task<WithdrawResult> WithdrawToStoreWalletAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken)
{
if (paymentMethod == WithdrawalPaymentMethod)
{
if (amount.ToString(CultureInfo.InvariantCulture).Equals(""+WithdrawalAmount, StringComparison.InvariantCulture) || WithdrawalAmountPercentage.Equals(amount))
{
return Task.FromResult(CreateWithdrawResult());
}
throw new InsufficientFundsException($"{Name} only supports withdrawals of {WithdrawalAmount} or {WithdrawalAmountPercentage}");
}
throw new CannotWithdrawException(this, paymentMethod, $"Only {WithdrawalPaymentMethod} can be withdrawn from {Name}");
}
public Task<SimulateWithdrawalResult> SimulateWithdrawalAsync(string paymentMethod, decimal amount, JObject config, CancellationToken cancellationToken)
{
if (paymentMethod == WithdrawalPaymentMethod)
{
if (amount == WithdrawalAmount)
{
return Task.FromResult(CreateWithdrawResult());
return Task.FromResult(CreateWithdrawSimulationResult());
}
throw new InsufficientFundsException($"{Name} only supports withdrawals of {WithdrawalAmount}");

@ -2,10 +2,9 @@ using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc;
using Xunit;
using Xunit.Abstractions;
@ -32,10 +31,11 @@ namespace BTCPayServer.Tests
var apps = user.GetController<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
var appType = PointOfSaleAppType.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };

@ -221,7 +221,7 @@ namespace BTCPayServer.Tests
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
string errorCode = receiverAddressType == senderAddressType ? null : "unavailable|any UTXO available";
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "sats", FullNotifications = true });
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() { Price = 50000, Currency = "SATS", FullNotifications = true });
if (unsupportedFormats.Contains(receiverAddressType))
{
Assert.Null(TestAccount.GetPayjoinBitcoinUrl(invoice, cashCow.Network));

@ -4,11 +4,13 @@ using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection.Metadata;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
@ -578,8 +580,7 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("invoice-processing", s.Driver.PageSource);
});
Assert.Contains(s.Server.PayTester.GetService<CurrencyNameTable>().DisplayFormatCurrency(100, "USD"),
s.Driver.PageSource);
Assert.Contains("100.00 USD", s.Driver.PageSource);
Assert.Contains(i, s.Driver.PageSource);
s.GoToInvoices(s.StoreId);
@ -832,6 +833,105 @@ namespace BTCPayServer.Tests
AssertUrlHasPairingCode(s);
}
[Fact(Timeout = TestTimeout)]
public async Task CookieReflectProperPermissions()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
var alice = s.Server.NewAccount();
alice.Register(false);
await alice.CreateStoreAsync();
var bob = s.Server.NewAccount();
await bob.CreateStoreAsync();
await bob.AddGuest(alice.UserId);
s.GoToLogin();
s.LogIn(alice.Email, alice.Password);
s.GoToUrl($"/cheat/permissions/stores/{bob.StoreId}");
var pageSource = s.Driver.PageSource;
AssertPermissions(pageSource, true,
new[]
{
Policies.CanViewInvoices,
Policies.CanModifyInvoices,
Policies.CanViewPaymentRequests,
Policies.CanViewStoreSettings,
Policies.CanModifyStoreSettingsUnscoped,
Policies.CanDeleteUser
});
AssertPermissions(pageSource, false,
new[]
{
Policies.CanModifyStoreSettings,
Policies.CanCreateNonApprovedPullPayments,
Policies.CanCreatePullPayments,
Policies.CanManagePullPayments,
Policies.CanModifyServerSettings
});
s.GoToUrl($"/cheat/permissions/stores/{alice.StoreId}");
pageSource = s.Driver.PageSource;
AssertPermissions(pageSource, true,
new[]
{
Policies.CanViewInvoices,
Policies.CanModifyInvoices,
Policies.CanViewPaymentRequests,
Policies.CanViewStoreSettings,
Policies.CanModifyStoreSettingsUnscoped,
Policies.CanDeleteUser,
Policies.CanModifyStoreSettings,
Policies.CanCreateNonApprovedPullPayments,
Policies.CanCreatePullPayments,
Policies.CanManagePullPayments
});
AssertPermissions(pageSource, false,
new[]
{
Policies.CanModifyServerSettings
});
await alice.MakeAdmin();
s.Logout();
s.GoToLogin();
s.LogIn(alice.Email, alice.Password);
s.GoToUrl($"/cheat/permissions/stores/{alice.StoreId}");
pageSource = s.Driver.PageSource;
AssertPermissions(pageSource, true,
new[]
{
Policies.CanViewInvoices,
Policies.CanModifyInvoices,
Policies.CanViewPaymentRequests,
Policies.CanViewStoreSettings,
Policies.CanModifyStoreSettingsUnscoped,
Policies.CanDeleteUser,
Policies.CanModifyStoreSettings,
Policies.CanCreateNonApprovedPullPayments,
Policies.CanCreatePullPayments,
Policies.CanManagePullPayments,
Policies.CanModifyServerSettings,
Policies.CanCreateUser,
Policies.CanManageUsers
});
}
void AssertPermissions(string source, bool expected, string[] permissions)
{
if (expected)
{
foreach (var p in permissions)
Assert.Contains(p + "<", source);
}
else
{
foreach (var p in permissions)
Assert.DoesNotContain(p + "<", source);
}
}
[Fact(Timeout = TestTimeout)]
public async Task CanCreateAppPoS()
{
@ -1274,11 +1374,11 @@ namespace BTCPayServer.Tests
// Can add a label?
await TestUtils.EventuallyAsync(async () =>
{
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).Click();
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).Click();
await Task.Delay(500);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("test-label" + Keys.Enter);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("test-label" + Keys.Enter);
await Task.Delay(500);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("label2" + Keys.Enter);
s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("label2" + Keys.Enter);
});
TestUtils.Eventually(() =>
@ -1470,9 +1570,20 @@ namespace BTCPayServer.Tests
Assert.Contains("\"Amount\": \"3.00000000\"", s.Driver.PageSource);
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// BIP-329 export
s.Driver.FindElement(By.Id("ExportDropdownToggle")).Click();
s.Driver.FindElement(By.Id("ExportBIP329")).Click();
Thread.Sleep(1000);
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
Assert.Contains(s.WalletId.ToString(), s.Driver.Url);
Assert.EndsWith("export?format=bip329", s.Driver.Url);
Assert.Contains("{\"type\":\"tx\",\"ref\":\"", s.Driver.PageSource);
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// CSV export
s.Driver.FindElement(By.Id("ExportDropdownToggle")).Click();
s.Driver.FindElement(By.Id("ExportCSV")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
}
[Fact(Timeout = TestTimeout)]
@ -1619,7 +1730,10 @@ namespace BTCPayServer.Tests
s.Driver.Navigate().Refresh();
Assert.Contains("transaction-label", s.Driver.PageSource);
});
Assert.Equal("payout", s.Driver.FindElement(By.ClassName("transaction-label")).Text);
var labels = s.Driver.FindElements(By.CssSelector("#WalletTransactionsList tr:first-child div.transaction-label"));
Assert.Equal(2, labels.Count);
Assert.Contains(labels, element => element.Text == "payout");
Assert.Contains(labels, element => element.Text == "pull-payment");
s.GoToStore(s.StoreId, StoreNavPages.Payouts);
s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click();

@ -220,8 +220,8 @@ namespace BTCPayServer.Tests
RegisterDetails = new RegisterViewModel()
{
Email = Utils.GenerateEmail(),
ConfirmPassword = "Kitten0@",
Password = "Kitten0@",
ConfirmPassword = Password,
Password = Password,
IsAdmin = isAdmin
};
await account.Register(RegisterDetails);
@ -240,6 +240,7 @@ namespace BTCPayServer.Tests
Email = RegisterDetails.Email;
IsAdmin = account.RegisteredAdmin;
}
public string Password { get; set; } = "Kitten0@";
public RegisterViewModel RegisterDetails { get; set; }

@ -353,6 +353,11 @@ retry:
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "tom-select", "tom-select.complete.min.js").Trim();
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "dom-confetti", "dom-confetti.min.js").Trim();
version = Regex.Match(actual, "Original file: /npm/dom-confetti@([0-9]+.[0-9]+.[0-9]+)/lib/main.js").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/dom-confetti@{version}/lib/main.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
}
string GetFileContent(params string[] path)

@ -35,6 +35,8 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Payments.PayJoin.Sender;
using BTCPayServer.Plugins.PayButton;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services;
@ -1953,14 +1955,13 @@ namespace BTCPayServer.Tests
var apps = user.GetController<UIAppsController>();
var apps2 = user2.GetController<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
Assert.NotNull(vm.SelectedAppType);
var appType = PointOfSaleAppType.AppType;
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId, appType)).Model);
Assert.Equal(appType, vm.SelectedAppType);
Assert.Null(vm.AppName);
vm.AppName = "test";
vm.SelectedAppType = appType;
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.Equal(nameof(pos.UpdatePointOfSale), redirectToAction.ActionName);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var appList2 =
Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps2.ListApps(user2.StoreId).Result).Model);
@ -1976,7 +1977,7 @@ namespace BTCPayServer.Tests
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));
redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
Assert.Equal(nameof(stores.Dashboard), redirectToAction.ActionName);
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
Assert.Empty(appList.Apps);

@ -14,6 +14,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using ExchangeSharp;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
@ -72,19 +73,24 @@ namespace BTCPayServer.Tests
// // DO NOT RUN IT, THIS WILL ERASE THE CURRENT TRANSIFEX TRANSLATIONS
// var client = GetTransifexClient();
// var translations = JsonTranslation.GetTranslations(TranslationFolder.CheckoutV1);
// var translations = JsonTranslation.GetTranslations(TranslationFolder.CheckoutV2);
// var enTranslations = translations["en"];
// translations.Remove("en");
// foreach (var t in translations)
// {
// foreach (var w in t.Value.Words.ToArray())
// {
// if (w.Value == enTranslations.Words[w.Key])
// t.Value.Words[w.Key] = null;
// if (t.Value.Words[w.Key] == null)
// t.Value.Words[w.Key] = enTranslations.Words[w.Key];
// }
// t.Value.Words.Remove("code");
// t.Value.Words.Remove("NOTICE_WARN");
// }
// await client.UpdateTranslations(translations);
// }
//#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
//#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
///// <summary>
///// This utility will copy translations made on checkout v1 to checkout v2
@ -245,7 +251,6 @@ retry:
{
// 1. Generate an API Token on https://www.transifex.com/user/settings/api/
// 2. Run "dotnet user-secrets set TransifexAPIToken <youapitoken>"
await PullTransifexTranslationsCore(TranslationFolder.CheckoutV1);
await PullTransifexTranslationsCore(TranslationFolder.CheckoutV2);
@ -282,6 +287,7 @@ retry:
{
translation.Words["InvoiceExpired_Body_3"] = string.Empty;
}
translation.Translate(langTranslations);
translation.Save();
}
catch

@ -53,7 +53,7 @@
<PackageReference Include="Fido2" Version="2.0.2" />
<PackageReference Include="Fido2.AspNet" Version="2.0.2" />
<PackageReference Include="HtmlSanitizer" Version="5.0.372" />
<PackageReference Include="LNURL" Version="0.0.28" />
<PackageReference Include="LNURL" Version="0.0.29" />
<PackageReference Include="MailKit" Version="3.3.0" />
<PackageReference Include="BTCPayServer.NETCore.Plugins.Mvc" Version="1.4.4" />
<PackageReference Include="QRCoder" Version="1.4.3" />
@ -139,6 +139,7 @@
<ItemGroup>
<Watch Include="Views\**\*.*"></Watch>
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Content Update="Views\UIApps\_ViewImports.cshtml">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<Pack>$(IncludeRazorContentInPack)</Pack>

@ -19,7 +19,7 @@ namespace BTCPayServer
var bg = ColorTranslator.FromHtml(bgColor);
int bgDelta = Convert.ToInt32((bg.R * 0.299) + (bg.G * 0.587) + (bg.B * 0.114));
Color color = (255 - bgDelta < nThreshold) ? Color.Black : Color.White;
return ColorTranslator.ToHtml(color);
return ColorTranslator.ToHtml(color).ToLowerInvariant();
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
public static readonly ColorPalette Default = new ColorPalette(new string[] {
@ -59,5 +59,44 @@ namespace BTCPayServer
return Labels[num % Labels.Length];
}
}
/// https://gist.github.com/zihotki/09fc41d52981fb6f93a81ebf20b35cd5
/// <summary>
/// Creates color with corrected brightness.
/// </summary>
/// <param name="color">Color to correct.</param>
/// <param name="correctionFactor">The brightness correction factor. Must be between -1 and 1.
/// Negative values produce darker colors.</param>
/// <returns>
/// Corrected <see cref="Color"/> structure.
/// </returns>
public Color AdjustBrightness(Color color, float correctionFactor)
{
float red = color.R;
float green = color.G;
float blue = color.B;
if (correctionFactor < 0)
{
correctionFactor = 1 + correctionFactor;
red *= correctionFactor;
green *= correctionFactor;
blue *= correctionFactor;
}
else
{
red = (255 - red) * correctionFactor + red;
green = (255 - green) * correctionFactor + green;
blue = (255 - blue) * correctionFactor + blue;
}
return Color.FromArgb(color.A, (int)red, (int)green, (int)blue);
}
public string AdjustBrightness(string html, float correctionFactor)
{
var color = AdjustBrightness(ColorTranslator.FromHtml(html), correctionFactor);
return ColorTranslator.ToHtml(color);
}
}
}

@ -1,4 +1,5 @@
using System;
using System.Security.AccessControl;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
@ -6,6 +7,8 @@ using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
namespace BTCPayServer.Components.AppSales;
@ -24,17 +27,28 @@ public class AppSales : ViewComponent
_appService = appService;
}
public async Task<IViewComponentResult> InvokeAsync(AppSalesViewModel vm)
public async Task<IViewComponentResult> InvokeAsync(string appId, string appType)
{
if (vm.App == null)
throw new ArgumentNullException(nameof(vm.App));
var type = _appService.GetAppType(appType);
if (type is not IHasSaleStatsAppType salesAppType || type is not AppBaseType appBaseType)
return new HtmlContentViewComponentResult(new StringHtmlContent(string.Empty));
var vm = new AppSalesViewModel
{
Id = appId,
AppType = appType,
DataUrl = Url.Action("AppSales", "UIApps", new { appId }),
InitialRendering = HttpContext.GetAppData()?.Id != appId
};
if (vm.InitialRendering)
return View(vm);
var stats = await _appService.GetSalesStats(vm.App);
var app = HttpContext.GetAppData();
var stats = await _appService.GetSalesStats(app);
vm.SalesCount = stats.SalesCount;
vm.Series = stats.Series;
vm.AppType = app.AppType;
vm.AppUrl = await appBaseType.ConfigureLink(app);
vm.Name = app.Name;
return View(vm);
}

@ -1,14 +1,17 @@
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.AppSales;
public class AppSalesViewModel
{
public AppData App { get; set; }
public AppSalesPeriod Period { get; set; } = AppSalesPeriod.Week;
public int SalesCount { get; set; }
public string Id { get; set; }
public string Name { get; set; }
public string AppType { get; set; }
public AppSalesPeriod Period { get; set; }
public string AppUrl { get; set; }
public string DataUrl { get; set; }
public long SalesCount { get; set; }
public IEnumerable<SalesStatsItem> Series { get; set; }
public bool InitialRendering { get; set; }
}

@ -1,17 +1,18 @@
@using BTCPayServer.Services.Apps
@using BTCPayServer.Components.AppSales
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Plugins.Crowdfund
@model BTCPayServer.Components.AppSales.AppSalesViewModel
@{
var controller = $"UI{Model.App.AppType}";
var action = $"Update{Model.App.AppType}";
var label = Model.App.AppType == nameof(AppType.Crowdfund) ? "Contributions" : "Sales";
var label = Model.AppType == CrowdfundAppType.AppType ? "Contributions" : "Sales";
}
<div id="AppSales-@Model.App.Id" class="widget app-sales">
<div id="AppSales-@Model.Id" class="widget app-sales">
<header class="mb-3">
<h3>@Model.App.Name @label</h3>
<a asp-controller="@controller" asp-action="@action" asp-route-appId="@Model.App.Id">Manage</a>
<h3>@Model.Name @label</h3>
@if (!string.IsNullOrEmpty(Model.AppUrl))
{
<a href="@Model.AppUrl">Manage</a>
}
</header>
@if (Model.InitialRendering)
{
@ -20,15 +21,16 @@
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script src="~/Components/AppSales/Default.cshtml.js" asp-append-version="true"></script>
<script>
(async () => {
const url = @Safe.Json(Url.Action("AppSales", "UIApps", new { appId = Model.App.Id }));
const appId = @Safe.Json(Model.App.Id);
const url = @Safe.Json(Model.DataUrl);
const appId = @Safe.Json(Model.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`AppSales-${appId}`).outerHTML = await response.text();
const initScript = document.querySelector(`#AppSales-${appId} script`);
if (initScript) eval(initScript.innerHTML);
const data = document.querySelector(`#AppSales-${appId} template`);
if (data) window.appSales.dataLoaded(JSON.parse(data.innerHTML));
}
})();
</script>
@ -40,54 +42,15 @@
<span class="sales-count">@Model.SalesCount</span> Total @label
</span>
<div class="btn-group only-for-js" role="group" aria-label="Filter">
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.App.Id" id="AppSalesPeriodWeek-@Model.App.Id" value="@AppSalesPeriod.Week" @(Model.Period == AppSalesPeriod.Week ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodWeek-@Model.App.Id">1W</label>
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.App.Id" id="AppSalesPeriodMonth-@Model.App.Id" value="@AppSalesPeriod.Month" @(Model.Period == AppSalesPeriod.Month ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodMonth-@Model.App.Id">1M</label>
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.Id" id="AppSalesPeriodWeek-@Model.Id" value="@AppSalesPeriod.Week" @(Model.Period == AppSalesPeriod.Week ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodWeek-@Model.Id">1W</label>
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.Id" id="AppSalesPeriodMonth-@Model.Id" value="@AppSalesPeriod.Month" @(Model.Period == AppSalesPeriod.Month ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodMonth-@Model.Id">1M</label>
</div>
</header>
<div class="ct-chart"></div>
<script>
(function () {
const id = @Safe.Json($"AppSales-{Model.App.Id}");
const appId = @Safe.Json(Model.App.Id);
const period = @Safe.Json(Model.Period.ToString());
const baseUrl = @Safe.Json(Url.Action("AppSales", "UIApps", new { appId = Model.App.Id }));
const data = { series: @Safe.Json(Model.Series), salesCount: @Safe.Json(Model.SalesCount) };
const render = (data, period) => {
const series = data.series.map(s => s.salesCount);
const labels = data.series.map((s, i) => period === @Safe.Json(Model.Period.ToString()) ? s.label : (i % 5 === 0 ? s.label : ''));
const min = Math.min(...series);
const max = Math.max(...series);
const low = min === max ? 0 : Math.max(min - ((max - min) / 5), 0);
document.querySelectorAll(`#${id} .sales-count`).innerText = data.salesCount;
new Chartist.Bar(`#${id} .ct-chart`, {
labels,
series: [series]
}, {
low,
});
};
render(data, period);
const update = async period => {
const url = `${baseUrl}/${period}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
render(data, period);
}
};
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => {
const type = e.target.value;
await update(type);
});
})();
</script>
<template>
@Safe.Json(Model)
</template>
}
</div>

@ -0,0 +1,45 @@
if (!window.appSales) {
window.appSales =
{
dataLoaded: function (model) {
const id = "AppSales-" + model.id;
const appId = model.id;
const period = model.period;
const baseUrl = model.url;
const data = model;
const render = (data, period) => {
const series = data.series.map(s => s.salesCount);
const labels = data.series.map((s, i) => period === model.period ? s.label : (i % 5 === 0 ? s.label : ''));
const min = Math.min(...series);
const max = Math.max(...series);
const low = min === max ? 0 : Math.max(min - ((max - min) / 5), 0);
document.querySelectorAll(`#${id} .sales-count`).innerText = data.salesCount;
new Chartist.Bar(`#${id} .ct-chart`, {
labels,
series: [series]
}, {
low,
});
};
render(data, period);
const update = async period => {
const url = `${baseUrl}/${period}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
render(data, period);
}
};
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => {
const type = e.target.value;
await update(type);
});
}
};
}

@ -1,11 +1,14 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Components.AppSales;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewComponents;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
namespace BTCPayServer.Components.AppTopItems;
@ -18,18 +21,29 @@ public class AppTopItems : ViewComponent
_appService = appService;
}
public async Task<IViewComponentResult> InvokeAsync(AppTopItemsViewModel vm)
public async Task<IViewComponentResult> InvokeAsync(string appId, string appType)
{
if (vm.App == null)
throw new ArgumentNullException(nameof(vm.App));
var type = _appService.GetAppType(appType);
if (type is not IHasItemStatsAppType salesAppType || type is not AppBaseType appBaseType)
return new HtmlContentViewComponentResult(new StringHtmlContent(string.Empty));
var vm = new AppTopItemsViewModel
{
Id = appId,
AppType = appType,
DataUrl = Url.Action("AppTopItems", "UIApps", new { appId }),
InitialRendering = HttpContext.GetAppData()?.Id != appId
};
if (vm.InitialRendering)
return View(vm);
var entries = Enum.Parse<AppType>(vm.App.AppType) == AppType.Crowdfund
? await _appService.GetPerkStats(vm.App)
: await _appService.GetItemStats(vm.App);
var app = HttpContext.GetAppData();
var entries = await _appService.GetItemStats(app);
vm.SalesCount = entries.Select(e => e.SalesCount).ToList();
vm.Entries = entries.ToList();
vm.AppType = app.AppType;
vm.AppUrl = await appBaseType.ConfigureLink(app);
vm.Name = app.Name;
return View(vm);
}

@ -1,12 +1,16 @@
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.AppTopItems;
public class AppTopItemsViewModel
{
public AppData App { get; set; }
public string Id { get; set; }
public string Name { get; set; }
public string AppType { get; set; }
public string AppUrl { get; set; }
public string DataUrl { get; set; }
public List<ItemStats> Entries { get; set; }
public List<int> SalesCount { get; set; }
public bool InitialRendering { get; set; }
}

@ -1,16 +1,16 @@
@using BTCPayServer.Services.Apps
@using BTCPayServer.Plugins.Crowdfund
@model BTCPayServer.Components.AppTopItems.AppTopItemsViewModel
@{
var controller = $"UI{Model.App.AppType}";
var action = $"Update{Model.App.AppType}";
var label = Model.App.AppType == nameof(AppType.Crowdfund) ? "contribution" : "sale";
var label = Model.AppType == CrowdfundAppType.AppType ? "contribution" : "sale";
}
<div id="AppTopItems-@Model.App.Id" class="widget app-top-items">
<div id="AppTopItems-@Model.Id" class="widget app-top-items">
<header class="mb-3">
<h3>Top @(Model.App.AppType == nameof(AppType.Crowdfund) ? "Perks" : "Items")</h3>
<a asp-controller="@controller" asp-action="@action" asp-route-appId="@Model.App.Id">View All</a>
<h3>Top @(Model.AppType == CrowdfundAppType.AppType ? "Perks" : "Items")</h3>
@if (!string.IsNullOrEmpty(Model.AppUrl))
{
<a href="@Model.AppUrl">View All</a>
}
</header>
@if (Model.InitialRendering)
{
@ -19,37 +19,26 @@
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script src="~/Components/AppTopItems/Default.cshtml.js" asp-append-version="true"></script>
<script>
(async () => {
const url = @Safe.Json(Url.Action("AppTopItems", "UIApps", new { appId = Model.App.Id }));
const appId = @Safe.Json(Model.App.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`AppTopItems-${appId}`).outerHTML = await response.text();
const initScript = document.querySelector(`#AppTopItems-${appId} script`);
if (initScript) eval(initScript.innerHTML);
}
})();
</script>
(async () => {
const url = @Safe.Json(Model.DataUrl);
const appId = @Safe.Json(Model.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`AppTopItems-${appId}`).outerHTML = await response.text();
const data = document.querySelector(`#AppSales-${appId} template`);
if (data) window.appSales.dataLoaded(JSON.parse(data.innerHTML));
}
})();
</script>
}
else if (Model.Entries.Any())
{
<div class="ct-chart mb-3"></div>
<script>
(function () {
const id = @Safe.Json($"AppTopItems-{Model.App.Id}");
const series = @Safe.Json(Model.Entries.Select(i => i.SalesCount));
new Chartist.Bar(`#${id} .ct-chart`, { series }, {
distributeSeries: true,
horizontalBars: true,
showLabel: false,
stackBars: true,
axisY: {
offset: 0
}
});
})();
</script>
<template>
@Safe.Json(Model)
</template>
<div class="app-items">
@for (var i = 0; i < Model.Entries.Count; i++)
{

@ -0,0 +1,18 @@
if (!window.appTopItems) {
window.appTopItems =
{
dataLoaded: function (model) {
const id = "AppTopItems-" + model.id;
const series = model.salesCount;
new Chartist.Bar(`#${id} .ct-chart`, { series }, {
distributeSeries: true,
horizontalBars: true,
showLabel: false,
stackBars: true,
axisY: {
offset: 0
}
});
}
};
}

@ -1,104 +1,22 @@
@using NBitcoin.DataEncoders
@using NBitcoin
@using BTCPayServer.Abstractions.TagHelpers
@model BTCPayServer.Components.LabelManager.LabelViewModel
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery
@{
var commonCall = Model.ObjectId.Type + Model.ObjectId.Id;
var elementId = "a" + Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
var fetchUrl = Url.Action("GetLabels", "UIWallets", new {
walletId = Model.WalletObjectId.WalletId,
excludeTypes = Safe.Json(Model.ExcludeTypes)
});
var updateUrl = Model.AutoUpdate? Url.Action("UpdateLabels", "UIWallets", new {
walletId = Model.WalletObjectId.WalletId
}): string.Empty;
}
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" rel="stylesheet">
<script src="~/vendor/tom-select/tom-select.complete.min.js"></script>
<script>
const updateUrl = @Safe.Json(Url.Action("UpdateLabels", "UIWallets", new {
Model.ObjectId.WalletId
}));
const getUrl = @Safe.Json(@Url.Action("GetLabels", "UIWallets", new {
walletId = Model.ObjectId.WalletId,
excludeTypes = true
}));
const commonCall = @Safe.Json(commonCall);
const elementId = @Safe.Json(elementId);
if (!window[commonCall]) {
window[commonCall] = fetch(getUrl, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
}).then(response => {
return response.json();
});
}
document.addEventListener("DOMContentLoaded", async () => {
const element = document.querySelector(`#${elementId}`);
if (element) {
const labelsFetchTask = await window[commonCall];
const config = {
create: true,
items: @Safe.Json(Model.SelectedLabels),
options: labelsFetchTask,
valueField: "label",
labelField: "label",
searchField: "label",
allowEmptyOption: false,
closeAfterSelect: false,
persist: true,
render: {
option: function(data, escape) {
return `<div ${data.color? `style='background-color:${data.color}; color:${data.textColor}'`: ""}>${escape(data.label)}</div>`;
},
item: function(data, escape) {
return `<div ${data.color? `style='background-color:${data.color}; color:${data.textColor}'`: ""}>${escape(data.label)}</div>`;
}
},
onItemAdd: (val) => {
window[commonCall] = window[commonCall].then(labels => {
return [...labels, { label: val }]
});
document.dispatchEvent(new CustomEvent(`${commonCall}-option-added`, {
detail: val
}));
},
onChange: async (values) => {
select.lock();
try {
const response = await fetch(updateUrl, {
method: "POST",
credentials: "include",
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
address: @Safe.Json(Model.ObjectId.Id),
labels: select.items
})
});
if (!response.ok) {
throw new Error('Network response was not OK');
}
} catch (error) {
console.error('There has been a problem with your fetch operation:', error);
} finally {
select.unlock();
}
}
};
const select = new TomSelect(element, config);
document.addEventListener(`${commonCall}-option-added`, evt => {
if (!(evt.detail in select.options)) {
select.addOption({
label: evt.detail
})
}
})
}
})
</script>
<input id="@elementId" placeholder="Select labels to associate with this object" autocomplete="off" class="form-control label-manager"/>
<input id="@elementId" placeholder="Select labels" autocomplete="off" value="@string.Join(",", Model.SelectedLabels)"
class="only-for-js form-control label-manager ts-wrapper @(Model.DisplayInline ? "ts-inline" : "")"
data-fetch-url="@fetchUrl"
data-update-url="@updateUrl"
data-wallet-id="@Model.WalletObjectId.WalletId"
data-wallet-object-id="@Model.WalletObjectId.Id"
data-wallet-object-type="@Model.WalletObjectId.Type"
data-select-element="@Model.SelectElement"
data-labels='@Safe.Json(Model.RichLabelInfo)' />

@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;
@ -5,14 +7,25 @@ namespace BTCPayServer.Components.LabelManager
{
public class LabelManager : ViewComponent
{
public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels)
public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels, bool excludeTypes = true, bool displayInline = false, Dictionary<string, RichLabelInfo> richLabelInfo = null, bool autoUpdate = true, string selectElement = null)
{
var vm = new LabelViewModel
{
ObjectId = walletObjectId,
SelectedLabels = selectedLabels
ExcludeTypes = excludeTypes,
WalletObjectId = walletObjectId,
SelectedLabels = selectedLabels?? Array.Empty<string>(),
DisplayInline = displayInline,
RichLabelInfo = richLabelInfo,
AutoUpdate = autoUpdate,
SelectElement = selectElement
};
return View(vm);
}
}
public class RichLabelInfo
{
public string Link { get; set; }
public string Tooltip { get; set; }
}
}

@ -1,3 +1,4 @@
using System.Collections.Generic;
using BTCPayServer.Services;
namespace BTCPayServer.Components.LabelManager
@ -5,6 +6,11 @@ namespace BTCPayServer.Components.LabelManager
public class LabelViewModel
{
public string[] SelectedLabels { get; set; }
public WalletObjectId ObjectId { get; set; }
public WalletObjectId WalletObjectId { get; set; }
public bool ExcludeTypes { get; set; }
public bool DisplayInline { get; set; }
public Dictionary<string, RichLabelInfo> RichLabelInfo { get; set; }
public bool AutoUpdate { get; set; }
public string SelectElement { get; set; }
}
}

@ -74,7 +74,7 @@ namespace BTCPayServer.Components.MainNav
Id = a.Id,
IsOwner = a.IsOwner,
AppName = a.AppName,
AppType = Enum.Parse<AppType>(a.AppType)
AppType = a.AppType
}).ToList();
if (PoliciesSettings.Experimental)

@ -19,7 +19,7 @@ namespace BTCPayServer.Components.MainNav
{
public string Id { get; set; }
public string AppName { get; set; }
public AppType AppType { get; set; }
public string AppType { get; set; }
public bool IsOwner { get; set; }
}
}

@ -9,7 +9,7 @@
"invoice_expiredpaidpartial" => "notifications-invoice-failure",
"invoice_failedtoconfirm" => "notifications-invoice-failure",
"invoice_confirmed" => "notifications-invoice-settled",
"invoice_paidafterexpiration" => "notifications-settled",
"invoice_paidafterexpiration" => "notifications-invoice-settled",
"external-payout-transaction" => "notifications-payout",
"payout_awaitingapproval" => "notifications-payout",
"payout_awaitingpayment" => "notifications-payout-approved",

@ -1,6 +1,8 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Client.Models
@using BTCPayServer.Services
@using BTCPayServer.Services.Invoices
@inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Components.StoreRecentInvoices.StoreRecentInvoicesViewModel
<div class="widget store-recent-invoices" id="StoreRecentInvoices-@Model.Store.Id">
@ -63,7 +65,7 @@
</span>
}
</td>
<td class="text-end">@invoice.AmountCurrency</td>
<td class="text-end">@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</td>
</tr>
}
</tbody>

@ -7,7 +7,8 @@ public class StoreRecentInvoiceViewModel
{
public string InvoiceId { get; set; }
public string OrderId { get; set; }
public string AmountCurrency { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public InvoiceState Status { get; set; }
public DateTimeOffset Date { get; set; }
public bool HasRefund { get; set; }

@ -61,7 +61,8 @@ public class StoreRecentInvoices : ViewComponent
HasRefund = invoice.Refunds.Any(),
InvoiceId = invoice.Id,
OrderId = invoice.Metadata.OrderId ?? string.Empty,
AmountCurrency = _currencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency),
Amount = invoice.Price,
Currency = invoice.Currency
}).ToList();
return View(vm);

@ -1,4 +1,6 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Services
@inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Components.StoreRecentTransactions.StoreRecentTransactionsViewModel
<div class="widget store-recent-transactions" id="StoreRecentTransactions-@Model.Store.Id">
@ -49,11 +51,11 @@
</td>
@if (tx.Positive)
{
<td class="text-end text-success">@tx.Balance</td>
<td class="text-end text-success">@DisplayFormatter.Currency(tx.Balance, tx.Currency)</td>
}
else
{
<td class="text-end text-danger">@tx.Balance</td>
<td class="text-end text-danger">@DisplayFormatter.Currency(tx.Balance, tx.Currency)</td>
}
</tr>
}

@ -5,6 +5,7 @@ namespace BTCPayServer.Components.StoreRecentTransactions;
public class StoreRecentTransactionViewModel
{
public string Id { get; set; }
public string Currency { get; set; }
public string Balance { get; set; }
public bool Positive { get; set; }
public bool IsConfirmed { get; set; }

@ -58,6 +58,7 @@ public class StoreRecentTransactions : ViewComponent
Id = tx.TransactionId.ToString(),
Positive = tx.BalanceChange.GetValue(network) >= 0,
Balance = tx.BalanceChange.ShowMoney(network),
Currency = vm.CryptoCode,
IsConfirmed = tx.Confirmations != 0,
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, tx.TransactionId.ToString()),
Timestamp = tx.SeenAt

@ -0,0 +1,17 @@
@model BTCPayServer.Components.TruncateCenter.TruncateCenterViewModel
<span class="truncate-center @Model.Classes">
<span class="truncate-center-truncated" @(Model.Truncated != Model.Text ? $"data-bs-toggle=tooltip title={Model.Text}" : "")>@Model.Truncated</span>
<span class="truncate-center-text">@Model.Text</span>
@if (Model.Copy)
{
<button type="button" class="btn btn-link p-0" data-clipboard="@Model.Text">
<vc:icon symbol="copy" />
</button>
}
@if (!string.IsNullOrEmpty(Model.Link))
{
<a href="@Model.Link" rel="noreferrer noopener" target="_blank">
<vc:icon symbol="info" />
</a>
}
</span>

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Components.TruncateCenter;
/// <summary>
/// Truncates long strings in the center with ellipsis: Turns e.g. a BOLT11 into "lnbcrt7…q2ns60y"
/// </summary>
/// <param name="text">The full text, e.g. a Bitcoin address or BOLT11</param>
/// <param name="link">Optional link, e.g. a block explorer URL</param>
/// <param name="classes">Optional additional CSS classes</param>
/// <param name="padding">The number of characters to show on each side</param>
/// <param name="copy">Display a copy button</param>
/// <returns>HTML with truncated string</returns>
public class TruncateCenter : ViewComponent
{
public IViewComponentResult Invoke(string text, string link = null, string classes = null, int padding = 7, bool copy = true)
{
var vm = new TruncateCenterViewModel
{
Classes = classes,
Padding = padding,
Copy = copy,
Text = text,
Link = link,
Truncated = text.Length > 2 * padding ? $"{text[..padding]}…{text[^padding..]}" : text
};
return View(vm);
}
}

@ -0,0 +1,12 @@
namespace BTCPayServer.Components.TruncateCenter
{
public class TruncateCenterViewModel
{
public string Text { get; set; }
public string Truncated { get; set; }
public string Classes { get; set; }
public string Link { get; set; }
public int Padding { get; set; }
public bool Copy { get; set; }
}
}

@ -67,11 +67,21 @@ namespace BTCPayServer.Configuration
if (conf.GetOrDefault<string>("POSTGRES", null) == null)
{
if (conf.GetOrDefault<string>("SQLITEFILE", null) != null)
Logs.Configuration.LogWarning("SQLITE backend support is out of support. Please migrate to Postgres by following the following instructions https://github.com/btcpayserver/btcpayserver/blob/master/docs/db-migration.md");
if (conf.GetOrDefault<string>("MYSQL", null) != null)
Logs.Configuration.LogWarning("MYSQL backend support is out of support. Please migrate to Postgres by following the following instructions (https://github.com/btcpayserver/btcpayserver/blob/master/docs/db-migration.md)");
var allowDeprecated = conf.GetOrDefault<bool>("DEPRECATED", false);
if (allowDeprecated)
{
if (conf.GetOrDefault<string>("SQLITEFILE", null) != null)
Logs.Configuration.LogWarning("SQLITE backend support is out of support. Please migrate to Postgres by following the following instructions https://github.com/btcpayserver/btcpayserver/blob/master/docs/db-migration.md");
if (conf.GetOrDefault<string>("MYSQL", null) != null)
Logs.Configuration.LogWarning("MYSQL backend support is out of support. Please migrate to Postgres by following the following instructions (https://github.com/btcpayserver/btcpayserver/blob/master/docs/db-migration.md)");
}
else
{
if (conf.GetOrDefault<string>("SQLITEFILE", null) != null)
throw new ConfigException("SQLITE backend support is out of support. Please migrate to Postgres by following the following instructions (https://github.com/btcpayserver/btcpayserver/blob/master/docs/db-migration.md). If you don't want to update, you can try to start this instance by using the command line argument --deprecated");
if (conf.GetOrDefault<string>("MYSQL", null) != null)
throw new ConfigException("MYSQL backend support is out of support. Please migrate to Postgres by following the following instructions (https://github.com/btcpayserver/btcpayserver/blob/master/docs/db-migration.md). If you don't want to update, you can try to start this instance by using the command line argument --deprecated");
}
}
DockerDeployment = conf.GetOrDefault<bool>("dockerdeployment", true);
TorrcFile = conf.GetOrDefault<string>("torrcfile", null);
@ -148,9 +158,6 @@ namespace BTCPayServer.Configuration
}
DisableRegistration = conf.GetOrDefault<bool>("disable-registration", true);
var pluginRemote = conf.GetOrDefault<string>("plugin-remote", null);
if (pluginRemote != null)
Logs.Configuration.LogWarning("plugin-remote is an obsolete configuration setting, please remove it from configuration");
RecommendedPlugins = conf.GetOrDefault("recommended-plugins", "").ToLowerInvariant().Split('\r', '\n', '\t', ' ').Where(s => !string.IsNullOrEmpty(s)).Distinct().ToArray();
CheatMode = conf.GetOrDefault("cheatmode", false);
if (CheatMode && this.NetworkType == ChainName.Mainnet)

@ -30,6 +30,7 @@ namespace BTCPayServer.Configuration
app.Option("--mysql", $"DEPRECATED: Connection string to a MySQL database", CommandOptionType.SingleValue);
app.Option("--nocsp", $"Disable CSP (default false)", CommandOptionType.BoolValue);
app.Option("--sqlitefile", $"DEPRECATED: File name to an SQLite database file inside the data directory", CommandOptionType.SingleValue);
app.Option("--deprecated", $"Allow deprecated settings (default:false)", CommandOptionType.BoolValue);
app.Option("--externalservices", $"Links added to external services inside Server Settings / Services under the format service1:path2;service2:path2.(default: empty)", CommandOptionType.SingleValue);
app.Option("--rootpath", "The root path in the URL to access BTCPay (default: /)", CommandOptionType.SingleValue);
app.Option("--sshconnection", "SSH server to manage BTCPay under the form user@server:port (default: root@externalhost or empty)", CommandOptionType.SingleValue);
@ -45,7 +46,6 @@ namespace BTCPayServer.Configuration
app.Option("--debuglog", "A rolling log file for debug messages.", CommandOptionType.SingleValue);
app.Option("--debugloglevel", "The severity you log (default:information)", CommandOptionType.SingleValue);
app.Option("--disable-registration", "Disables new user registrations (default:true)", CommandOptionType.SingleValue);
app.Option("--plugin-remote", "Obsolete, do not use", CommandOptionType.SingleValue);
app.Option("--recommended-plugins", "Plugins which would be marked as recommended to be installed. Separated by newline or space", CommandOptionType.MultipleValue);
app.Option("--xforwardedproto", "If specified, set X-Forwarded-Proto to the specified value, this may be useful if your reverse proxy handle https but is not configured to add X-Forwarded-Proto (example: --xforwardedproto https)", CommandOptionType.SingleValue);
app.Option("--cheatmode", "Add some helper UI to facilitate dev-time testing (Default false)", CommandOptionType.BoolValue);

@ -7,6 +7,8 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
@ -15,6 +17,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Controllers.Greenfield
{
@ -63,7 +66,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
StoreDataId = storeId,
Name = request.AppName,
AppType = AppType.Crowdfund.ToString()
AppType = CrowdfundAppType.AppType
};
appData.SetSettings(ToCrowdfundSettings(request));
@ -94,7 +97,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
StoreDataId = storeId,
Name = request.AppName,
AppType = AppType.PointOfSale.ToString()
AppType = PointOfSaleAppType.AppType
};
appData.SetSettings(ToPointOfSaleSettings(request));
@ -108,7 +111,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> UpdatePointOfSaleApp(string appId, CreatePointOfSaleAppRequest request)
{
var app = await _appService.GetApp(appId, AppType.PointOfSale);
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
if (app == null)
{
return AppNotFound();
@ -181,7 +184,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetPosApp(string appId)
{
var app = await _appService.GetApp(appId, AppType.PointOfSale);
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
if (app == null)
{
return AppNotFound();
@ -194,7 +197,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetCrowdfundApp(string appId)
{
var app = await _appService.GetApp(appId, AppType.Crowdfund);
var app = await _appService.GetApp(appId, CrowdfundAppType.AppType);
if (app == null)
{
return AppNotFound();
@ -242,7 +245,7 @@ namespace BTCPayServer.Controllers.Greenfield
EmbeddedCSS = request.EmbeddedCSS?.Trim(),
NotificationUrl = request.NotificationUrl?.Trim(),
Tagline = request.Tagline?.Trim(),
PerksTemplate = request.PerksTemplate != null ? _appService.SerializeTemplate(_appService.Parse(request.PerksTemplate?.Trim(), request.TargetCurrency)) : null,
PerksTemplate = request.PerksTemplate is not null ? _appService.SerializeTemplate(_appService.Parse(request.PerksTemplate.Trim(), request.TargetCurrency!)) : null,
// If Disqus shortname is not null or empty we assume that Disqus should be enabled
DisqusEnabled = !string.IsNullOrEmpty(request.DisqusShortname?.Trim()),
DisqusShortname = request.DisqusShortname?.Trim(),
@ -264,7 +267,7 @@ namespace BTCPayServer.Controllers.Greenfield
return new PointOfSaleSettings()
{
Title = request.Title,
DefaultView = (Services.Apps.PosViewType)request.DefaultView,
DefaultView = (PosViewType) request.DefaultView,
ShowCustomAmount = request.ShowCustomAmount,
ShowDiscount = request.ShowDiscount,
EnableTips = request.EnableTips,
@ -360,7 +363,8 @@ namespace BTCPayServer.Controllers.Greenfield
{
try
{
_appService.SerializeTemplate(_appService.Parse(request.Template, request.Currency));
// Just checking if we can serialize, we don't care about the currency
_appService.SerializeTemplate(_appService.Parse(request.Template, "USD"));
}
catch
{
@ -449,7 +453,8 @@ namespace BTCPayServer.Controllers.Greenfield
try
{
_appService.SerializeTemplate(_appService.Parse(request.PerksTemplate, request.TargetCurrency));
// Just checking if we can serialize, we don't care about the currency
_appService.SerializeTemplate(_appService.Parse(request.PerksTemplate, "USD"));
}
catch
{

@ -13,6 +13,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using BTCPayServer.Services.Custodian;
using BTCPayServer.Services.Custodian.Client;
@ -20,6 +21,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
using CustodianAccountDataClient = BTCPayServer.Client.Models.CustodianAccountData;
@ -221,6 +223,12 @@ namespace BTCPayServer.Controllers.Greenfield
if (custodian is ICanDeposit depositableCustodian)
{
var pm = PaymentMethodId.TryParse(paymentMethod);
if (pm == null)
{
return this.CreateAPIError(400, "unsupported-payment-method",
$"Unsupported payment method.");
}
var result = await depositableCustodian.GetDepositAddressAsync(paymentMethod, config, cancellationToken);
return Ok(result);
}
@ -338,6 +346,44 @@ namespace BTCPayServer.Controllers.Greenfield
$"Fetching past trade info on \"{custodian.Name}\" is not supported.");
}
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals/simulation")]
[Authorize(Policy = Policies.CanWithdrawFromCustodianAccounts,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> SimulateWithdrawal(string storeId, string accountId,
WithdrawRequestData request, CancellationToken cancellationToken = default)
{
var custodianAccount = await GetCustodianAccount(storeId, accountId);
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian is ICanWithdraw withdrawableCustodian)
{
var pm = PaymentMethodId.TryParse(request.PaymentMethod);
if (pm == null)
{
return this.CreateAPIError(400, "unsupported-payment-method",
$"Unsupported payment method.");
}
var asset = pm.CryptoCode;
decimal qty;
try
{
qty = await ParseQty(request.Qty, asset, custodianAccount, custodian, cancellationToken);
}
catch (Exception ex)
{
return UnsupportedAsset(asset, ex.Message);
}
var simulateWithdrawResult =
await withdrawableCustodian.SimulateWithdrawalAsync(request.PaymentMethod, qty, custodianAccount.GetBlob(), cancellationToken);
var result = new WithdrawalSimulationResponseData(simulateWithdrawResult.PaymentMethod, simulateWithdrawResult.Asset,
accountId, custodian.Code, simulateWithdrawResult.LedgerEntries, simulateWithdrawResult.MinQty, simulateWithdrawResult.MaxQty);
return Ok(result);
}
return this.CreateAPIError(400, "withdrawals-not-supported",
$"Withdrawals are not supported for \"{custodian.Name}\".");
}
[HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/withdrawals")]
[Authorize(Policy = Policies.CanWithdrawFromCustodianAccounts,
@ -350,8 +396,25 @@ namespace BTCPayServer.Controllers.Greenfield
if (custodian is ICanWithdraw withdrawableCustodian)
{
var pm = PaymentMethodId.TryParse(request.PaymentMethod);
if (pm == null)
{
return this.CreateAPIError(400, "unsupported-payment-method",
$"Unsupported payment method.");
}
var asset = pm.CryptoCode;
decimal qty;
try
{
qty = await ParseQty(request.Qty, asset, custodianAccount, custodian, cancellationToken);
}
catch (Exception ex)
{
return UnsupportedAsset(asset, ex.Message);
}
var withdrawResult =
await withdrawableCustodian.WithdrawAsync(request.PaymentMethod, request.Qty, custodianAccount.GetBlob(), cancellationToken);
await withdrawableCustodian.WithdrawToStoreWalletAsync(request.PaymentMethod, qty, custodianAccount.GetBlob(), cancellationToken);
var result = new WithdrawalResponseData(withdrawResult.PaymentMethod, withdrawResult.Asset, withdrawResult.LedgerEntries,
withdrawResult.WithdrawalId, accountId, custodian.Code, withdrawResult.Status, withdrawResult.CreatedTime, withdrawResult.TargetAddress, withdrawResult.TransactionId);
return Ok(result);
@ -361,6 +424,22 @@ namespace BTCPayServer.Controllers.Greenfield
$"Withdrawals are not supported for \"{custodian.Name}\".");
}
private IActionResult UnsupportedAsset(string asset, string err)
{
return this.CreateAPIError(400, "invalid-qty", $"It is impossible to use % quantity with this asset ({err})");
}
private async Task<decimal> ParseQty(TradeQuantity qty, string asset, CustodianAccountData custodianAccount, ICustodian custodian, CancellationToken cancellationToken = default)
{
if (qty.Type == TradeQuantity.ValueType.Exact)
return qty.Value;
// Percentage of current holdings => calculate the amount
var config = custodianAccount.GetBlob();
var balances = await custodian.GetAssetBalancesAsync(config, cancellationToken);
if (!balances.TryGetValue(asset, out var assetBalance))
return 0.0m;
return (assetBalance * qty.Value) / 100m;
}
async Task<CustodianAccountData> GetCustodianAccount(string storeId, string accountId)
{

@ -287,7 +287,7 @@ namespace BTCPayServer.Controllers.Greenfield
var lightningClient = await GetLightningClient(cryptoCode, false);
var param = new ListInvoicesParams { PendingOnly = pendingOnly, OffsetIndex = offsetIndex };
var invoices = await lightningClient.ListInvoices(param, cancellationToken);
return Ok(invoices.Select(ToModel));
return Ok(invoices.Select(ToModel).ToArray());
}
public virtual async Task<IActionResult> GetPayments(string cryptoCode, [FromQuery] bool? includePending, [FromQuery] long? offsetIndex, CancellationToken cancellationToken = default)
@ -295,7 +295,7 @@ namespace BTCPayServer.Controllers.Greenfield
var lightningClient = await GetLightningClient(cryptoCode, false);
var param = new ListPaymentsParams { IncludePending = includePending, OffsetIndex = offsetIndex };
var payments = await lightningClient.ListPayments(param, cancellationToken);
return Ok(payments.Select(ToModel));
return Ok(payments.Select(ToModel).ToArray());
}
public virtual async Task<IActionResult> CreateInvoice(string cryptoCode, CreateLightningInvoiceRequest request, CancellationToken cancellationToken = default)

@ -9,6 +9,7 @@ using BTCPayServer.Data;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using StoreData = BTCPayServer.Data.StoreData;
using PayoutProcessorData = BTCPayServer.Client.Models.PayoutProcessorData;
@ -17,6 +18,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldPayoutProcessorsController : ControllerBase
{
private readonly IEnumerable<IPayoutProcessorFactory> _factories;

@ -10,6 +10,7 @@ using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
@ -17,6 +18,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreAutomatedLightningPayoutProcessorsController : ControllerBase
{
private readonly PayoutProcessorService _payoutProcessorService;
@ -30,9 +32,8 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors/" + nameof(LightningAutomatedPayoutSenderFactory))]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors/" + nameof(LightningAutomatedPayoutSenderFactory) +
"/{paymentMethod}")]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory")]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory/{paymentMethod}")]
public async Task<IActionResult> GetStoreLightningAutomatedPayoutProcessors(
string storeId, string? paymentMethod)
{
@ -64,8 +65,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/stores/{storeId}/payout-processors/" + nameof(LightningAutomatedPayoutSenderFactory) +
"/{paymentMethod}")]
[HttpPut("~/api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory/{paymentMethod}")]
public async Task<IActionResult> UpdateStoreLightningAutomatedPayoutProcessor(
string storeId, string paymentMethod, LightningAutomatedPayoutSettings request)
{

@ -10,6 +10,7 @@ using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.OnChain;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
@ -17,6 +18,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreAutomatedOnChainPayoutProcessorsController : ControllerBase
{
private readonly PayoutProcessorService _payoutProcessorService;
@ -30,9 +32,8 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors/" + nameof(OnChainAutomatedPayoutSenderFactory))]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors/" + nameof(OnChainAutomatedPayoutSenderFactory) +
"/{paymentMethod}")]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors/OnChainAutomatedPayoutSenderFactory")]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors/OnChainAutomatedPayoutSenderFactory/{paymentMethod}")]
public async Task<IActionResult> GetStoreOnChainAutomatedPayoutProcessors(
string storeId, string? paymentMethod)
{
@ -70,8 +71,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/stores/{storeId}/payout-processors/" + nameof(OnChainAutomatedPayoutSenderFactory) +
"/{paymentMethod}")]
[HttpPut("~/api/v1/stores/{storeId}/payout-processors/OnChainAutomatedPayoutSenderFactory/{paymentMethod}")]
public async Task<IActionResult> UpdateStoreOnchainAutomatedPayoutProcessor(
string storeId, string paymentMethod, OnChainAutomatedPayoutSettings request)
{

@ -17,6 +17,7 @@ using BTCPayServer.Payments.Lightning;
using BTCPayServer.Security;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StoreData = BTCPayServer.Data.StoreData;
@ -25,24 +26,19 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreLNURLPayPaymentMethodsController : ControllerBase
{
private StoreData Store => HttpContext.GetStoreData();
private readonly StoreRepository _storeRepository;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly IAuthorizationService _authorizationService;
private readonly ISettingsRepository _settingsRepository;
public GreenfieldStoreLNURLPayPaymentMethodsController(
StoreRepository storeRepository,
BTCPayNetworkProvider btcPayNetworkProvider,
IAuthorizationService authorizationService,
ISettingsRepository settingsRepository)
BTCPayNetworkProvider btcPayNetworkProvider)
{
_storeRepository = storeRepository;
_btcPayNetworkProvider = btcPayNetworkProvider;
_authorizationService = authorizationService;
_settingsRepository = settingsRepository;
}
public static IEnumerable<LNURLPayPaymentMethodData> GetLNURLPayPaymentMethods(StoreData store,

@ -6,6 +6,7 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes;
using LightningAddressData = BTCPayServer.Client.Models.LightningAddressData;
@ -14,6 +15,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreLightningAddressesController : ControllerBase
{
private readonly LightningAddressService _lightningAddressService;

@ -18,6 +18,7 @@ using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StoreData = BTCPayServer.Data.StoreData;
@ -26,6 +27,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreLightningNetworkPaymentMethodsController : ControllerBase
{
private StoreData Store => HttpContext.GetStoreData();

@ -8,6 +8,7 @@ using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using NBXplorer.Models;
@ -17,6 +18,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/generate")]
[EnableCors(CorsPolicies.All)]
public async Task<IActionResult> GenerateOnChainWallet(string storeId, string cryptoCode,
GenerateWalletRequest request)
{

@ -14,6 +14,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBXplorer.DerivationStrategy;
@ -24,6 +25,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public partial class GreenfieldStoreOnChainPaymentMethodsController : ControllerBase
{
private StoreData Store => HttpContext.GetStoreData();

@ -8,6 +8,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using StoreData = BTCPayServer.Data.StoreData;
@ -15,6 +16,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStorePaymentMethodsController : ControllerBase
{
private StoreData Store => HttpContext.GetStoreData();

@ -7,12 +7,14 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.PayoutProcessors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStorePayoutProcessorsController : ControllerBase
{
private readonly PayoutProcessorService _payoutProcessorService;

@ -13,6 +13,7 @@ using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using RateSource = BTCPayServer.Client.Models.RateSource;
@ -21,6 +22,7 @@ namespace BTCPayServer.Controllers.GreenField
[ApiController]
[Route("api/v1/stores/{storeId}/rates/configuration")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreRateConfigurationController : ControllerBase
{
private readonly RateFetcher _rateProviderFactory;

@ -11,6 +11,7 @@ using BTCPayServer.Data;
using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.GreenField
@ -18,6 +19,7 @@ namespace BTCPayServer.Controllers.GreenField
[ApiController]
[Route("api/v1/stores/{storeId}/rates")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreRatesController : ControllerBase
{
private readonly RateFetcher _rateProviderFactory;

@ -6,6 +6,7 @@ using BTCPayServer.Data;
using BTCPayServer.Security;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -17,6 +18,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Route("api/test/apikey")]
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldTestApiKeyController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;

@ -223,6 +223,20 @@ namespace BTCPayServer.Controllers.Greenfield
return GetFromActionResult<MarketTradeResponseData>(
await GetController<GreenfieldCustodianAccountController>().MarketTradeCustodianAccountAsset(storeId, accountId, request, cancellationToken));
}
public override async Task<WithdrawalSimulationResponseData> SimulateCustodianAccountWithdrawal(string storeId, string accountId,
WithdrawRequestData request, CancellationToken cancellationToken = default)
{
return GetFromActionResult<WithdrawalSimulationResponseData>(
await GetController<GreenfieldCustodianAccountController>().SimulateWithdrawal(storeId, accountId, request, cancellationToken));
}
public override async Task<WithdrawalResponseData> CreateCustodianAccountWithdrawal(string storeId, string accountId,
WithdrawRequestData request, CancellationToken cancellationToken = default)
{
return GetFromActionResult<WithdrawalResponseData>(
await GetController<GreenfieldCustodianAccountController>().CreateWithdrawal(storeId, accountId, request, cancellationToken));
}
public override async Task<OnChainWalletObjectData[]> GetOnChainWalletObjects(string storeId, string cryptoCode,
GetWalletObjectsRequest query = null,

@ -7,10 +7,12 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Fido2;
using BTCPayServer.Fido2.Models;
using BTCPayServer.Filters;
using BTCPayServer.Logging;
using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Services;
@ -83,6 +85,24 @@ namespace BTCPayServer.Controllers
get; set;
}
[HttpGet("/cheat/permissions")]
[HttpGet("/cheat/permissions/stores/{storeId}")]
[CheatModeRoute]
public async Task<IActionResult> CheatPermissions([FromServices]IAuthorizationService authorizationService, string storeId = null)
{
var vm = new CheatPermissionsViewModel();
vm.StoreId = storeId;
var results = new System.Collections.Generic.List<(string, Task<AuthorizationResult>)>();
foreach (var p in Policies.AllPolicies.Concat(new[] { Policies.CanModifyStoreSettingsUnscoped }))
{
results.Add((p, authorizationService.AuthorizeAsync(User, storeId, p)));
}
await Task.WhenAll(results.Select(r => r.Item2));
results = results.OrderBy(r => r.Item1).ToList();
vm.Permissions = results.Select(r => (r.Item1, r.Item2.Result)).ToArray();
return View(vm);
}
[HttpGet("/login")]
[AllowAnonymous]
public async Task<IActionResult> Login(string returnUrl = null, string email = null)

@ -21,8 +21,7 @@ namespace BTCPayServer.Controllers
app.StoreData = GetCurrentStore();
var vm = new AppTopItemsViewModel { App = app };
return ViewComponent("AppTopItems", new { vm });
return ViewComponent("AppTopItems", new { appId = app.Id, appType = app.AppType });
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
@ -34,9 +33,7 @@ namespace BTCPayServer.Controllers
return NotFound();
app.StoreData = GetCurrentStore();
var vm = new AppSalesViewModel { App = app };
return ViewComponent("AppSales", new { vm });
return ViewComponent("AppSales", new { appId = app.Id, appType = app.AppType });
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]

@ -1,4 +1,3 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@ -6,8 +5,6 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.Crowdfund.Controllers;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
@ -57,13 +54,14 @@ namespace BTCPayServer.Controllers
var app = await _appService.GetApp(appId, null);
if (app is null)
return NotFound();
return app.AppType switch
var res = await _appService.ViewLink(app);
if (res is null)
{
nameof(AppType.Crowdfund) => RedirectToAction(nameof(UICrowdfundController.ViewCrowdfund), "UICrowdfund", new { appId }),
nameof(AppType.PointOfSale) => RedirectToAction(nameof(UIPointOfSaleController.ViewPointOfSale), "UIPointOfSale", new { appId }),
_ => NotFound()
};
return NotFound();
}
return Redirect(res);
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
@ -114,12 +112,10 @@ namespace BTCPayServer.Controllers
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("/stores/{storeId}/apps/create")]
public IActionResult CreateApp(string storeId)
public IActionResult CreateApp(string storeId, string appType = null)
{
return View(new CreateAppViewModel
{
StoreId = GetCurrentStore().Id
});
var vm = new CreateAppViewModel (_appService){StoreId = GetCurrentStore().Id, SelectedAppType = appType};
return View(vm);
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
@ -128,8 +124,8 @@ namespace BTCPayServer.Controllers
{
var store = GetCurrentStore();
vm.StoreId = store.Id;
if (!Enum.TryParse(vm.SelectedAppType, out AppType appType))
var type = _appService.GetAppType(vm.SelectedAppType);
if (type is null)
ModelState.AddModelError(nameof(vm.SelectedAppType), "Invalid App Type");
if (!ModelState.IsValid)
@ -141,34 +137,19 @@ namespace BTCPayServer.Controllers
{
StoreDataId = store.Id,
Name = vm.AppName,
AppType = appType.ToString()
AppType = vm.SelectedAppType
};
var defaultCurrency = await GetStoreDefaultCurrentIfEmpty(appData.StoreDataId, null);
switch (appType)
{
case AppType.Crowdfund:
var emptyCrowdfund = new CrowdfundSettings { TargetCurrency = defaultCurrency };
appData.SetSettings(emptyCrowdfund);
break;
case AppType.PointOfSale:
var empty = new PointOfSaleSettings { Currency = defaultCurrency };
appData.SetSettings(empty);
break;
default:
throw new ArgumentOutOfRangeException();
}
await _appService.SetDefaultSettings(appData, defaultCurrency);
await _appService.UpdateOrCreateApp(appData);
TempData[WellKnownTempData.SuccessMessage] = "App successfully created";
CreatedAppId = appData.Id;
return appType switch
{
AppType.PointOfSale => RedirectToAction(nameof(UIPointOfSaleController.UpdatePointOfSale), "UIPointOfSale", new { appId = appData.Id }),
AppType.Crowdfund => RedirectToAction(nameof(UICrowdfundController.UpdateCrowdfund), "UICrowdfund", new { appId = appData.Id }),
_ => throw new ArgumentOutOfRangeException()
};
var url = await type.ConfigureLink(appData);
return Redirect(url);
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]

@ -13,6 +13,7 @@ using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.CustodianAccountViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization;
@ -20,6 +21,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json.Linq;
using NLog.Config;
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
using StoreData = BTCPayServer.Data.StoreData;
@ -32,13 +34,13 @@ namespace BTCPayServer.Controllers
{
private readonly IEnumerable<ICustodian> _custodianRegistry;
private readonly CustodianAccountRepository _custodianAccountRepository;
private readonly CurrencyNameTable _currencyNameTable;
private readonly DisplayFormatter _displayFormatter;
private readonly BTCPayServerClient _btcPayServerClient;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly LinkGenerator _linkGenerator;
public UICustodianAccountsController(
CurrencyNameTable currencyNameTable,
DisplayFormatter displayFormatter,
UserManager<ApplicationUser> userManager,
CustodianAccountRepository custodianAccountRepository,
IEnumerable<ICustodian> custodianRegistry,
@ -47,7 +49,7 @@ namespace BTCPayServer.Controllers
LinkGenerator linkGenerator
)
{
_currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_displayFormatter = displayFormatter;
_custodianAccountRepository = custodianAccountRepository;
_custodianRegistry = custodianRegistry;
_btcPayServerClient = btcPayServerClient;
@ -144,7 +146,7 @@ namespace BTCPayServer.Controllers
if (asset.Equals(defaultCurrency))
{
assetBalance.FormattedFiatValue =
_currencyNameTable.DisplayFormatCurrency(pair.Value.Qty, defaultCurrency);
_displayFormatter.Currency(pair.Value.Qty, defaultCurrency);
assetBalance.FiatValue = pair.Value.Qty;
}
else
@ -156,11 +158,11 @@ namespace BTCPayServer.Controllers
assetBalance.Bid = quote.Bid;
assetBalance.Ask = quote.Ask;
assetBalance.FormattedBid =
_currencyNameTable.DisplayFormatCurrency(quote.Bid, quote.FromAsset);
_displayFormatter.Currency(quote.Bid, quote.FromAsset);
assetBalance.FormattedAsk =
_currencyNameTable.DisplayFormatCurrency(quote.Ask, quote.FromAsset);
_displayFormatter.Currency(quote.Ask, quote.FromAsset);
assetBalance.FormattedFiatValue =
_currencyNameTable.DisplayFormatCurrency(pair.Value.Qty * quote.Bid,
_displayFormatter.Currency(pair.Value.Qty * quote.Bid,
defaultCurrency);
assetBalance.FiatValue = pair.Value.Qty * quote.Bid;
}
@ -174,14 +176,14 @@ namespace BTCPayServer.Controllers
if (custodian is ICanWithdraw withdrawableCustodian)
{
var withdrawableePaymentMethods = withdrawableCustodian.GetWithdrawablePaymentMethods();
foreach (var withdrawableePaymentMethod in withdrawableePaymentMethods)
var withdrawablePaymentMethods = withdrawableCustodian.GetWithdrawablePaymentMethods();
foreach (var withdrawablePaymentMethod in withdrawablePaymentMethods)
{
var withdrawableAsset = withdrawableePaymentMethod.Split("-")[0];
var withdrawableAsset = withdrawablePaymentMethod.Split("-")[0];
if (assetBalances.ContainsKey(withdrawableAsset))
{
var assetBalance = assetBalances[withdrawableAsset];
assetBalance.CanWithdraw = true;
assetBalance.WithdrawablePaymentMethods.Add(withdrawablePaymentMethod);
}
}
}
@ -215,7 +217,8 @@ namespace BTCPayServer.Controllers
return NotFound();
}
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), "en-US");
var configForm = await custodian.GetConfigForm();
configForm.SetValues(custodianAccount.GetBlob());
var vm = new EditCustodianAccountViewModel();
vm.CustodianAccount = custodianAccount;
@ -227,9 +230,6 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> EditCustodianAccount(string storeId, string accountId,
EditCustodianAccountViewModel vm)
{
// The locale is not important yet, but keeping it here so we can find it easily when localization becomes a thing.
var locale = "en-US";
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
return NotFound();
@ -241,37 +241,22 @@ namespace BTCPayServer.Controllers
return NotFound();
}
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), locale);
var configForm = await custodian.GetConfigForm();
configForm.ApplyValuesFromForm(Request.Form);
var newData = new JObject();
foreach (var pair in Request.Form)
if (configForm.IsValid())
{
if ("CustodianAccount.Name".Equals(pair.Key))
{
custodianAccount.Name = pair.Value;
}
else
{
// TODO support posted array notation, like a field called "WithdrawToAddressNamePerPaymentMethod[BTC-OnChain]". The data should be nested in the JSON.
newData.Add(pair.Key, pair.Value.ToString());
}
}
var newConfigData = RemoveUnusedFieldsFromConfig(custodianAccount.GetBlob(), newData, configForm);
var newConfigForm = await custodian.GetConfigForm(newConfigData, locale);
if (newConfigForm.IsValid())
{
custodianAccount.SetBlob(newConfigData);
var newData = configForm.GetValues();
custodianAccount.SetBlob(newData);
custodianAccount = await _custodianAccountRepository.CreateOrUpdate(custodianAccount);
return RedirectToAction(nameof(ViewCustodianAccount),
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
}
// Form not valid: The user must fix the errors before we can save
vm.CustodianAccount = custodianAccount;
vm.ConfigForm = newConfigForm;
vm.ConfigForm = configForm;
return View(vm);
}
@ -312,16 +297,11 @@ namespace BTCPayServer.Controllers
};
var configData = new JObject();
foreach (var pair in Request.Form)
{
configData.Add(pair.Key, pair.Value.ToString());
}
var configForm = await custodian.GetConfigForm(configData, "en-US");
var configForm = await custodian.GetConfigForm();
configForm.ApplyValuesFromForm(Request.Form);
if (configForm.IsValid())
{
// configForm.removeUnusedKeys();
var configData = configForm.GetValues();
custodianAccountData.SetBlob(configData);
custodianAccountData = await _custodianAccountRepository.CreateOrUpdate(custodianAccountData);
TempData[WellKnownTempData.SuccessMessage] = "Custodian account successfully created";
@ -357,37 +337,11 @@ namespace BTCPayServer.Controllers
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
}
// The JObject may contain too much data because we used ALL post values and this may be more than we needed.
// Because we don't know the form fields beforehand, we will filter out the superfluous data afterwards.
// We will keep all the old keys + merge the new keys as per the current form.
// Since the form can differ by circumstances, we will never remove any keys that were previously stored. We just limit what we add.
private JObject RemoveUnusedFieldsFromConfig(JObject storedData, JObject newData, Form form)
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/trade/simulate")]
public async Task<IActionResult> SimulateTradeJson(string storeId, string accountId,
[FromBody] TradeRequestData request)
{
JObject filteredData = new JObject();
var storedKeys = new List<string>();
foreach (var item in storedData)
{
storedKeys.Add(item.Key);
}
var formKeys = form.GetAllFields().Select(f => f.FullName).ToHashSet();
foreach (var item in newData)
{
if (storedKeys.Contains(item.Key) || formKeys.Contains(item.Key))
{
filteredData[item.Key] = item.Value;
}
}
return filteredData;
}
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/trade/prepare")]
public async Task<IActionResult> GetTradePrepareJson(string storeId, string accountId,
[FromQuery] string assetToTrade, [FromQuery] string assetToTradeInto)
{
if (string.IsNullOrEmpty(assetToTrade) || string.IsNullOrEmpty(assetToTradeInto))
if (string.IsNullOrEmpty(request.FromAsset) || string.IsNullOrEmpty(request.ToAsset))
{
return BadRequest();
}
@ -421,12 +375,12 @@ namespace BTCPayServer.Controllers
foreach (var pair in assetBalancesData)
{
var oneAsset = pair.Key;
if (assetToTrade.Equals(oneAsset))
if (request.FromAsset.Equals(oneAsset))
{
vm.MaxQtyToTrade = pair.Value;
vm.MaxQty = pair.Value;
//vm.FormattedMaxQtyToTrade = pair.Value;
if (assetToTrade.Equals(assetToTradeInto))
if (request.FromAsset.Equals(request.ToAsset))
{
// We cannot trade the asset for itself
return BadRequest();
@ -434,7 +388,8 @@ namespace BTCPayServer.Controllers
try
{
var quote = await tradingCustodian.GetQuoteForAssetAsync(assetToTrade, assetToTradeInto,
var quote = await tradingCustodian.GetQuoteForAssetAsync(request.FromAsset,
request.ToAsset,
config, default);
// TODO Ask is normally a higher number than Bid!! Let's check this!! Maybe a Unit Test?
@ -574,6 +529,93 @@ namespace BTCPayServer.Controllers
return Request.GetRelativePathOrAbsolute(res);
}
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/withdraw/simulate")]
public async Task<IActionResult> SimulateWithdrawJson(string storeId, string accountId,
[FromBody] WithdrawRequestData withdrawRequestData)
{
if (string.IsNullOrEmpty(withdrawRequestData.PaymentMethod))
{
return BadRequest();
}
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
if (custodianAccount == null)
return NotFound();
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
if (custodian == null)
{
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
return NotFound();
}
var vm = new WithdrawalPrepareViewModel();
try
{
if (custodian is ICanWithdraw withdrawableCustodian)
{
var config = custodianAccount.GetBlob();
try
{
var simulateWithdrawal =
await _btcPayServerClient.SimulateCustodianAccountWithdrawal(storeId, accountId, withdrawRequestData,
default);
vm = new WithdrawalPrepareViewModel(simulateWithdrawal);
// There are no bad config fields, so we need an empty array
vm.BadConfigFields = Array.Empty<string>();
}
catch (BadConfigException e)
{
Form configForm = await custodian.GetConfigForm();
configForm.SetValues(config);
string[] badConfigFields = new string[e.BadConfigKeys.Length];
int i = 0;
foreach (var oneField in configForm.GetAllFields())
{
foreach (var badConfigKey in e.BadConfigKeys)
{
if (oneField.FullName.Equals(badConfigKey))
{
var field = configForm.GetFieldByFullName(oneField.FullName);
badConfigFields[i] = field.Label;
i++;
}
}
}
vm.BadConfigFields = badConfigFields;
return Ok(vm);
}
}
}
catch (Exception e)
{
vm.ErrorMessage = e.Message;
}
return Ok(vm);
}
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/withdraw")]
public async Task<IActionResult> Withdraw(string storeId, string accountId,
[FromBody] WithdrawRequestData request)
{
try
{
var result = await _btcPayServerClient.CreateCustodianAccountWithdrawal(storeId, accountId, request);
return Ok(result);
}
catch (GreenfieldAPIException e)
{
var result = new ObjectResult(e.APIError) { StatusCode = e.HttpCode };
return result;
}
}
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
}
}

@ -222,7 +222,7 @@ namespace BTCPayServer.Controllers
public RedirectToActionResult RedirectToStore(StoreData store)
{
return store.Role == StoreRoles.Owner
return store.HasPermission(Policies.CanModifyStoreSettings)
? RedirectToAction("Dashboard", "UIStores", new { storeId = store.Id })
: RedirectToAction("ListInvoices", "UIInvoice", new { storeId = store.Id });
}

@ -80,15 +80,13 @@ namespace BTCPayServer.Controllers
}
return UnprocessableEntity(new
{
ErrorMessage = response.ErrorDetail,
AmountRemaining = invoice.Price
ErrorMessage = response.ErrorDetail
});
default:
return UnprocessableEntity(new
{
ErrorMessage = $"Payment method {paymentMethodId} is not supported",
AmountRemaining = invoice.Price
ErrorMessage = $"Payment method {paymentMethodId} is not supported"
});
}
@ -97,8 +95,7 @@ namespace BTCPayServer.Controllers
{
return BadRequest(new
{
ErrorMessage = e.Message,
AmountRemaining = invoice.Price
ErrorMessage = e.Message
});
}
}

@ -19,7 +19,9 @@ using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Invoices.Export;
@ -137,10 +139,10 @@ namespace BTCPayServer.Controllers
CreatedDate = invoice.InvoiceTime,
ExpirationDate = invoice.ExpirationTime,
MonitoringDate = invoice.MonitoringExpiration,
Fiat = _CurrencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency),
Fiat = _displayFormatter.Currency(invoice.Price, invoice.Currency),
TaxIncluded = invoice.Metadata.TaxIncluded is null
? null
: _CurrencyNameTable.DisplayFormatCurrency(invoice.Metadata.TaxIncluded ?? 0.0m, invoice.Currency),
: _displayFormatter.Currency(invoice.Metadata.TaxIncluded ?? 0.0m, invoice.Currency),
NotificationUrl = invoice.NotificationURL?.AbsoluteUri,
RedirectUrl = invoice.RedirectURL?.AbsoluteUri,
TypedMetadata = invoice.Metadata,
@ -229,12 +231,14 @@ namespace BTCPayServer.Controllers
Amount = amount,
Paid = paid,
ReceivedDate = paymentEntity.ReceivedTime.DateTime,
PaidFormatted = _CurrencyNameTable.FormatCurrency(paid, i.Currency),
RateFormatted = _CurrencyNameTable.FormatCurrency(rate, i.Currency),
PaidFormatted = _displayFormatter.Currency(paid, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(rate, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaymentMethod = paymentMethodId.ToPrettyString(),
Link = link,
Id = txId,
Destination = paymentData.GetDestination()
Destination = paymentData.GetDestination(),
PaymentProof = GetPaymentProof(paymentData),
PaymentType = paymentData.GetPaymentType()
};
})
.Where(payment => payment != null)
@ -246,6 +250,17 @@ namespace BTCPayServer.Controllers
return View(vm);
}
private string? GetPaymentProof(CryptoPaymentData paymentData)
{
return paymentData switch
{
BitcoinLikePaymentData b => b.Outpoint.ToString(),
LightningPaymentData l => l.Preimage,
_ => null
};
}
private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId)
{
var network = _NetworkProvider.GetNetwork(paymentMethodId.CryptoCode);
@ -253,7 +268,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("invoices/{invoiceId}/refund")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanCreateNonApprovedPullPayments, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Refund([FromServices] IEnumerable<IPayoutHandler> payoutHandlers, string invoiceId, CancellationToken cancellationToken)
{
await using var ctx = _dbContextFactory.CreateContext();
@ -316,7 +331,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("invoices/{invoiceId}/refund")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanCreateNonApprovedPullPayments, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Refund(string invoiceId, RefundModel model, CancellationToken cancellationToken)
{
await using var ctx = _dbContextFactory.CreateContext();
@ -354,8 +369,7 @@ namespace BTCPayServer.Controllers
var cryptoPaid = paymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC);
var paidCurrency = Math.Round(cryptoPaid * paymentMethod.Rate, cdCurrency.Divisibility);
model.CryptoAmountThen = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
model.RateThenText =
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountThen, paymentMethodId.CryptoCode);
model.RateThenText = _displayFormatter.Currency(model.CryptoAmountThen, paymentMethodId.CryptoCode);
rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
rateResult = await _RateProvider.FetchRate(
new CurrencyPair(paymentMethodId.CryptoCode, invoice.Currency), rules,
@ -369,13 +383,12 @@ namespace BTCPayServer.Controllers
}
model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
model.CurrentRateText =
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
model.CurrentRateText = _displayFormatter.Currency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
model.FiatAmount = paidCurrency;
}
model.CustomAmount = model.FiatAmount;
model.CustomCurrency = invoice.Currency;
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency);
model.FiatText = _displayFormatter.Currency(model.FiatAmount, invoice.Currency);
return View("_RefundModal", model);
case RefundSteps.SelectRate:
@ -386,18 +399,21 @@ namespace BTCPayServer.Controllers
StoreId = invoice.StoreId,
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration
};
var authorizedForAutoApprove = (await
_authorizationService.AuthorizeAsync(User, invoice.StoreId, Policies.CanCreatePullPayments))
.Succeeded;
switch (model.SelectedRefundOption)
{
case "RateThen":
createPullPayment.Currency = paymentMethodId.CryptoCode;
createPullPayment.Amount = model.CryptoAmountThen;
createPullPayment.AutoApproveClaims = true;
createPullPayment.AutoApproveClaims = authorizedForAutoApprove;
break;
case "CurrentRate":
createPullPayment.Currency = paymentMethodId.CryptoCode;
createPullPayment.Amount = model.CryptoAmountNow;
createPullPayment.AutoApproveClaims = true;
createPullPayment.AutoApproveClaims = authorizedForAutoApprove;
break;
case "Fiat":
@ -442,7 +458,7 @@ namespace BTCPayServer.Controllers
createPullPayment.Currency = model.CustomCurrency;
createPullPayment.Amount = model.CustomAmount;
createPullPayment.AutoApproveClaims = paymentMethodId.CryptoCode == model.CustomCurrency;
createPullPayment.AutoApproveClaims = authorizedForAutoApprove && paymentMethodId.CryptoCode == model.CustomCurrency;
break;
default:
@ -477,7 +493,6 @@ namespace BTCPayServer.Controllers
private InvoiceDetailsModel InvoicePopulatePayments(InvoiceEntity invoice)
{
var overpaid = false;
var model = new InvoiceDetailsModel
{
@ -500,15 +515,11 @@ namespace BTCPayServer.Controllers
{
PaymentMethodId = paymentMethodId,
PaymentMethod = paymentMethodId.ToPrettyString(),
Due = _CurrencyNameTable.DisplayFormatCurrency(accounting.Due.ToDecimal(MoneyUnit.BTC),
paymentMethodId.CryptoCode),
Paid = _CurrencyNameTable.DisplayFormatCurrency(
accounting.CryptoPaid.ToDecimal(MoneyUnit.BTC),
paymentMethodId.CryptoCode),
Overpaid = _CurrencyNameTable.DisplayFormatCurrency(
overpaidAmount, paymentMethodId.CryptoCode),
Due = _displayFormatter.Currency(accounting.Due.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode),
Paid = _displayFormatter.Currency(accounting.CryptoPaid.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode),
Overpaid = _displayFormatter.Currency(overpaidAmount, paymentMethodId.CryptoCode),
Address = data.GetPaymentMethodDetails().GetPaymentDestination(),
Rate = ExchangeRate(data),
Rate = ExchangeRate(data.GetId().CryptoCode, data),
PaymentMethodRaw = data
};
}).ToList()
@ -789,14 +800,15 @@ namespace BTCPayServer.Controllers
BrandColor = storeBlob.BrandColor,
CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType,
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",
CelebratePayment = storeBlob.CelebratePayment,
OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback,
CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)),
BtcAddress = paymentMethodDetails.GetPaymentDestination(),
BtcDue = accounting.Due.ShowMoney(divisibility),
BtcPaid = accounting.Paid.ShowMoney(divisibility),
InvoiceCurrency = invoice.Currency,
OrderAmount = (accounting.TotalDue - accounting.NetworkFee).ShowMoney(divisibility),
IsUnsetTopUp = invoice.IsUnsetTopUp(),
OrderAmountFiat = OrderAmountFromInvoice(network.CryptoCode, invoice),
CustomerEmail = invoice.RefundMail,
RequiresRefundEmail = invoice.RequiresRefundEmail ?? storeBlob.RequiresRefundEmail,
ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds),
@ -804,7 +816,7 @@ namespace BTCPayServer.Controllers
MaxTimeSeconds = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalSeconds,
MaxTimeMinutes = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalMinutes,
ItemDesc = invoice.Metadata.ItemDesc,
Rate = ExchangeRate(paymentMethod),
Rate = ExchangeRate(network.CryptoCode, paymentMethod, DisplayFormatter.CurrencyFormat.Symbol),
MerchantRefLink = invoice.RedirectURL?.AbsoluteUri ?? receiptUrl ?? "/",
ReceiptLink = receiptUrl,
RedirectAutomatically = invoice.RedirectAutomatically,
@ -817,7 +829,15 @@ namespace BTCPayServer.Controllers
NetworkFeeMode.Never => 0,
_ => throw new NotImplementedException()
},
BtcPaid = accounting.Paid.ShowMoney(divisibility),
RequiredConfirmations = invoice.SpeedPolicy switch
{
SpeedPolicy.HighSpeed => 0,
SpeedPolicy.MediumSpeed => 1,
SpeedPolicy.LowMediumSpeed => 2,
SpeedPolicy.LowSpeed => 6,
_ => null
},
ReceivedConfirmations = invoice.GetAllBitcoinPaymentData(false).FirstOrDefault()?.ConfirmationCount,
#pragma warning disable CS0618 // Type or member is obsolete
Status = invoice.StatusString,
#pragma warning restore CS0618 // Type or member is obsolete
@ -864,23 +884,33 @@ namespace BTCPayServer.Controllers
model.UISettings = paymentMethodHandler.GetCheckoutUISettings();
model.PaymentMethodId = paymentMethodId.ToString();
model.PaymentType = paymentMethodId.PaymentType.ToString();
model.OrderAmountFiat = OrderAmountFromInvoice(model.CryptoCode, invoice, DisplayFormatter.CurrencyFormat.Symbol);
var expiration = TimeSpan.FromSeconds(model.ExpirationSeconds);
model.TimeLeft = expiration.PrettyPrint();
return model;
}
private string? OrderAmountFromInvoice(string cryptoCode, InvoiceEntity invoiceEntity)
private string? OrderAmountFromInvoice(string cryptoCode, InvoiceEntity invoiceEntity, DisplayFormatter.CurrencyFormat format = DisplayFormatter.CurrencyFormat.Code)
{
var currency = invoiceEntity.Currency;
var crypto = cryptoCode.ToUpperInvariant(); // uppercase to make comparison easier, might be "sats"
// if invoice source currency is the same as currently display currency, no need for "order amount from invoice"
if (cryptoCode == invoiceEntity.Currency)
if (crypto == currency || (crypto == "SATS" && currency == "BTC") || (crypto == "BTC" && currency == "SATS"))
return null;
return _CurrencyNameTable.DisplayFormatCurrency(invoiceEntity.Price, invoiceEntity.Currency);
return _displayFormatter.Currency(invoiceEntity.Price, currency, format);
}
private string ExchangeRate(PaymentMethod paymentMethod)
private string? ExchangeRate(string cryptoCode, PaymentMethod paymentMethod, DisplayFormatter.CurrencyFormat format = DisplayFormatter.CurrencyFormat.Code)
{
string currency = paymentMethod.ParentEntity.Currency;
return _CurrencyNameTable.DisplayFormatCurrency(paymentMethod.Rate, currency);
var currency = paymentMethod.ParentEntity.Currency;
var crypto = cryptoCode.ToUpperInvariant(); // uppercase to make comparison easier, might be "sats"
if (crypto == currency || (crypto == "SATS" && currency == "BTC") || (crypto == "BTC" && currency == "SATS"))
return null;
return _displayFormatter.Currency(paymentMethod.Rate, currency, format);
}
[HttpGet("i/{invoiceId}/status")]
@ -1003,7 +1033,8 @@ namespace BTCPayServer.Controllers
InvoiceId = invoice.Id,
OrderId = invoice.Metadata.OrderId ?? string.Empty,
RedirectUrl = invoice.RedirectURL?.AbsoluteUri ?? string.Empty,
AmountCurrency = _CurrencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency),
Amount = invoice.Price,
Currency = invoice.Currency,
CanMarkInvalid = state.CanMarkInvalid(),
CanMarkSettled = state.CanMarkComplete(),
Details = InvoicePopulatePayments(invoice),

@ -24,6 +24,7 @@ using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -45,6 +46,7 @@ namespace BTCPayServer.Controllers
readonly StoreRepository _StoreRepository;
readonly UserManager<ApplicationUser> _UserManager;
private readonly CurrencyNameTable _CurrencyNameTable;
private readonly DisplayFormatter _displayFormatter;
readonly EventAggregator _EventAggregator;
readonly BTCPayNetworkProvider _NetworkProvider;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
@ -55,12 +57,14 @@ namespace BTCPayServer.Controllers
private readonly UIWalletsController _walletsController;
private readonly InvoiceActivator _invoiceActivator;
private readonly LinkGenerator _linkGenerator;
private readonly IAuthorizationService _authorizationService;
public WebhookSender WebhookNotificationManager { get; }
public UIInvoiceController(
InvoiceRepository invoiceRepository,
WalletRepository walletRepository,
DisplayFormatter displayFormatter,
CurrencyNameTable currencyNameTable,
UserManager<ApplicationUser> userManager,
RateFetcher rateProvider,
@ -76,8 +80,10 @@ namespace BTCPayServer.Controllers
ExplorerClientProvider explorerClients,
UIWalletsController walletsController,
InvoiceActivator invoiceActivator,
LinkGenerator linkGenerator)
LinkGenerator linkGenerator,
IAuthorizationService authorizationService)
{
_displayFormatter = displayFormatter;
_CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
_StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository));
_InvoiceRepository = invoiceRepository ?? throw new ArgumentNullException(nameof(invoiceRepository));
@ -95,6 +101,7 @@ namespace BTCPayServer.Controllers
_walletsController = walletsController;
_invoiceActivator = invoiceActivator;
_linkGenerator = linkGenerator;
_authorizationService = authorizationService;
}
@ -210,7 +217,8 @@ namespace BTCPayServer.Controllers
return await CreateInvoiceCoreRaw(invoiceRequest, storeData, request.GetAbsoluteRoot(), additionalTags, cancellationToken);
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string>? additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
[NonAction]
public async Task<InvoiceEntity> CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string>? additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice();

@ -6,6 +6,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
@ -18,6 +19,8 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@ -47,13 +50,13 @@ namespace BTCPayServer
private readonly LightningLikePaymentHandler _lightningLikePaymentHandler;
private readonly StoreRepository _storeRepository;
private readonly AppService _appService;
private readonly UIInvoiceController _invoiceController;
private readonly LinkGenerator _linkGenerator;
private readonly LightningAddressService _lightningAddressService;
private readonly LightningLikePayoutHandler _lightningLikePayoutHandler;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly IPluginHookService _pluginHookService;
public UILNURLController(InvoiceRepository invoiceRepository,
EventAggregator eventAggregator,
@ -66,7 +69,8 @@ namespace BTCPayServer
LightningAddressService lightningAddressService,
LightningLikePayoutHandler lightningLikePayoutHandler,
PullPaymentHostedService pullPaymentHostedService,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
IPluginHookService pluginHookService)
{
_invoiceRepository = invoiceRepository;
_eventAggregator = eventAggregator;
@ -80,6 +84,7 @@ namespace BTCPayServer
_lightningLikePayoutHandler = lightningLikePayoutHandler;
_pullPaymentHostedService = pullPaymentHostedService;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_pluginHookService = pluginHookService;
}
[HttpGet("withdraw/pp/{pullPaymentId}")]
@ -155,6 +160,7 @@ namespace BTCPayServer
if (claimResponse.Result != ClaimRequest.ClaimResult.Ok)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Payment request could not be paid" });
switch (claimResponse.PayoutData.State)
{
case PayoutState.AwaitingPayment:
@ -249,37 +255,49 @@ namespace BTCPayServer
return NotFound();
}
ViewPointOfSaleViewModel.Item[] items = null;
string currencyCode = null;
ViewPointOfSaleViewModel.Item[] items;
string currencyCode;
PointOfSaleSettings posS = null;
switch (app.AppType)
{
case nameof(AppType.Crowdfund):
case CrowdfundAppType.AppType:
var cfS = app.GetSettings<CrowdfundSettings>();
currencyCode = cfS.TargetCurrency;
items = _appService.Parse(cfS.PerksTemplate, cfS.TargetCurrency);
break;
case nameof(AppType.PointOfSale):
var posS = app.GetSettings<PointOfSaleSettings>();
case PointOfSaleAppType.AppType:
posS = app.GetSettings<PointOfSaleSettings>();
currencyCode = posS.Currency;
items = _appService.Parse(posS.Template, posS.Currency);
break;
default:
//TODO: Allow other apps to define lnurl support
return NotFound();
}
var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode);
var item = items.FirstOrDefault(item1 =>
item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) ||
item1.Id.Equals(escapedItemId, StringComparison.InvariantCultureIgnoreCase));
ViewPointOfSaleViewModel.Item item = null;
if (!string.IsNullOrEmpty(itemCode))
{
var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode);
item = items.FirstOrDefault(item1 =>
item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) ||
item1.Id.Equals(escapedItemId, StringComparison.InvariantCultureIgnoreCase));
if (item is null ||
item.Inventory <= 0 ||
(item.PaymentMethods?.Any() is true &&
item.PaymentMethods?.Any(s => PaymentMethodId.Parse(s) == pmi) is false))
if (item is null ||
item.Inventory <= 0 ||
(item.PaymentMethods?.Any() is true &&
item.PaymentMethods?.Any(s => PaymentMethodId.Parse(s) == pmi) is false))
{
return NotFound();
}
}
else if (app.AppType == PointOfSaleAppType.AppType && posS?.ShowCustomAmount is not true)
{
return NotFound();
}
return await GetLNURL(cryptoCode, app.StoreDataId, currencyCode, null, null,
() => (null, app, item, new List<string> { AppService.GetAppInternalTag(appId) }, item.Price.Value, true));
() => (null, app, item, new List<string> { AppService.GetAppInternalTag(appId) }, item?.Price.Value, true));
}
public class EditLightningAddressVM
@ -311,11 +329,8 @@ namespace BTCPayServer
public decimal? Max { get; set; }
}
public ConcurrentDictionary<string, LightningAddressItem> Items { get; set; } =
new ConcurrentDictionary<string, LightningAddressItem>();
public ConcurrentDictionary<string, string[]> StoreToItemMap { get; set; } =
new ConcurrentDictionary<string, string[]>();
public ConcurrentDictionary<string, LightningAddressItem> Items { get; } = new ();
public ConcurrentDictionary<string, string[]> StoreToItemMap { get; } = new ();
public override string ToString()
{
@ -389,7 +404,7 @@ namespace BTCPayServer
var redirectUrl = app?.AppType switch
{
nameof(AppType.PointOfSale) => app.GetSettings<PointOfSaleSettings>().RedirectUrl ??
PointOfSaleAppType.AppType => app.GetSettings<PointOfSaleSettings>().RedirectUrl ??
HttpContext.Request.GetAbsoluteUri($"/apps/{app.Id}/pos"),
_ => null
};
@ -442,31 +457,36 @@ namespace BTCPayServer
await _invoiceRepository.UpdateInvoicePaymentMethod(i.Id, pm);
}
var description = blob.LightningDescriptionTemplate
var invoiceDescription = blob.LightningDescriptionTemplate
.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{ItemDescription}", i.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{OrderId}", i.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase);
lnurlMetadata.Add(new[] { "text/plain", description });
lnurlMetadata.Add(new[] { "text/plain", invoiceDescription });
if (!string.IsNullOrEmpty(username))
{
lnurlMetadata.Add(new[] { "text/identifier", lnAddress });
}
return Ok(new LNURLPayRequest
if (await _pluginHookService.ApplyFilter("modify-lnurlp-request", new LNURLPayRequest
{
Tag = "payRequest",
MinSendable = new LightMoney(min ?? 1m, LightMoneyUnit.Satoshi),
MaxSendable =
max is null
? LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC)
: new LightMoney(max.Value, LightMoneyUnit.Satoshi),
CommentAllowed = lnUrlMethod.LUD12Enabled ? 2000 : 0,
Metadata = JsonConvert.SerializeObject(lnurlMetadata),
Callback = new Uri(_linkGenerator.GetUriByAction(
action: nameof(GetLNURLForInvoice),
controller: "UILNURL",
values: new {cryptoCode, invoiceId = i.Id}, Request.Scheme, Request.Host, Request.PathBase))
}) is not LNURLPayRequest lnurlp)
{
Tag = "payRequest",
MinSendable = new LightMoney(min ?? 1m, LightMoneyUnit.Satoshi),
MaxSendable =
max is null
? LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC)
: new LightMoney(max.Value, LightMoneyUnit.Satoshi),
CommentAllowed = lnUrlMethod.LUD12Enabled ? 2000 : 0,
Metadata = JsonConvert.SerializeObject(lnurlMetadata),
Callback = new Uri(_linkGenerator.GetUriByAction(
action: nameof(GetLNURLForInvoice),
controller: "UILNURL",
values: new { cryptoCode, invoiceId = i.Id }, Request.Scheme, Request.Host, Request.PathBase))
});
return NotFound();
}
return Ok(lnurlp);
}
[HttpGet("pay/i/{invoiceId}")]
@ -519,12 +539,12 @@ namespace BTCPayServer
List<string[]> lnurlMetadata = new();
var blob = store.GetStoreBlob();
var description = blob.LightningDescriptionTemplate
var invoiceDescription = blob.LightningDescriptionTemplate
.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{ItemDescription}", i.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{OrderId}", i.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase);
lnurlMetadata.Add(new[] { "text/plain", description });
lnurlMetadata.Add(new[] { "text/plain", invoiceDescription });
if (!string.IsNullOrEmpty(paymentMethodDetails.ConsumedLightningAddress))
{
lnurlMetadata.Add(new[] { "text/identifier", paymentMethodDetails.ConsumedLightningAddress });
@ -556,15 +576,20 @@ namespace BTCPayServer
if (amt is null)
{
return Ok(new LNURLPayRequest
if (await _pluginHookService.ApplyFilter("modify-lnurlp-request", new LNURLPayRequest
{
Tag = "payRequest",
MinSendable = min,
MaxSendable = max,
CommentAllowed = lnurlSupportedPaymentMethod.LUD12Enabled ? 2000 : 0,
Metadata = metadata,
Callback = new Uri(Request.GetCurrentUrl())
}) is not LNURLPayRequest lnurlp)
{
Tag = "payRequest",
MinSendable = min,
MaxSendable = max,
CommentAllowed = lnurlSupportedPaymentMethod.LUD12Enabled ? 2000 : 0,
Metadata = metadata,
Callback = new Uri(Request.GetCurrentUrl())
});
return NotFound();
}
return Ok(lnurlp);
}
if (string.IsNullOrEmpty(paymentMethodDetails.BOLT11) || paymentMethodDetails.GeneratedBoltAmount != amt)
@ -588,14 +613,19 @@ namespace BTCPayServer
try
{
var expiry = i.ExpirationTime.ToUniversalTime() - DateTimeOffset.UtcNow;
var param = new CreateInvoiceParams(amt, metadata, expiry)
var description = (await _pluginHookService.ApplyFilter("modify-lnurlp-description", metadata)) as string;
if (description is null)
{
return NotFound();
}
var param = new CreateInvoiceParams(amt, description, expiry)
{
PrivateRouteHints = blob.LightningPrivateRouteHints,
DescriptionHashOnly = true
};
invoice = await client.CreateInvoice(param);
if (!BOLT11PaymentRequest.Parse(invoice.BOLT11, network.NBitcoinNetwork)
.VerifyDescriptionHash(metadata))
.VerifyDescriptionHash(description))
{
return BadRequest(new LNUrlStatusResponse
{

@ -14,6 +14,7 @@ using BTCPayServer.Forms.Models;
using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
@ -37,6 +38,7 @@ namespace BTCPayServer.Controllers
private readonly PaymentRequestService _PaymentRequestService;
private readonly EventAggregator _EventAggregator;
private readonly CurrencyNameTable _Currencies;
private readonly DisplayFormatter _displayFormatter;
private readonly InvoiceRepository _InvoiceRepository;
private readonly StoreRepository _storeRepository;
@ -50,6 +52,7 @@ namespace BTCPayServer.Controllers
PaymentRequestService paymentRequestService,
EventAggregator eventAggregator,
CurrencyNameTable currencies,
DisplayFormatter displayFormatter,
StoreRepository storeRepository,
InvoiceRepository invoiceRepository,
FormComponentProviders formProviders,
@ -61,6 +64,7 @@ namespace BTCPayServer.Controllers
_PaymentRequestService = paymentRequestService;
_EventAggregator = eventAggregator;
_Currencies = currencies;
_displayFormatter = displayFormatter;
_storeRepository = storeRepository;
_InvoiceRepository = invoiceRepository;
FormProviders = formProviders;
@ -89,7 +93,7 @@ namespace BTCPayServer.Controllers
var blob = data.GetBlob();
return new ViewPaymentRequestViewModel(data)
{
AmountFormatted = _Currencies.DisplayFormatCurrency(blob.Amount, blob.Currency)
AmountFormatted = _displayFormatter.Currency(blob.Amount, blob.Currency)
};
}).ToList();

@ -5,7 +5,6 @@ using System.Web;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Plugins.PayButton.Models;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Cors;

@ -27,6 +27,7 @@ namespace BTCPayServer.Controllers
{
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly CurrencyNameTable _currencyNameTable;
private readonly DisplayFormatter _displayFormatter;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
@ -34,6 +35,7 @@ namespace BTCPayServer.Controllers
public UIPullPaymentController(ApplicationDbContextFactory dbContextFactory,
CurrencyNameTable currencyNameTable,
DisplayFormatter displayFormatter,
PullPaymentHostedService pullPaymentHostedService,
BTCPayNetworkJsonSerializerSettings serializerSettings,
IEnumerable<IPayoutHandler> payoutHandlers,
@ -41,6 +43,7 @@ namespace BTCPayServer.Controllers
{
_dbContextFactory = dbContextFactory;
_currencyNameTable = currencyNameTable;
_displayFormatter = displayFormatter;
_pullPaymentHostedService = pullPaymentHostedService;
_serializerSettings = serializerSettings;
_payoutHandlers = payoutHandlers;
@ -79,12 +82,9 @@ namespace BTCPayServer.Controllers
{
BrandColor = storeBlob.BrandColor,
CssFileId = storeBlob.CssFileId,
AmountFormatted = _currencyNameTable.FormatCurrency(blob.Limit, blob.Currency),
AmountCollected = totalPaid,
AmountCollectedFormatted = _currencyNameTable.FormatCurrency(totalPaid, blob.Currency),
AmountDue = amountDue,
ClaimedAmount = amountDue,
AmountDueFormatted = _currencyNameTable.FormatCurrency(amountDue, blob.Currency),
CurrencyData = cd,
StartDate = pp.StartDate,
LastRefreshed = DateTime.UtcNow,
@ -93,7 +93,6 @@ namespace BTCPayServer.Controllers
{
Id = entity.Entity.Id,
Amount = entity.Blob.Amount,
AmountFormatted = _currencyNameTable.FormatCurrency(entity.Blob.Amount, blob.Currency),
Currency = blob.Currency,
Status = entity.Entity.State,
Destination = entity.Blob.Destination,
@ -200,8 +199,8 @@ namespace BTCPayServer.Controllers
var amount = ppBlob.Currency == "SATS" ? new Money(vm.ClaimedAmount, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) : vm.ClaimedAmount;
if (destination.destination.Amount != null && amount != destination.destination.Amount)
{
var implied = _currencyNameTable.DisplayFormatCurrency(destination.destination.Amount.Value, paymentMethodId.CryptoCode);
var provided = _currencyNameTable.DisplayFormatCurrency(vm.ClaimedAmount, ppBlob.Currency);
var implied = _displayFormatter.Currency(destination.destination.Amount.Value, paymentMethodId.CryptoCode, DisplayFormatter.CurrencyFormat.Symbol);
var provided = _displayFormatter.Currency(vm.ClaimedAmount, ppBlob.Currency, DisplayFormatter.CurrencyFormat.Symbol);
ModelState.AddModelError(nameof(vm.ClaimedAmount),
$"Amount implied in destination ({implied}) does not match the payout amount provided ({provided}).");
}
@ -235,7 +234,7 @@ namespace BTCPayServer.Controllers
TempData.SetStatusMessageModel(new StatusMessageModel
{
Message = $"Your claim request of {_currencyNameTable.DisplayFormatCurrency(vm.ClaimedAmount, ppBlob.Currency)} to {vm.Destination} has been submitted and is awaiting {(result.PayoutData.State == PayoutState.AwaitingApproval ? "approval" : "payment")}.",
Message = $"Your claim request of {_displayFormatter.Currency(vm.ClaimedAmount, ppBlob.Currency, DisplayFormatter.CurrencyFormat.Symbol)} to {vm.Destination} has been submitted and is awaiting {(result.PayoutData.State == PayoutState.AwaitingApproval ? "approval" : "payment")}.",
Severity = StatusMessageModel.StatusSeverity.Success
});

@ -347,7 +347,7 @@ namespace BTCPayServer.Controllers
if (appIdsToFetch.Any())
{
var apps = (await _AppService.GetApps(appIdsToFetch.ToArray()))
.ToDictionary(data => data.Id, data => Enum.Parse<AppType>(data.AppType));
.ToDictionary(data => data.Id, data => data.AppType);
;
if (!string.IsNullOrEmpty(settings.RootAppId))
{
@ -422,8 +422,10 @@ namespace BTCPayServer.Controllers
private async Task<List<SelectListItem>> GetAppSelectList()
{
var types = _AppService.GetAvailableAppTypes();
var apps = (await _AppService.GetAllApps(null, true))
.Select(a => new SelectListItem($"{typeof(AppType).DisplayName(a.AppType)} - {a.AppName} - {a.StoreName}", a.Id)).ToList();
.Select(a =>
new SelectListItem($"{types[a.AppType]} - {a.AppName} - {a.StoreName}", a.Id)).ToList();
apps.Insert(0, new SelectListItem("(None)", null));
return apps;
}

@ -34,6 +34,7 @@ namespace BTCPayServer.Controllers
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly CurrencyNameTable _currencyNameTable;
private readonly DisplayFormatter _displayFormatter;
private readonly PullPaymentHostedService _pullPaymentService;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
@ -49,6 +50,7 @@ namespace BTCPayServer.Controllers
public UIStorePullPaymentsController(BTCPayNetworkProvider btcPayNetworkProvider,
IEnumerable<IPayoutHandler> payoutHandlers,
CurrencyNameTable currencyNameTable,
DisplayFormatter displayFormatter,
PullPaymentHostedService pullPaymentHostedService,
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings)
@ -56,6 +58,7 @@ namespace BTCPayServer.Controllers
_btcPayNetworkProvider = btcPayNetworkProvider;
_payoutHandlers = payoutHandlers;
_currencyNameTable = currencyNameTable;
_displayFormatter = displayFormatter;
_pullPaymentService = pullPaymentHostedService;
_dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings;
@ -532,7 +535,7 @@ namespace BTCPayServer.Controllers
PullPaymentName = ppBlob?.Name ?? item.PullPayment?.Id,
Date = item.Payout.Date,
PayoutId = item.Payout.Id,
Amount = _currencyNameTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob?.Currency ?? PaymentMethodId.Parse(item.Payout.PaymentMethodId).CryptoCode),
Amount = _displayFormatter.Currency(payoutBlob.Amount, ppBlob?.Currency ?? PaymentMethodId.Parse(item.Payout.PaymentMethodId).CryptoCode),
Destination = payoutBlob.Destination
};
var handler = _payoutHandlers

@ -42,6 +42,8 @@ namespace BTCPayServer.Controllers
return View(vm);
var userId = GetUserId();
if (userId is null)
return NotFound();
var apps = await _appService.GetAllApps(userId, false, store.Id);
foreach (var app in apps)
{

@ -16,12 +16,6 @@ namespace BTCPayServer.Controllers
{
public partial class UIStoresController
{
[HttpGet("{storeId}/plugins")]
public IActionResult Plugins()
{
return View("Plugins", new PluginsViewModel());
}
private async Task<Data.WebhookDeliveryData?> LastDeliveryForWebhook(string webhookId)
{
return (await _Repo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 1)).ToList().FirstOrDefault();

@ -27,7 +27,6 @@ using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -387,7 +386,9 @@ namespace BTCPayServer.Controllers
}).ToList();
vm.UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2;
vm.CelebratePayment = storeBlob.CelebratePayment;
vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback;
vm.LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi;
vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail;
vm.LazyPaymentMethods = storeBlob.LazyPaymentMethods;
vm.RedirectAutomatically = storeBlob.RedirectAutomatically;
@ -505,8 +506,9 @@ namespace BTCPayServer.Controllers
}
blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1;
blob.CelebratePayment = model.CelebratePayment;
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi;
blob.RequiresRefundEmail = model.RequiresRefundEmail;
blob.LazyPaymentMethods = model.LazyPaymentMethods;
blob.RedirectAutomatically = model.RedirectAutomatically;

@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.BIP78.Sender;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
@ -266,7 +267,7 @@ namespace BTCPayServer.Controllers
ModelState.Remove(nameof(vm.PSBT));
ModelState.Remove(nameof(vm.FileName));
ModelState.Remove(nameof(vm.UploadedPSBTFile));
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
return View("WalletPSBTDecoded", vm);
case "save-psbt":
@ -320,7 +321,7 @@ namespace BTCPayServer.Controllers
return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token);
}
private async Task FetchTransactionDetails(DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
private async Task FetchTransactionDetails(WalletId walletId, DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network)
{
var psbtObject = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork);
if (!psbtObject.IsAllFinalized())
@ -371,17 +372,29 @@ namespace BTCPayServer.Controllers
vm.Positive = balanceChange >= Money.Zero;
}
vm.Inputs = new List<WalletPSBTReadyViewModel.InputViewModel>();
var inputToObjects = new Dictionary<uint, ObjectTypeId[]>();
var outputToObjects = new Dictionary<string, ObjectTypeId>();
foreach (var input in psbtObject.Inputs)
{
var inputVm = new WalletPSBTReadyViewModel.InputViewModel();
vm.Inputs.Add(inputVm);
var txOut = input.GetTxOut();
var mine = input.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any();
var balanceChange2 = input.GetTxOut()?.Value ?? Money.Zero;
var balanceChange2 = txOut?.Value ?? Money.Zero;
if (mine)
balanceChange2 = -balanceChange2;
inputVm.BalanceChange = ValueToString(balanceChange2, network);
inputVm.Positive = balanceChange2 >= Money.Zero;
inputVm.Index = (int)input.Index;
var walletObjectIds = new List<ObjectTypeId>();
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Utxo, input.PrevOut.ToString()));
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Tx, input.PrevOut.Hash.ToString()));
var address = txOut?.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
if(address != null)
walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Address, address));
inputToObjects.Add(input.Index, walletObjectIds.ToArray());
}
vm.Destinations = new List<WalletPSBTReadyViewModel.DestinationViewModel>();
foreach (var output in psbtObject.Outputs)
@ -395,6 +408,10 @@ namespace BTCPayServer.Controllers
dest.Balance = ValueToString(balanceChange2, network);
dest.Positive = balanceChange2 >= Money.Zero;
dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString();
var address = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
if(address != null)
outputToObjects.Add(dest.Destination, new ObjectTypeId(WalletObjectData.Types.Address, address));
}
if (psbtObject.TryGetFee(out var fee))
@ -420,6 +437,38 @@ namespace BTCPayServer.Controllers
{
vm.SetErrors(errors);
}
var combinedTypeIds = inputToObjects.Values.SelectMany(ids => ids).Concat(outputToObjects.Values)
.DistinctBy(id => $"{id.Type}:{id.Id}").ToArray();
var labelInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, combinedTypeIds);
foreach (KeyValuePair<uint,ObjectTypeId[]> inputToObject in inputToObjects)
{
var keys = inputToObject.Value.Select(id => id.Id).ToArray();
WalletTransactionInfo ix = null;
foreach (var key in keys)
{
if (!labelInfo.TryGetValue(key, out var i)) continue;
if (ix is null)
{
ix = i;
}
else
{
ix.Merge(i);
}
}
if (ix is null) continue;
var input = vm.Inputs.First(model => model.Index == inputToObject.Key);
input.Labels = ix.LabelColors;
}
foreach (var outputToObject in outputToObjects)
{
if (!labelInfo.TryGetValue(outputToObject.Value.Id, out var ix)) continue;
var destination = vm.Destinations.First(model => model.Destination == outputToObject.Key);
destination.Labels = ix.LabelColors;
}
}
[HttpPost("{walletId}/psbt/ready")]
@ -439,7 +488,7 @@ namespace BTCPayServer.Controllers
if (derivationSchemeSettings == null)
return NotFound();
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
switch (command)
{
@ -570,7 +619,7 @@ namespace BTCPayServer.Controllers
BackUrl = vm.BackUrl
});
case "decode":
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network);
return View("WalletPSBTDecoded", vm);
default:
vm.Errors.Add("Unknown command");

@ -235,8 +235,7 @@ namespace BTCPayServer.Controllers
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
model.Labels.AddRange(
(await WalletRepository.GetWalletLabels(walletId))
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color)))
);
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
if (labelFilter != null)
{
@ -733,6 +732,18 @@ namespace BTCPayServer.Controllers
if (!ModelState.IsValid)
return View(vm);
foreach (var transactionOutput in vm.Outputs.Where(output => output.Labels?.Any() is true))
{
var labels = transactionOutput.Labels.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress.ToLowerInvariant());
var obj = await WalletRepository.GetWalletObject(walletObjectAddress);
if (obj is null)
{
await WalletRepository.EnsureWalletObject(walletObjectAddress);
}
await WalletRepository.AddWalletObjectLabels(walletObjectAddress, labels);
}
var derivationScheme = GetDerivationSchemeSettings(walletId);
if (derivationScheme is null)
return NotFound();
@ -1306,36 +1317,51 @@ namespace BTCPayServer.Controllers
var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null);
var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, null, null);
var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation);
var walletTransactionsInfo = await walletTransactionsInfoAsync;
var export = new TransactionsExport(wallet, walletTransactionsInfo);
var res = export.Process(input, format);
var fileType = format switch
{
"csv" => "csv",
"json" => "json",
"bip329" => "jsonl",
_ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
};
var mimeType = format switch
{
"csv" => "text/csv",
"json" => "application/json",
"bip329" => "text/jsonl", // https://stackoverflow.com/questions/59938644/what-is-the-mime-type-of-jsonl-files
_ => throw new ArgumentOutOfRangeException(nameof(format), format, null)
};
var cd = new ContentDisposition
{
FileName = $"btcpay-{walletId}-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.{format}",
FileName = $"btcpay-{walletId}-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.{fileType}",
Inline = true
};
Response.Headers.Add("Content-Disposition", cd.ToString());
Response.Headers.Add("X-Content-Type-Options", "nosniff");
return Content(res, "application/" + format);
return Content(res, mimeType);
}
public class UpdateLabelsRequest
{
public string? Address { get; set; }
public string? Id { get; set; }
public string? Type { get; set; }
public string[]? Labels { get; set; }
}
[HttpPost("{walletId}/update-labels")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> UpdateLabels([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, [FromBody] UpdateLabelsRequest request)
public async Task<IActionResult> UpdateLabels(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
[FromBody] UpdateLabelsRequest request)
{
if (string.IsNullOrEmpty(request.Address) || request.Labels is null)
if (string.IsNullOrEmpty(request.Type) || string.IsNullOrEmpty(request.Id) || request.Labels is null)
return BadRequest();
var objid = new WalletObjectId(walletId, WalletObjectData.Types.Address, request.Address);
var objid = new WalletObjectId(walletId, request.Type, request.Id);
var obj = await WalletRepository.GetWalletObject(objid);
if (obj is null)
{
@ -1353,17 +1379,26 @@ namespace BTCPayServer.Controllers
[HttpGet("{walletId}/labels")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> GetLabels( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, bool excludeTypes)
public async Task<IActionResult> GetLabels(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
bool excludeTypes,
string? type = null,
string? id = null)
{
return Ok(( await WalletRepository.GetWalletLabels(walletId))
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
.Select(tuple => new
{
label = tuple.Label,
color = tuple.Color,
textColor = ColorPalette.Default.TextColor(tuple.Color)
}));
var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id)
? new WalletObjectId(walletId, type, id)
: null;
var labels = walletObjectId == null
? await WalletRepository.GetWalletLabels(walletId)
: await WalletRepository.GetWalletLabels(walletObjectId);
return Ok(labels
.Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label))
.Select(tuple => new
{
label = tuple.Label,
color = tuple.Color,
textColor = ColorPalette.Default.TextColor(tuple.Color)
}));
}
private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network)
@ -1433,8 +1468,7 @@ namespace BTCPayServer.Controllers
{
0 => PayoutTooltip(),
1 => PayoutTooltip(payoutsByPullPaymentId.First()),
_ =>
$"<ul>{string.Join(string.Empty, payoutsByPullPaymentId.Select(pair => $"<li>{PayoutTooltip(pair)}</li>"))}</ul>"
_ => string.Join(", ", payoutsByPullPaymentId.Select(PayoutTooltip))
};
model.Link = _linkGenerator.PayoutLink(transactionInfo.WalletId.ToString(), null, PayoutState.Completed, Request.Scheme, Request.Host,
@ -1442,7 +1476,7 @@ namespace BTCPayServer.Controllers
}
else if (tag.Type == WalletObjectData.Types.Payjoin)
{
model.Tooltip = $"This UTXO was part of a PayJoin transaction.";
model.Tooltip = "This UTXO was part of a PayJoin transaction.";
}
else if (tag.Type == WalletObjectData.Types.Invoice)
{

@ -218,6 +218,10 @@ namespace BTCPayServer.Data
public string BrandColor { get; set; }
public string LogoFileId { get; set; }
public string CssFileId { get; set; }
[DefaultValue(true)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool CelebratePayment { get; set; } = true;
public IPaymentFilter GetExcludedPaymentMethods()
{

@ -1,8 +1,10 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using BTCPayServer.Client;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services.Rates;
@ -14,6 +16,33 @@ namespace BTCPayServer.Data
{
public static class StoreDataExtensions
{
public static PermissionSet GetPermissionSet(this StoreData store)
{
ArgumentNullException.ThrowIfNull(store);
if (store.Role is null)
return new PermissionSet();
return new PermissionSet(store.Role == StoreRoles.Owner
? new[]
{
Permission.Create(Policies.CanModifyStoreSettings, store.Id),
Permission.Create(Policies.CanTradeCustodianAccount, store.Id),
Permission.Create(Policies.CanWithdrawFromCustodianAccounts, store.Id),
Permission.Create(Policies.CanDepositToCustodianAccounts, store.Id)
}
: new[]
{
Permission.Create(Policies.CanViewStoreSettings, store.Id),
Permission.Create(Policies.CanModifyInvoices, store.Id),
Permission.Create(Policies.CanViewCustodianAccounts, store.Id),
Permission.Create(Policies.CanDepositToCustodianAccounts, store.Id)
});
}
public static bool HasPermission(this StoreData store, string permission)
{
ArgumentNullException.ThrowIfNull(store);
return store.GetPermissionSet().Contains(permission, store.Id);
}
#pragma warning disable CS0618
public static PaymentMethodId? GetDefaultPaymentId(this StoreData storeData)
{

@ -49,6 +49,7 @@ namespace BTCPayServer
{
return AccountDerivation is null ? null : DBUtils.nbxv1_get_wallet_id(Network.CryptoCode, AccountDerivation.ToString());
}
private static bool TryParseXpub(string xpub, DerivationSchemeParser derivationSchemeParser, ref DerivationSchemeSettings derivationSchemeSettings, ref string error, bool electrum = true)
{
if (!electrum)
@ -78,15 +79,36 @@ namespace BTCPayServer
}
try
{
// Extract fingerprint and account key path from export formats that contain them.
// Possible formats: [fingerprint/account_key_path]xpub, [fingerprint]xpub, xpub
HDFingerprint? rootFingerprint = null;
KeyPath accountKeyPath = null;
var derivationRegex = new Regex(@"^(?:\[(\w+)(?:\/(.*?))?\])?(\w+)$", RegexOptions.IgnoreCase);
var match = derivationRegex.Match(xpub.Trim());
if (match.Success)
{
if (!string.IsNullOrEmpty(match.Groups[1].Value)) rootFingerprint = HDFingerprint.Parse(match.Groups[1].Value);
if (!string.IsNullOrEmpty(match.Groups[2].Value)) accountKeyPath = KeyPath.Parse(match.Groups[2].Value);
if (!string.IsNullOrEmpty(match.Groups[3].Value)) xpub = match.Groups[3].Value;
}
derivationSchemeSettings.AccountOriginal = xpub.Trim();
derivationSchemeSettings.AccountDerivation = electrum ? derivationSchemeParser.ParseElectrum(derivationSchemeSettings.AccountOriginal) : derivationSchemeParser.Parse(derivationSchemeSettings.AccountOriginal);
derivationSchemeSettings.AccountKeySettings = derivationSchemeSettings.AccountDerivation.GetExtPubKeys()
.Select(key => new AccountKeySettings()
.Select(key => new AccountKeySettings
{
AccountKey = key.GetWif(derivationSchemeParser.Network)
}).ToArray();
if (derivationSchemeSettings.AccountDerivation is DirectDerivationStrategy direct && !direct.Segwit)
derivationSchemeSettings.AccountOriginal = null; // Saving this would be confusing for user, as xpub of electrum is legacy derivation, but for btcpay, it is segwit derivation
// apply initial matches if there were no results from parsing
if (rootFingerprint != null && derivationSchemeSettings.AccountKeySettings[0].RootFingerprint == null)
{
derivationSchemeSettings.AccountKeySettings[0].RootFingerprint = rootFingerprint;
}
if (accountKeyPath != null && derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath == null)
{
derivationSchemeSettings.AccountKeySettings[0].AccountKeyPath = accountKeyPath;
}
return true;
}
catch (Exception exception)

Some files were not shown because too many files have changed in this diff Show More