Transfer Processors (#3476)

* Automated Transfer processors

This PR introduces a few things:
* Payouts can now be directly nested under a store instead of through a pull payment.
* The Wallet Send screen now has an option to "schedule" instead of simply creating a transaction. When you click on schedule, all transaction destinations are converted into approved payouts. Any options relating to fees or coin selection are discarded.
* There is a new concept introduced, called "Transfer Processors".  Transfer Processors are services for stores that process payouts that are awaiting payment. Each processor specifies which payment methods it can handle.  BTCPay Server will have some forms of transfer processors baked in but it has been designed to allow the Plugin System to provide additional processors.
* The initial transfer processors provided are "automated processors", for on chain and lightning payment methods. They can be configured to process payouts every X amount of minutes. For  on-chain, this means payments are batched into one transaction, resulting in more efficient and cheaper fees for processing.
*

* fix build

* extract

* remove magic string stuff

* fix error message when scheduling

* Paginate migration

* add payout count to payment method tab

* remove unused var

* add protip

* optimzie payout migration dramatically

* Remove useless double condition

* Fix bunch of warnings

* Remove warning

* Remove warnigns

* Rename to Payout processors

* fix typo

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri
2022-04-24 05:19:34 +02:00
committed by GitHub
parent 9ab129ba89
commit 51690b47a3
72 changed files with 3862 additions and 557 deletions

View File

@@ -33,7 +33,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.3" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="6.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="6.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,18 @@
#nullable enable
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<IEnumerable<PayoutProcessorData>> GetPayoutProcessors(
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/payout-processors"), token);
return await HandleResponse<IEnumerable<PayoutProcessorData>>(response);
}
}
}

View File

@@ -41,12 +41,23 @@ namespace BTCPayServer.Client
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/payouts", queryPayload: query, method: HttpMethod.Get), cancellationToken); var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/payouts", queryPayload: query, method: HttpMethod.Get), cancellationToken);
return await HandleResponse<PayoutData[]>(response); return await HandleResponse<PayoutData[]>(response);
} }
public virtual async Task<PayoutData[]> GetStorePayouts(string storeId, bool includeCancelled = false, CancellationToken cancellationToken = default)
{
Dictionary<string, object> query = new Dictionary<string, object>();
query.Add("includeCancelled", includeCancelled);
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payouts", queryPayload: query, method: HttpMethod.Get), cancellationToken);
return await HandleResponse<PayoutData[]>(response);
}
public virtual async Task<PayoutData> CreatePayout(string pullPaymentId, CreatePayoutRequest payoutRequest, CancellationToken cancellationToken = default) public virtual async Task<PayoutData> CreatePayout(string pullPaymentId, CreatePayoutRequest payoutRequest, CancellationToken cancellationToken = default)
{ {
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/payouts", bodyPayload: payoutRequest, method: HttpMethod.Post), cancellationToken); var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/payouts", bodyPayload: payoutRequest, method: HttpMethod.Post), cancellationToken);
return await HandleResponse<PayoutData>(response); return await HandleResponse<PayoutData>(response);
} }
public virtual async Task<PayoutData> CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest, CancellationToken cancellationToken = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payouts", bodyPayload: payoutRequest, method: HttpMethod.Post), cancellationToken);
return await HandleResponse<PayoutData>(response);
}
public virtual async Task CancelPayout(string storeId, string payoutId, CancellationToken cancellationToken = default) public virtual async Task CancelPayout(string storeId, string payoutId, CancellationToken cancellationToken = default)
{ {
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}", method: HttpMethod.Delete), cancellationToken); var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}", method: HttpMethod.Delete), cancellationToken);

View File

@@ -0,0 +1,48 @@
#nullable enable
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<IEnumerable<PayoutProcessorData>> GetPayoutProcessors(string storeId,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors"), token);
return await HandleResponse<IEnumerable<PayoutProcessorData>>(response);
}
public virtual async Task RemovePayoutProcessor(string storeId, string processor, string paymentMethod, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/{processor}/{paymentMethod}", null, HttpMethod.Delete), token);
await HandleResponse(response);
}
public virtual async Task<IEnumerable<LightningAutomatedPayoutSettings>> GetStoreLightningAutomatedPayoutProcessors(string storeId, string? paymentMethod = null,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory{(paymentMethod is null? string.Empty: $"/{paymentMethod}")}"), token);
return await HandleResponse<IEnumerable<LightningAutomatedPayoutSettings>>(response);
}
public virtual async Task<LightningAutomatedPayoutSettings> UpdateStoreLightningAutomatedPayoutProcessors(string storeId, string paymentMethod,LightningAutomatedPayoutSettings request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory/{paymentMethod}",null, request, HttpMethod.Put ), token);
return await HandleResponse<LightningAutomatedPayoutSettings>(response);
}
public virtual async Task<OnChainAutomatedPayoutSettings> UpdateStoreOnChainAutomatedPayoutProcessors(string storeId, string paymentMethod,OnChainAutomatedPayoutSettings request, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/OnChainAutomatedPayoutSenderFactory/{paymentMethod}",null, request, HttpMethod.Put ), token);
return await HandleResponse<OnChainAutomatedPayoutSettings>(response);
}
public virtual async Task<IEnumerable<OnChainAutomatedPayoutSettings>> GetStoreOnChainAutomatedPayoutProcessors(string storeId, string? paymentMethod = null,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/OnChainAutomatedPayoutSenderFactory{(paymentMethod is null? string.Empty: $"/{paymentMethod}")}"), token);
return await HandleResponse<IEnumerable<OnChainAutomatedPayoutSettings>>(response);
}
}
}

View File

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

View File

@@ -0,0 +1,8 @@
#nullable enable
namespace BTCPayServer.Client.Models;
public class CreatePayoutThroughStoreRequest : CreatePayoutRequest
{
public string? PullPaymentId { get; set; }
public bool Approved { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System;
using BTCPayServer.Client.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class LightningAutomatedPayoutSettings
{
public string PaymentMethod { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan IntervalSeconds { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System;
using BTCPayServer.Client.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class OnChainAutomatedPayoutSettings
{
public string PaymentMethod { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan IntervalSeconds { get; set; }
}

View File

@@ -1,4 +1,3 @@
#nullable enable
using BTCPayServer.Client.JsonConverters; using BTCPayServer.Client.JsonConverters;
using BTCPayServer.JsonConverters; using BTCPayServer.JsonConverters;
using NBitcoin; using NBitcoin;
@@ -15,6 +14,6 @@ namespace BTCPayServer.Client.Models
public float? MaxFeePercent { get; set; } public float? MaxFeePercent { get; set; }
[JsonConverter(typeof(MoneyJsonConverter))] [JsonConverter(typeof(MoneyJsonConverter))]
public Money? MaxFeeFlat { get; set; } public Money MaxFeeFlat { get; set; }
} }
} }

View File

@@ -0,0 +1,9 @@
namespace BTCPayServer.Client.Models
{
public class PayoutProcessorData
{
public string Name { get; set; }
public string FriendlyName { get; set; }
public string[] PaymentMethods { get; set; }
}
}

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Linq; using System.Linq;
using BTCPayServer.Data.Data;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
@@ -62,6 +63,7 @@ namespace BTCPayServer.Data
public DbSet<WebhookDeliveryData> WebhookDeliveries { get; set; } public DbSet<WebhookDeliveryData> WebhookDeliveries { get; set; }
public DbSet<WebhookData> Webhooks { get; set; } public DbSet<WebhookData> Webhooks { get; set; }
public DbSet<LightningAddressData> LightningAddresses{ get; set; } public DbSet<LightningAddressData> LightningAddresses{ get; set; }
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -88,7 +90,7 @@ namespace BTCPayServer.Data
InvoiceData.OnModelCreating(builder); InvoiceData.OnModelCreating(builder);
NotificationData.OnModelCreating(builder); NotificationData.OnModelCreating(builder);
//OffchainTransactionData.OnModelCreating(builder); //OffchainTransactionData.OnModelCreating(builder);
Data.PairedSINData.OnModelCreating(builder); BTCPayServer.Data.PairedSINData.OnModelCreating(builder);
PairingCodeData.OnModelCreating(builder); PairingCodeData.OnModelCreating(builder);
//PayjoinLock.OnModelCreating(builder); //PayjoinLock.OnModelCreating(builder);
PaymentRequestData.OnModelCreating(builder); PaymentRequestData.OnModelCreating(builder);
@@ -103,11 +105,12 @@ namespace BTCPayServer.Data
//StoreData.OnModelCreating(builder); //StoreData.OnModelCreating(builder);
U2FDevice.OnModelCreating(builder); U2FDevice.OnModelCreating(builder);
Fido2Credential.OnModelCreating(builder); Fido2Credential.OnModelCreating(builder);
Data.UserStore.OnModelCreating(builder); BTCPayServer.Data.UserStore.OnModelCreating(builder);
//WalletData.OnModelCreating(builder); //WalletData.OnModelCreating(builder);
WalletTransactionData.OnModelCreating(builder); WalletTransactionData.OnModelCreating(builder);
WebhookDeliveryData.OnModelCreating(builder); WebhookDeliveryData.OnModelCreating(builder);
LightningAddressData.OnModelCreating(builder); LightningAddressData.OnModelCreating(builder);
PayoutProcessorData.OnModelCreating(builder);
//WebhookData.OnModelCreating(builder); //WebhookData.OnModelCreating(builder);

View File

@@ -14,6 +14,7 @@ namespace BTCPayServer.Data
public string Id { get; set; } public string Id { get; set; }
public DateTimeOffset Date { get; set; } public DateTimeOffset Date { get; set; }
public string PullPaymentDataId { get; set; } public string PullPaymentDataId { get; set; }
public string StoreDataId { get; set; }
public PullPaymentData PullPaymentData { get; set; } public PullPaymentData PullPaymentData { get; set; }
[MaxLength(20)] [MaxLength(20)]
public PayoutState State { get; set; } public PayoutState State { get; set; }
@@ -25,12 +26,16 @@ namespace BTCPayServer.Data
#nullable enable #nullable enable
public string? Destination { get; set; } public string? Destination { get; set; }
#nullable restore #nullable restore
public StoreData StoreData { get; set; }
internal static void OnModelCreating(ModelBuilder builder) internal static void OnModelCreating(ModelBuilder builder)
{ {
builder.Entity<PayoutData>() builder.Entity<PayoutData>()
.HasOne(o => o.PullPaymentData) .HasOne(o => o.PullPaymentData)
.WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade); .WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade);
builder.Entity<PayoutData>()
.HasOne(o => o.StoreData)
.WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade);
builder.Entity<PayoutData>() builder.Entity<PayoutData>()
.Property(o => o.State) .Property(o => o.State)
.HasConversion<string>(); .HasConversion<string>();

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data.Data;
public class PayoutProcessorData
{
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public string Id { get; set; }
public string StoreId { get; set; }
public StoreData Store { get; set; }
public string PaymentMethod { get; set; }
public string Processor { get; set; }
public byte[] Blob { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
{
builder.Entity<PayoutProcessorData>()
.HasOne(o => o.Store)
.WithMany(data => data.PayoutProcessors).OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -2,6 +2,8 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data.Data;
using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData;
namespace BTCPayServer.Data namespace BTCPayServer.Data
{ {
@@ -42,5 +44,7 @@ namespace BTCPayServer.Data
public List<PairedSINData> PairedSINs { get; set; } public List<PairedSINData> PairedSINs { get; set; }
public IEnumerable<APIKeyData> APIKeys { get; set; } public IEnumerable<APIKeyData> APIKeys { get; set; }
public IEnumerable<LightningAddressData> LightningAddresses { get; set; } public IEnumerable<LightningAddressData> LightningAddresses { get; set; }
public IEnumerable<PayoutProcessorData> PayoutProcessors { get; set; }
public IEnumerable<PayoutData> Payouts { get; set; }
} }
} }

View File

@@ -0,0 +1,92 @@
// <auto-generated />
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("20220311135252_AddPayoutProcessors")]
public partial class AddPayoutProcessors : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "StoreDataId",
table: "Payouts",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "PayoutProcessors",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
StoreId = table.Column<string>(type: "TEXT", nullable: true),
PaymentMethod = table.Column<string>(type: "TEXT", nullable: true),
Processor = table.Column<string>(type: "TEXT", nullable: true),
Blob = table.Column<byte[]>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PayoutProcessors", x => x.Id);
table.ForeignKey(
name: "FK_PayoutProcessors_Stores_StoreId",
column: x => x.StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Payouts_StoreDataId",
table: "Payouts",
column: "StoreDataId");
migrationBuilder.CreateIndex(
name: "IX_PayoutProcessors_StoreId",
table: "PayoutProcessors",
column: "StoreId");
if (this.SupportAddForeignKey(ActiveProvider))
{
migrationBuilder.AddForeignKey(
name: "FK_Payouts_Stores_StoreDataId",
table: "Payouts",
column: "StoreDataId",
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
if (this.SupportDropForeignKey(ActiveProvider))
{
migrationBuilder.DropForeignKey(
name: "FK_Payouts_Stores_StoreDataId",
table: "Payouts");
migrationBuilder.DropTable(
name: "PayoutProcessors");
migrationBuilder.DropIndex(
name: "IX_Payouts_StoreDataId",
table: "Payouts");
}
if(this.SupportDropColumn(ActiveProvider))
{
migrationBuilder.DropColumn(
name: "StoreDataId",
table: "Payouts");
}
}
}
}

View File

@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using BTCPayServer.Data; using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -548,12 +548,17 @@ namespace BTCPayServer.Migrations
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("StoreDataId")
.HasColumnType("TEXT");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("PullPaymentDataId"); b.HasIndex("PullPaymentDataId");
b.HasIndex("State"); b.HasIndex("State");
b.HasIndex("StoreDataId");
b.HasIndex("Destination", "State"); b.HasIndex("Destination", "State");
b.ToTable("Payouts"); b.ToTable("Payouts");
@@ -848,6 +853,31 @@ namespace BTCPayServer.Migrations
b.ToTable("WebhookDeliveries"); b.ToTable("WebhookDeliveries");
}); });
modelBuilder.Entity("BTCPayServer.PayoutProcessors.PayoutProcessorData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<string>("PaymentMethod")
.HasColumnType("TEXT");
b.Property<string>("Processor")
.HasColumnType("TEXT");
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("PayoutProcessors");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -1149,7 +1179,14 @@ namespace BTCPayServer.Migrations
.HasForeignKey("PullPaymentDataId") .HasForeignKey("PullPaymentDataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("Payouts")
.HasForeignKey("StoreDataId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("PullPaymentData"); b.Navigation("PullPaymentData");
b.Navigation("StoreData");
}); });
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
@@ -1271,6 +1308,16 @@ namespace BTCPayServer.Migrations
b.Navigation("Webhook"); b.Navigation("Webhook");
}); });
modelBuilder.Entity("BTCPayServer.PayoutProcessors.PayoutProcessorData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany("PayoutProcessors")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Store");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -1375,8 +1422,12 @@ namespace BTCPayServer.Migrations
b.Navigation("PaymentRequests"); b.Navigation("PaymentRequests");
b.Navigation("Payouts");
b.Navigation("PullPayments"); b.Navigation("PullPayments");
b.Navigation("PayoutProcessors");
b.Navigation("UserStores"); b.Navigation("UserStores");
}); });

View File

@@ -449,10 +449,10 @@ namespace BTCPayServer.Tests
await tester.StartAsync(); await tester.StartAsync();
var acc = tester.NewAccount(); var acc = tester.NewAccount();
acc.Register(); acc.Register();
acc.CreateStore(); await acc.CreateStoreAsync();
var storeId = (await acc.RegisterDerivationSchemeAsync("BTC", importKeysToNBX: true)).StoreId; var storeId = (await acc.RegisterDerivationSchemeAsync("BTC", importKeysToNBX: true)).StoreId;
var client = await acc.CreateClient(); var client = await acc.CreateClient();
var result = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() var result = await client.CreatePullPayment(storeId, new CreatePullPaymentRequest()
{ {
Name = "Test", Name = "Test",
Description = "Test description", Description = "Test description",
@@ -2340,5 +2340,126 @@ namespace BTCPayServer.Tests
await adminClient.SendEmail(admin.StoreId, await adminClient.SendEmail(admin.StoreId,
new SendEmailRequest() { Body = "lol", Subject = "subj", Email = "sdasdas" }); new SendEmailRequest() { Body = "lol", Subject = "subj", Email = "sdasdas" });
} }
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
public async Task CanUsePayoutProcessorsThroughAPI()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
var registeredProcessors = await adminClient.GetPayoutProcessors();
Assert.Equal(2,registeredProcessors.Count());
await adminClient.GenerateOnChainWallet(admin.StoreId, "BTC", new GenerateOnChainWalletRequest()
{
SavePrivateKeys = true
});
var preApprovedPayoutWithoutPullPayment = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
Amount = 0.0001m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
var notApprovedPayoutWithoutPullPayment = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
Amount = 0.00001m,
Approved = false,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
var pullPayment = await adminClient.CreatePullPayment(admin.StoreId, new CreatePullPaymentRequest()
{
Amount = 100,
Currency = "USD",
Name = "pull payment",
PaymentMethods = new []{ "BTC"}
});
var notapprovedPayoutWithPullPayment = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
PullPaymentId = pullPayment.Id,
Amount = 10,
Approved = false,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await adminClient.ApprovePayout(admin.StoreId, notapprovedPayoutWithPullPayment.Id,
new ApprovePayoutRequest() { });
var payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Equal(3, payouts.Length);
Assert.Single(payouts, data => data.State == PayoutState.AwaitingApproval);
await adminClient.ApprovePayout(admin.StoreId, notApprovedPayoutWithoutPullPayment.Id,
new ApprovePayoutRequest() { });
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Equal(3, payouts.Length);
Assert.Empty(payouts.Where(data => data.State == PayoutState.AwaitingApproval));
Assert.Empty(payouts.Where(data => data.PaymentMethodAmount is null));
Assert.Empty( await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC"));
Assert.Empty( await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.Empty(await adminClient.GetPayoutProcessors(admin.StoreId));
await adminClient.UpdateStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC",
new OnChainAutomatedPayoutSettings() {IntervalSeconds = TimeSpan.FromSeconds(100000)});
Assert.Equal(100000, Assert.Single( await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC")).IntervalSeconds.TotalSeconds);
var tpGen = Assert.Single(await adminClient.GetPayoutProcessors(admin.StoreId));
Assert.Equal("BTC", Assert.Single(tpGen.PaymentMethods));
//still too poor to process any payouts
Assert.Empty( await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC"));
await adminClient.RemovePayoutProcessor(admin.StoreId, tpGen.Name, tpGen.PaymentMethods.First());
Assert.Empty( await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.Empty(await adminClient.GetPayoutProcessors(admin.StoreId));
await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.000012m));
await tester.ExplorerNode.GenerateAsync(1);
await TestUtils.EventuallyAsync(async () =>
{
Assert.Single(await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC"));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Equal(3, payouts.Length);
});
await adminClient.UpdateStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC",
new OnChainAutomatedPayoutSettings() {IntervalSeconds = TimeSpan.FromSeconds(5)});
Assert.Equal(5, Assert.Single( await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC")).IntervalSeconds.TotalSeconds);
await TestUtils.EventuallyAsync(async () =>
{
Assert.Equal(2, (await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC")).Count());
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Single(payouts.Where(data => data.State == PayoutState.InProgress));
});
await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.01m));
await TestUtils.EventuallyAsync(async () =>
{
Assert.Equal(4, (await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC")).Count());
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress));
});
}
} }
} }

View File

@@ -32,7 +32,7 @@ services:
TESTS_SOCKSENDPOINT: "tor:9050" TESTS_SOCKSENDPOINT: "tor:9050"
expose: expose:
- "80" - "80"
links: depends_on:
- dev - dev
- selenium - selenium
extra_hosts: extra_hosts:
@@ -46,7 +46,7 @@ services:
dev: dev:
image: alpine:3.7 image: alpine:3.7
command: [ "/bin/sh", "-c", "trap : TERM INT; while :; do echo Ready to code and debug like a rockstar!!!; sleep 2073600; done & wait" ] command: [ "/bin/sh", "-c", "trap : TERM INT; while :; do echo Ready to code and debug like a rockstar!!!; sleep 2073600; done & wait" ]
links: depends_on:
- nbxplorer - nbxplorer
- postgres - postgres
- customer_lightningd - customer_lightningd
@@ -79,7 +79,7 @@ services:
connect=bitcoind:39388 connect=bitcoind:39388
fallbackfee=0.0002 fallbackfee=0.0002
rpcallowip=0.0.0.0/0 rpcallowip=0.0.0.0/0
links: depends_on:
- nbxplorer - nbxplorer
- postgres - postgres
- customer_lnd - customer_lnd
@@ -117,7 +117,7 @@ services:
NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
NBXPLORER_NOAUTH: 1 NBXPLORER_NOAUTH: 1
NBXPLORER_EXPOSERPC: 1 NBXPLORER_EXPOSERPC: 1
links: depends_on:
- bitcoind - bitcoind
- litecoind - litecoind
- elementsd-liquid - elementsd-liquid
@@ -176,7 +176,7 @@ services:
volumes: volumes:
- "bitcoin_datadir:/etc/bitcoin" - "bitcoin_datadir:/etc/bitcoin"
- "customer_lightningd_datadir:/root/.lightning" - "customer_lightningd_datadir:/root/.lightning"
links: depends_on:
- bitcoind - bitcoind
lightning-charged: lightning-charged:
@@ -197,7 +197,7 @@ services:
- "9735" # Lightning - "9735" # Lightning
ports: ports:
- "54938:9112" # Charge - "54938:9112" # Charge
links: depends_on:
- bitcoind - bitcoind
- merchant_lightningd - merchant_lightningd
@@ -224,7 +224,7 @@ services:
volumes: volumes:
- "bitcoin_datadir:/etc/bitcoin" - "bitcoin_datadir:/etc/bitcoin"
- "merchant_lightningd_datadir:/root/.lightning" - "merchant_lightningd_datadir:/root/.lightning"
links: depends_on:
- bitcoind - bitcoind
postgres: postgres:
image: postgres:13.4 image: postgres:13.4
@@ -266,7 +266,7 @@ services:
volumes: volumes:
- "merchant_lnd_datadir:/data" - "merchant_lnd_datadir:/data"
- "bitcoin_datadir:/deps/.bitcoin" - "bitcoin_datadir:/deps/.bitcoin"
links: depends_on:
- bitcoind - bitcoind
customer_lnd: customer_lnd:
@@ -301,7 +301,7 @@ services:
volumes: volumes:
- "customer_lnd_datadir:/root/.lnd" - "customer_lnd_datadir:/root/.lnd"
- "bitcoin_datadir:/deps/.bitcoin" - "bitcoin_datadir:/deps/.bitcoin"
links: depends_on:
- bitcoind - bitcoind
tor: tor:

View File

@@ -30,7 +30,7 @@ services:
TESTS_SOCKSENDPOINT: "tor:9050" TESTS_SOCKSENDPOINT: "tor:9050"
expose: expose:
- "80" - "80"
links: depends_on:
- dev - dev
- selenium - selenium
extra_hosts: extra_hosts:
@@ -44,7 +44,7 @@ services:
dev: dev:
image: alpine:3.7 image: alpine:3.7
command: [ "/bin/sh", "-c", "trap : TERM INT; while :; do echo Ready to code and debug like a rockstar!!!; sleep 2073600; done & wait" ] command: [ "/bin/sh", "-c", "trap : TERM INT; while :; do echo Ready to code and debug like a rockstar!!!; sleep 2073600; done & wait" ]
links: depends_on:
- nbxplorer - nbxplorer
- postgres - postgres
- customer_lightningd - customer_lightningd
@@ -76,7 +76,7 @@ services:
connect=bitcoind:39388 connect=bitcoind:39388
rpcallowip=0.0.0.0/0 rpcallowip=0.0.0.0/0
fallbackfee=0.0002 fallbackfee=0.0002
links: depends_on:
- nbxplorer - nbxplorer
- postgres - postgres
- customer_lnd - customer_lnd
@@ -106,7 +106,7 @@ services:
NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
NBXPLORER_EXPOSERPC: 1 NBXPLORER_EXPOSERPC: 1
NBXPLORER_NOAUTH: 1 NBXPLORER_NOAUTH: 1
links: depends_on:
- bitcoind - bitcoind
@@ -163,7 +163,7 @@ services:
volumes: volumes:
- "bitcoin_datadir:/etc/bitcoin" - "bitcoin_datadir:/etc/bitcoin"
- "customer_lightningd_datadir:/root/.lightning" - "customer_lightningd_datadir:/root/.lightning"
links: depends_on:
- bitcoind - bitcoind
lightning-charged: lightning-charged:
@@ -184,7 +184,7 @@ services:
- "9735" # Lightning - "9735" # Lightning
ports: ports:
- "54938:9112" # Charge - "54938:9112" # Charge
links: depends_on:
- bitcoind - bitcoind
- merchant_lightningd - merchant_lightningd
@@ -211,7 +211,7 @@ services:
volumes: volumes:
- "bitcoin_datadir:/etc/bitcoin" - "bitcoin_datadir:/etc/bitcoin"
- "merchant_lightningd_datadir:/root/.lightning" - "merchant_lightningd_datadir:/root/.lightning"
links: depends_on:
- bitcoind - bitcoind
postgres: postgres:
@@ -256,7 +256,7 @@ services:
volumes: volumes:
- "merchant_lnd_datadir:/data" - "merchant_lnd_datadir:/data"
- "bitcoin_datadir:/deps/.bitcoin" - "bitcoin_datadir:/deps/.bitcoin"
links: depends_on:
- bitcoind - bitcoind
customer_lnd: customer_lnd:
@@ -292,7 +292,7 @@ services:
volumes: volumes:
- "customer_lnd_datadir:/root/.lnd" - "customer_lnd_datadir:/root/.lnd"
- "bitcoin_datadir:/deps/.bitcoin" - "bitcoin_datadir:/deps/.bitcoin"
links: depends_on:
- bitcoind - bitcoind
tor: tor:

View File

@@ -0,0 +1,43 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public class GreenfieldPayoutProcessorsController : ControllerBase
{
private readonly IEnumerable<IPayoutProcessorFactory> _factories;
public GreenfieldPayoutProcessorsController(IEnumerable<IPayoutProcessorFactory>factories)
{
_factories = factories;
}
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/payout-processors")]
public IActionResult GetPayoutProcessors()
{
return Ok(_factories.Select(factory => new PayoutProcessorData()
{
Name = factory.Processor,
FriendlyName = factory.FriendlyName,
PaymentMethods = factory.GetSupportedPaymentMethods().Select(id => id.ToStringNormalized())
.ToArray()
}));
}
}
}

View File

@@ -31,7 +31,6 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly ApplicationDbContextFactory _dbContextFactory; private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly CurrencyNameTable _currencyNameTable; private readonly CurrencyNameTable _currencyNameTable;
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers; private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService, public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService,
@@ -39,7 +38,6 @@ namespace BTCPayServer.Controllers.Greenfield
ApplicationDbContextFactory dbContextFactory, ApplicationDbContextFactory dbContextFactory,
CurrencyNameTable currencyNameTable, CurrencyNameTable currencyNameTable,
Services.BTCPayNetworkJsonSerializerSettings serializerSettings, Services.BTCPayNetworkJsonSerializerSettings serializerSettings,
BTCPayNetworkProvider networkProvider,
IEnumerable<IPayoutHandler> payoutHandlers) IEnumerable<IPayoutHandler> payoutHandlers)
{ {
_pullPaymentService = pullPaymentService; _pullPaymentService = pullPaymentService;
@@ -47,7 +45,6 @@ namespace BTCPayServer.Controllers.Greenfield
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_currencyNameTable = currencyNameTable; _currencyNameTable = currencyNameTable;
_serializerSettings = serializerSettings; _serializerSettings = serializerSettings;
_networkProvider = networkProvider;
_payoutHandlers = payoutHandlers; _payoutHandlers = payoutHandlers;
} }
@@ -191,9 +188,8 @@ namespace BTCPayServer.Controllers.Greenfield
if (pp is null) if (pp is null)
return PullPaymentNotFound(); return PullPaymentNotFound();
var payouts = pp.Payouts.Where(p => p.State != PayoutState.Cancelled || includeCancelled).ToList(); var payouts = pp.Payouts.Where(p => p.State != PayoutState.Cancelled || includeCancelled).ToList();
var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false);
return base.Ok(payouts return base.Ok(payouts
.Select(p => ToModel(p, cd)).ToList()); .Select(ToModel).ToList());
} }
[HttpGet("~/api/v1/pull-payments/{pullPaymentId}/payouts/{payoutId}")] [HttpGet("~/api/v1/pull-payments/{pullPaymentId}/payouts/{payoutId}")]
@@ -209,11 +205,10 @@ namespace BTCPayServer.Controllers.Greenfield
var payout = pp.Payouts.FirstOrDefault(p => p.Id == payoutId); var payout = pp.Payouts.FirstOrDefault(p => p.Id == payoutId);
if (payout is null) if (payout is null)
return PayoutNotFound(); return PayoutNotFound();
var cd = _currencyNameTable.GetCurrencyData(payout.PullPaymentData.GetBlob().Currency, false); return base.Ok(ToModel(payout));
return base.Ok(ToModel(payout, cd));
} }
private Client.Models.PayoutData ToModel(Data.PayoutData p, CurrencyData cd) private Client.Models.PayoutData ToModel(Data.PayoutData p)
{ {
var blob = p.GetBlob(_serializerSettings); var blob = p.GetBlob(_serializerSettings);
var model = new Client.Models.PayoutData() var model = new Client.Models.PayoutData()
@@ -275,14 +270,83 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {ppBlob.MinimumClaim})"); ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {ppBlob.MinimumClaim})");
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false); var result = await _pullPaymentService.Claim(new ClaimRequest()
var result = await _pullPaymentService.Claim(new ClaimRequest()
{ {
Destination = destination.destination, Destination = destination.destination,
PullPaymentId = pullPaymentId, PullPaymentId = pullPaymentId,
Value = request.Amount, Value = request.Amount,
PaymentMethodId = paymentMethodId PaymentMethodId = paymentMethodId,
}); });
return HandleClaimResult(result);
}
[HttpPost("~/api/v1/stores/{storeId}/payouts")]
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CreatePayoutThroughStore(string storeId, CreatePayoutThroughStoreRequest request)
{
if (request is null || !PaymentMethodId.TryParse(request?.PaymentMethod, out var paymentMethodId))
{
ModelState.AddModelError(nameof(request.PaymentMethod), "Invalid payment method");
return this.CreateValidationError(ModelState);
}
var payoutHandler = _payoutHandlers.FindPayoutHandler(paymentMethodId);
if (payoutHandler is null)
{
ModelState.AddModelError(nameof(request.PaymentMethod), "Invalid payment method");
return this.CreateValidationError(ModelState);
}
await using var ctx = _dbContextFactory.CreateContext();
PullPaymentBlob? ppBlob = null;
if (request?.PullPaymentId is not null)
{
var pp = await ctx.PullPayments.FirstOrDefaultAsync(data =>
data.Id == request.PullPaymentId && data.StoreId == storeId);
if (pp is null)
return PullPaymentNotFound();
ppBlob = pp.GetBlob();
}
var destination = await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, request!.Destination, ppBlob);
if (destination.destination is null)
{
ModelState.AddModelError(nameof(request.Destination), destination.error ?? "The destination is invalid for the payment specified");
return this.CreateValidationError(ModelState);
}
if (request.Amount is null && destination.destination.Amount != null)
{
request.Amount = destination.destination.Amount;
}
else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount)
{
ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})");
return this.CreateValidationError(ModelState);
}
if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
{
var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m;
ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {minimumClaim})");
return this.CreateValidationError(ModelState);
}
var result = await _pullPaymentService.Claim(new ClaimRequest()
{
Destination = destination.destination,
PullPaymentId = request.PullPaymentId,
PreApprove = request.Approved,
Value = request.Amount,
PaymentMethodId = paymentMethodId,
StoreId = storeId
});
return HandleClaimResult(result);
}
private IActionResult HandleClaimResult(ClaimRequest.ClaimResponse result)
{
switch (result.Result) switch (result.Result)
{ {
case ClaimRequest.ClaimResult.Ok: case ClaimRequest.ClaimResult.Ok:
@@ -304,7 +368,8 @@ namespace BTCPayServer.Controllers.Greenfield
default: default:
throw new NotSupportedException("Unsupported ClaimResult"); throw new NotSupportedException("Unsupported ClaimResult");
} }
return Ok(ToModel(result.PayoutData, cd));
return Ok(ToModel(result.PayoutData));
} }
[HttpDelete("~/api/v1/stores/{storeId}/pull-payments/{pullPaymentId}")] [HttpDelete("~/api/v1/stores/{storeId}/pull-payments/{pullPaymentId}")]
@@ -319,6 +384,20 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(); return Ok();
} }
[HttpGet("~/api/v1/stores/{storeId}/payouts")]
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetStorePayouts(string storeId, bool includeCancelled = false)
{
await using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts
.Where(p => p.StoreDataId == storeId && (p.State != PayoutState.Cancelled || includeCancelled))
.ToListAsync();
return base.Ok(payouts
.Select(ToModel).ToList());
}
[HttpDelete("~/api/v1/stores/{storeId}/payouts/{payoutId}")] [HttpDelete("~/api/v1/stores/{storeId}/payouts/{payoutId}")]
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CancelPayout(string storeId, string payoutId) public async Task<IActionResult> CancelPayout(string storeId, string payoutId)
@@ -361,8 +440,6 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(approvePayoutRequest.RateRule), "Invalid RateRule"); ModelState.AddModelError(nameof(approvePayoutRequest.RateRule), "Invalid RateRule");
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
var ppBlob = payout.PullPaymentData.GetBlob();
var cd = _currencyNameTable.GetCurrencyData(ppBlob.Currency, false);
var result = await _pullPaymentService.Approve(new PullPaymentHostedService.PayoutApproval() var result = await _pullPaymentService.Approve(new PullPaymentHostedService.PayoutApproval()
{ {
PayoutId = payoutId, PayoutId = payoutId,
@@ -373,7 +450,7 @@ namespace BTCPayServer.Controllers.Greenfield
switch (result) switch (result)
{ {
case PullPaymentHostedService.PayoutApproval.Result.Ok: case PullPaymentHostedService.PayoutApproval.Result.Ok:
return Ok(ToModel(await ctx.Payouts.GetPayout(payoutId, storeId, true), cd)); return Ok(ToModel(await ctx.Payouts.GetPayout(payoutId, storeId, true)));
case PullPaymentHostedService.PayoutApproval.Result.InvalidState: case PullPaymentHostedService.PayoutApproval.Result.InvalidState:
return this.CreateAPIError("invalid-state", errorMessage); return this.CreateAPIError("invalid-state", errorMessage);
case PullPaymentHostedService.PayoutApproval.Result.TooLowAmount: case PullPaymentHostedService.PayoutApproval.Result.TooLowAmount:

View File

@@ -0,0 +1,94 @@
#nullable enable
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data.Data;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.PayoutProcessors.Settings;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public class GreenfieldStoreAutomatedLightningPayoutProcessorsController : ControllerBase
{
private readonly PayoutProcessorService _payoutProcessorService;
private readonly EventAggregator _eventAggregator;
public GreenfieldStoreAutomatedLightningPayoutProcessorsController(PayoutProcessorService payoutProcessorService,
EventAggregator eventAggregator)
{
_payoutProcessorService = payoutProcessorService;
_eventAggregator = eventAggregator;
}
[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}")]
public async Task<IActionResult> GetStoreLightningAutomatedPayoutProcessors(
string storeId, string? paymentMethod)
{
var configured =
await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] {storeId},
Processors = new[] {LightningAutomatedPayoutSenderFactory.ProcessorName},
PaymentMethods = paymentMethod is null ? null : new[] {paymentMethod}
});
return Ok(configured.Select(ToModel).ToArray());
}
private static LightningAutomatedPayoutSettings ToModel(PayoutProcessorData data)
{
return new LightningAutomatedPayoutSettings()
{
PaymentMethod = data.PaymentMethod,
IntervalSeconds = InvoiceRepository.FromBytes<AutomatedPayoutBlob>(data.Blob).Interval
};
}
private static AutomatedPayoutBlob FromModel(LightningAutomatedPayoutSettings data)
{
return new AutomatedPayoutBlob() {Interval = data.IntervalSeconds};
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/stores/{storeId}/payout-processors/" + nameof(LightningAutomatedPayoutSenderFactory) +
"/{paymentMethod}")]
public async Task<IActionResult> UpdateStoreLightningAutomatedPayoutProcessor(
string storeId, string paymentMethod, LightningAutomatedPayoutSettings request)
{
var activeProcessor =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] {storeId},
Processors = new[] {LightningAutomatedPayoutSenderFactory.ProcessorName},
PaymentMethods = new[] {paymentMethod}
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();
activeProcessor.Blob = InvoiceRepository.ToBytes(FromModel(request));
activeProcessor.StoreId = storeId;
activeProcessor.PaymentMethod = paymentMethod;
activeProcessor.Processor = LightningAutomatedPayoutSenderFactory.ProcessorName;
var tcs = new TaskCompletionSource();
_eventAggregator.Publish(new PayoutProcessorUpdated()
{
Data = activeProcessor, Id = activeProcessor.Id, Processed = tcs
});
await tcs.Task;
return Ok(ToModel(activeProcessor));
}
}
}

View File

@@ -0,0 +1,95 @@
#nullable enable
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data.Data;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.OnChain;
using BTCPayServer.PayoutProcessors.Settings;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public class GreenfieldStoreAutomatedOnChainPayoutProcessorsController : ControllerBase
{
private readonly PayoutProcessorService _payoutProcessorService;
private readonly EventAggregator _eventAggregator;
public GreenfieldStoreAutomatedOnChainPayoutProcessorsController(PayoutProcessorService payoutProcessorService,
EventAggregator eventAggregator)
{
_payoutProcessorService = payoutProcessorService;
_eventAggregator = eventAggregator;
}
[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}")]
public async Task<IActionResult> GetStoreOnChainAutomatedPayoutProcessors(
string storeId, string? paymentMethod)
{
var configured =
await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] {storeId},
Processors = new[] {OnChainAutomatedPayoutSenderFactory.ProcessorName},
PaymentMethods = paymentMethod is null ? null : new[] {paymentMethod}
});
return Ok(configured.Select(ToModel).ToArray());
}
private static OnChainAutomatedPayoutSettings ToModel(PayoutProcessorData data)
{
return new OnChainAutomatedPayoutSettings()
{
PaymentMethod = data.PaymentMethod,
IntervalSeconds = InvoiceRepository.FromBytes<AutomatedPayoutBlob>(data.Blob).Interval
};
}
private static AutomatedPayoutBlob FromModel(OnChainAutomatedPayoutSettings data)
{
return new AutomatedPayoutBlob() {Interval = data.IntervalSeconds};
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/stores/{storeId}/payout-processors/" + nameof(OnChainAutomatedPayoutSenderFactory) +
"/{paymentMethod}")]
public async Task<IActionResult> UpdateStoreOnchainAutomatedPayoutProcessor(
string storeId, string paymentMethod, OnChainAutomatedPayoutSettings request)
{
var activeProcessor =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] {storeId},
Processors = new[] {OnChainAutomatedPayoutSenderFactory.ProcessorName},
PaymentMethods = new[] {paymentMethod}
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();
activeProcessor.Blob = InvoiceRepository.ToBytes(FromModel(request));
activeProcessor.StoreId = storeId;
activeProcessor.PaymentMethod = paymentMethod;
activeProcessor.Processor = OnChainAutomatedPayoutSenderFactory.ProcessorName;
var tcs = new TaskCompletionSource();
_eventAggregator.Publish(new PayoutProcessorUpdated()
{
Data = activeProcessor, Id = activeProcessor.Id, Processed = tcs
});
await tcs.Task;
return Ok(ToModel(activeProcessor));
}
}
}

View File

@@ -0,0 +1,72 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.PayoutProcessors;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public class GreenfieldStorePayoutProcessorsController : ControllerBase
{
private readonly PayoutProcessorService _payoutProcessorService;
private readonly IEnumerable<IPayoutProcessorFactory> _factories;
public GreenfieldStorePayoutProcessorsController(PayoutProcessorService payoutProcessorService, IEnumerable<IPayoutProcessorFactory> factories)
{
_payoutProcessorService = payoutProcessorService;
_factories = factories;
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payout-processors")]
public async Task<IActionResult> GetStorePayoutProcessors(
string storeId)
{
var configured =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery() { Stores = new[] { storeId } }))
.GroupBy(data => data.Processor).Select(datas => new PayoutProcessorData()
{
Name = datas.Key,
FriendlyName = _factories.FirstOrDefault(factory => factory.Processor == datas.Key)?.FriendlyName,
PaymentMethods = datas.Select(data => data.PaymentMethod).ToArray()
});
return Ok(configured);
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/stores/{storeId}/payout-processors/{processor}/{paymentMethod}")]
public async Task<IActionResult> RemoveStorePayoutProcessor(
string storeId,string processor,string paymentMethod)
{
var matched =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] { storeId },
Processors = new []{ processor},
PaymentMethods = new []{paymentMethod}
})).FirstOrDefault();
if (matched is null)
{
return NotFound();
}
var tcs = new TaskCompletionSource();
_payoutProcessorService.EventAggregator.Publish(new PayoutProcessorUpdated()
{
Id = matched.Id,
Processed = tcs
});
await tcs.Task;
return Ok();
}
}
}

View File

@@ -65,6 +65,10 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly GreenfieldStorePaymentMethodsController _storePaymentMethodsController; private readonly GreenfieldStorePaymentMethodsController _storePaymentMethodsController;
private readonly GreenfieldStoreEmailController _greenfieldStoreEmailController; private readonly GreenfieldStoreEmailController _greenfieldStoreEmailController;
private readonly GreenfieldStoreUsersController _greenfieldStoreUsersController; private readonly GreenfieldStoreUsersController _greenfieldStoreUsersController;
private readonly GreenfieldStorePayoutProcessorsController _greenfieldStorePayoutProcessorsController;
private readonly GreenfieldPayoutProcessorsController _greenfieldPayoutProcessorsController;
private readonly GreenfieldStoreAutomatedOnChainPayoutProcessorsController _greenfieldStoreAutomatedOnChainPayoutProcessorsController;
private readonly GreenfieldStoreAutomatedLightningPayoutProcessorsController _greenfieldStoreAutomatedLightningPayoutProcessorsController;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
public BTCPayServerClientFactory(StoreRepository storeRepository, public BTCPayServerClientFactory(StoreRepository storeRepository,
@@ -90,6 +94,10 @@ namespace BTCPayServer.Controllers.Greenfield
GreenfieldStorePaymentMethodsController storePaymentMethodsController, GreenfieldStorePaymentMethodsController storePaymentMethodsController,
GreenfieldStoreEmailController greenfieldStoreEmailController, GreenfieldStoreEmailController greenfieldStoreEmailController,
GreenfieldStoreUsersController greenfieldStoreUsersController, GreenfieldStoreUsersController greenfieldStoreUsersController,
GreenfieldStorePayoutProcessorsController greenfieldStorePayoutProcessorsController,
GreenfieldPayoutProcessorsController greenfieldPayoutProcessorsController,
GreenfieldStoreAutomatedOnChainPayoutProcessorsController greenfieldStoreAutomatedOnChainPayoutProcessorsController,
GreenfieldStoreAutomatedLightningPayoutProcessorsController greenfieldStoreAutomatedLightningPayoutProcessorsController,
IServiceProvider serviceProvider) IServiceProvider serviceProvider)
{ {
_storeRepository = storeRepository; _storeRepository = storeRepository;
@@ -115,6 +123,10 @@ namespace BTCPayServer.Controllers.Greenfield
_storePaymentMethodsController = storePaymentMethodsController; _storePaymentMethodsController = storePaymentMethodsController;
_greenfieldStoreEmailController = greenfieldStoreEmailController; _greenfieldStoreEmailController = greenfieldStoreEmailController;
_greenfieldStoreUsersController = greenfieldStoreUsersController; _greenfieldStoreUsersController = greenfieldStoreUsersController;
_greenfieldStorePayoutProcessorsController = greenfieldStorePayoutProcessorsController;
_greenfieldPayoutProcessorsController = greenfieldPayoutProcessorsController;
_greenfieldStoreAutomatedOnChainPayoutProcessorsController = greenfieldStoreAutomatedOnChainPayoutProcessorsController;
_greenfieldStoreAutomatedLightningPayoutProcessorsController = greenfieldStoreAutomatedLightningPayoutProcessorsController;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
} }
@@ -189,6 +201,10 @@ namespace BTCPayServer.Controllers.Greenfield
_storePaymentMethodsController, _storePaymentMethodsController,
_greenfieldStoreEmailController, _greenfieldStoreEmailController,
_greenfieldStoreUsersController, _greenfieldStoreUsersController,
_greenfieldStorePayoutProcessorsController,
_greenfieldPayoutProcessorsController,
_greenfieldStoreAutomatedOnChainPayoutProcessorsController,
_greenfieldStoreAutomatedLightningPayoutProcessorsController,
new LocalHttpContextAccessor() { HttpContext = context } new LocalHttpContextAccessor() { HttpContext = context }
); );
} }
@@ -196,7 +212,7 @@ namespace BTCPayServer.Controllers.Greenfield
public class LocalHttpContextAccessor : IHttpContextAccessor public class LocalHttpContextAccessor : IHttpContextAccessor
{ {
public HttpContext? HttpContext { get; set; } public HttpContext HttpContext { get; set; }
} }
public class LocalBTCPayServerClient : BTCPayServerClient public class LocalBTCPayServerClient : BTCPayServerClient
@@ -223,6 +239,10 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly UIHomeController _homeController; private readonly UIHomeController _homeController;
private readonly GreenfieldStorePaymentMethodsController _storePaymentMethodsController; private readonly GreenfieldStorePaymentMethodsController _storePaymentMethodsController;
private readonly GreenfieldStoreEmailController _greenfieldStoreEmailController; private readonly GreenfieldStoreEmailController _greenfieldStoreEmailController;
private readonly GreenfieldStorePayoutProcessorsController _greenfieldStorePayoutProcessorsController;
private readonly GreenfieldPayoutProcessorsController _greenfieldPayoutProcessorsController;
private readonly GreenfieldStoreAutomatedOnChainPayoutProcessorsController _greenfieldStoreAutomatedOnChainPayoutProcessorsController;
private readonly GreenfieldStoreAutomatedLightningPayoutProcessorsController _greenfieldStoreAutomatedLightningPayoutProcessorsController;
private readonly GreenfieldStoreUsersController _greenfieldStoreUsersController; private readonly GreenfieldStoreUsersController _greenfieldStoreUsersController;
public LocalBTCPayServerClient( public LocalBTCPayServerClient(
@@ -247,6 +267,10 @@ namespace BTCPayServer.Controllers.Greenfield
GreenfieldStorePaymentMethodsController storePaymentMethodsController, GreenfieldStorePaymentMethodsController storePaymentMethodsController,
GreenfieldStoreEmailController greenfieldStoreEmailController, GreenfieldStoreEmailController greenfieldStoreEmailController,
GreenfieldStoreUsersController greenfieldStoreUsersController, GreenfieldStoreUsersController greenfieldStoreUsersController,
GreenfieldStorePayoutProcessorsController greenfieldStorePayoutProcessorsController,
GreenfieldPayoutProcessorsController greenfieldPayoutProcessorsController,
GreenfieldStoreAutomatedOnChainPayoutProcessorsController greenfieldStoreAutomatedOnChainPayoutProcessorsController,
GreenfieldStoreAutomatedLightningPayoutProcessorsController greenfieldStoreAutomatedLightningPayoutProcessorsController,
IHttpContextAccessor httpContextAccessor) : base(new Uri("https://dummy.local"), "", "") IHttpContextAccessor httpContextAccessor) : base(new Uri("https://dummy.local"), "", "")
{ {
_chainPaymentMethodsController = chainPaymentMethodsController; _chainPaymentMethodsController = chainPaymentMethodsController;
@@ -269,6 +293,10 @@ namespace BTCPayServer.Controllers.Greenfield
_storePaymentMethodsController = storePaymentMethodsController; _storePaymentMethodsController = storePaymentMethodsController;
_greenfieldStoreEmailController = greenfieldStoreEmailController; _greenfieldStoreEmailController = greenfieldStoreEmailController;
_greenfieldStoreUsersController = greenfieldStoreUsersController; _greenfieldStoreUsersController = greenfieldStoreUsersController;
_greenfieldStorePayoutProcessorsController = greenfieldStorePayoutProcessorsController;
_greenfieldPayoutProcessorsController = greenfieldPayoutProcessorsController;
_greenfieldStoreAutomatedOnChainPayoutProcessorsController = greenfieldStoreAutomatedOnChainPayoutProcessorsController;
_greenfieldStoreAutomatedLightningPayoutProcessorsController = greenfieldStoreAutomatedLightningPayoutProcessorsController;
var controllers = new[] var controllers = new[]
{ {
@@ -277,7 +305,11 @@ namespace BTCPayServer.Controllers.Greenfield
storeLightningNetworkPaymentMethodsController, greenFieldInvoiceController, storeWebhooksController, storeLightningNetworkPaymentMethodsController, greenFieldInvoiceController, storeWebhooksController,
greenFieldServerInfoController, greenfieldPullPaymentController, storesController, homeController, greenFieldServerInfoController, greenfieldPullPaymentController, storesController, homeController,
lightningNodeApiController, storeLightningNodeApiController as ControllerBase, lightningNodeApiController, storeLightningNodeApiController as ControllerBase,
storePaymentMethodsController, greenfieldStoreEmailController, greenfieldStoreUsersController storePaymentMethodsController, greenfieldStoreEmailController, greenfieldStoreUsersController,
lightningNodeApiController, storeLightningNodeApiController as ControllerBase, storePaymentMethodsController,
greenfieldStoreEmailController, greenfieldStorePayoutProcessorsController, greenfieldPayoutProcessorsController,
greenfieldStoreAutomatedOnChainPayoutProcessorsController,
greenfieldStoreAutomatedLightningPayoutProcessorsController,
}; };
var authoverride = new DefaultAuthorizationService( var authoverride = new DefaultAuthorizationService(
@@ -1115,5 +1147,67 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return GetFromActionResult<ApplicationUserData>(await _usersController.GetUser(idOrEmail)); return GetFromActionResult<ApplicationUserData>(await _usersController.GetUser(idOrEmail));
} }
public override async Task<PayoutData> CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest,
CancellationToken cancellationToken = default)
{
return GetFromActionResult<PayoutData>(
await _greenfieldPullPaymentController.CreatePayoutThroughStore(storeId, payoutRequest));
}
public override async Task<IEnumerable<PayoutProcessorData>> GetPayoutProcessors(string storeId, CancellationToken token = default)
{
return GetFromActionResult<IEnumerable<PayoutProcessorData>>(await _greenfieldStorePayoutProcessorsController.GetStorePayoutProcessors(storeId));
}
public override Task<IEnumerable<PayoutProcessorData>> GetPayoutProcessors(CancellationToken token = default)
{
return Task.FromResult(GetFromActionResult<IEnumerable<PayoutProcessorData>>(_greenfieldPayoutProcessorsController.GetPayoutProcessors()));
}
public override async Task RemovePayoutProcessor(string storeId, string processor, string paymentMethod, CancellationToken token = default)
{
HandleActionResult(await _greenfieldStorePayoutProcessorsController.RemoveStorePayoutProcessor(storeId, processor, paymentMethod));
}
public override async Task<IEnumerable<OnChainAutomatedPayoutSettings>>
GetStoreOnChainAutomatedPayoutProcessors(string storeId, string paymentMethod = null,
CancellationToken token = default)
{
return GetFromActionResult<IEnumerable<OnChainAutomatedPayoutSettings>>(
await _greenfieldStoreAutomatedOnChainPayoutProcessorsController
.GetStoreOnChainAutomatedPayoutProcessors(storeId, paymentMethod));
}
public override async Task<IEnumerable<LightningAutomatedPayoutSettings>> GetStoreLightningAutomatedPayoutProcessors(string storeId, string paymentMethod = null,
CancellationToken token = default)
{
return GetFromActionResult<IEnumerable<LightningAutomatedPayoutSettings>>(
await _greenfieldStoreAutomatedLightningPayoutProcessorsController
.GetStoreLightningAutomatedPayoutProcessors(storeId, paymentMethod));
}
public override async Task<OnChainAutomatedPayoutSettings> UpdateStoreOnChainAutomatedPayoutProcessors(string storeId, string paymentMethod,
OnChainAutomatedPayoutSettings request, CancellationToken token = default)
{
return GetFromActionResult<OnChainAutomatedPayoutSettings>(
await _greenfieldStoreAutomatedOnChainPayoutProcessorsController
.UpdateStoreOnchainAutomatedPayoutProcessor(storeId, paymentMethod, request));
}
public override async Task<LightningAutomatedPayoutSettings> UpdateStoreLightningAutomatedPayoutProcessors(string storeId, string paymentMethod,
LightningAutomatedPayoutSettings request, CancellationToken token = default)
{
return GetFromActionResult<LightningAutomatedPayoutSettings>(
await _greenfieldStoreAutomatedLightningPayoutProcessorsController
.UpdateStoreLightningAutomatedPayoutProcessor(storeId, paymentMethod, request));
}
public override async Task<PayoutData[]> GetStorePayouts(string storeId, bool includeCancelled = false, CancellationToken cancellationToken = default)
{
return GetFromActionResult<PayoutData[]>(
await _greenfieldPullPaymentController
.GetStorePayouts(storeId,includeCancelled));
}
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; #nullable enable
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
@@ -30,15 +31,17 @@ public class LightningAddressService
query.Usernames = query.Usernames?.Select(NormalizeUsername)?.ToArray(); query.Usernames = query.Usernames?.Select(NormalizeUsername)?.ToArray();
if (query.Usernames is not null) if (query.Usernames is not null)
{ {
queryable = queryable.Where(data => query.Usernames.Contains(data.Username)); queryable = queryable.Where(data => query.Usernames.Contains(data!.Username));
} }
if (query.StoreIds is not null) if (query.StoreIds is not null)
{ {
queryable = queryable.Where(data => query.StoreIds.Contains(data.StoreDataId)); queryable = queryable.Where(data => query.StoreIds.Contains(data!.StoreDataId));
} }
#pragma warning disable CS8619 // Nullability of reference types in value doesn't match target type.
return await queryable.ToListAsync(); return await queryable.ToListAsync();
#pragma warning restore CS8619 // Nullability of reference types in value doesn't match target type.
} }
public async Task<LightningAddressData?> ResolveByAddress(string username) public async Task<LightningAddressData?> ResolveByAddress(string username)
@@ -77,7 +80,7 @@ public class LightningAddressService
return true; return true;
} }
public async Task<bool> Remove(string username, string storeId = null) public async Task<bool> Remove(string username, string? storeId = null)
{ {
await using var context = _applicationDbContextFactory.CreateContext(); await using var context = _applicationDbContextFactory.CreateContext();
var x = (await GetCore(context, new LightningAddressQuery() {Usernames = new[] {username}})).FirstOrDefault(); var x = (await GetCore(context, new LightningAddressQuery() {Usernames = new[] {username}})).FirstOrDefault();

View File

@@ -1,4 +1,3 @@
#nullable enable
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
@@ -148,8 +147,8 @@ namespace BTCPayServer
public class LightningAddressQuery public class LightningAddressQuery
{ {
public string[]? StoreIds { get; set; } public string[] StoreIds { get; set; }
public string[]? Usernames { get; set; } public string[] Usernames { get; set; }
} }
} }

View File

@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;

View File

@@ -25,7 +25,6 @@ using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers namespace BTCPayServer.Controllers
{ {
[Route("stores/{storeId}/pull-payments")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken] [AutoValidateAntiforgeryToken]
public class UIStorePullPaymentsController : Controller public class UIStorePullPaymentsController : Controller
@@ -44,6 +43,7 @@ namespace BTCPayServer.Controllers
return HttpContext.GetStoreData(); return HttpContext.GetStoreData();
} }
} }
public UIStorePullPaymentsController(BTCPayNetworkProvider btcPayNetworkProvider, public UIStorePullPaymentsController(BTCPayNetworkProvider btcPayNetworkProvider,
IEnumerable<IPayoutHandler> payoutHandlers, IEnumerable<IPayoutHandler> payoutHandlers,
CurrencyNameTable currencyNameTable, CurrencyNameTable currencyNameTable,
@@ -59,7 +59,7 @@ namespace BTCPayServer.Controllers
_jsonSerializerSettings = jsonSerializerSettings; _jsonSerializerSettings = jsonSerializerSettings;
} }
[HttpGet("new")] [HttpGet("stores/{storeId}/pull-payments/new")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewPullPayment(string storeId) public async Task<IActionResult> NewPullPayment(string storeId)
{ {
@@ -83,11 +83,12 @@ namespace BTCPayServer.Controllers
Currency = CurrentStore.GetStoreBlob().DefaultCurrency, Currency = CurrentStore.GetStoreBlob().DefaultCurrency,
CustomCSSLink = "", CustomCSSLink = "",
EmbeddedCSS = "", EmbeddedCSS = "",
PaymentMethodItems = paymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true)) PaymentMethodItems =
paymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true))
}); });
} }
[HttpPost("new")] [HttpPost("stores/{storeId}/pull-payments/new")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewPullPayment(string storeId, NewPullPaymentModel model) public async Task<IActionResult> NewPullPayment(string storeId, NewPullPaymentModel model)
{ {
@@ -104,7 +105,8 @@ namespace BTCPayServer.Controllers
{ {
// Since we assign all payment methods to be selected by default above we need to update // Since we assign all payment methods to be selected by default above we need to update
// them here to reflect user's selection so that they can correct their mistake // them here to reflect user's selection so that they can correct their mistake
model.PaymentMethodItems = paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), false)); model.PaymentMethodItems =
paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), false));
ModelState.AddModelError(nameof(model.PaymentMethods), "You need at least one payment method"); ModelState.AddModelError(nameof(model.PaymentMethods), "You need at least one payment method");
} }
if (_currencyNameTable.GetCurrencyData(model.Currency, false) is null) if (_currencyNameTable.GetCurrencyData(model.Currency, false) is null)
@@ -141,13 +143,12 @@ namespace BTCPayServer.Controllers
}); });
this.TempData.SetStatusMessageModel(new StatusMessageModel() this.TempData.SetStatusMessageModel(new StatusMessageModel()
{ {
Message = "Pull payment request created", Message = "Pull payment request created", Severity = StatusMessageModel.StatusSeverity.Success
Severity = StatusMessageModel.StatusSeverity.Success
}); });
return RedirectToAction(nameof(PullPayments), new { storeId = storeId }); return RedirectToAction(nameof(PullPayments), new { storeId = storeId });
} }
[HttpGet("")] [HttpGet("stores/{storeId}/pull-payments")]
public async Task<IActionResult> PullPayments( public async Task<IActionResult> PullPayments(
string storeId, string storeId,
PullPaymentState pullPaymentState, PullPaymentState pullPaymentState,
@@ -190,20 +191,18 @@ namespace BTCPayServer.Controllers
var vm = this.ParseListQuery(new PullPaymentsModel var vm = this.ParseListQuery(new PullPaymentsModel
{ {
Skip = skip, Skip = skip, Count = count, Total = await ppsQuery.CountAsync(), ActiveState = pullPaymentState
Count = count,
Total = await ppsQuery.CountAsync(),
ActiveState = pullPaymentState
}); });
switch (pullPaymentState) { switch (pullPaymentState)
{
case PullPaymentState.Active: case PullPaymentState.Active:
ppsQuery = ppsQuery ppsQuery = ppsQuery
.Where( .Where(
p => !p.Archived && p => !p.Archived &&
(p.EndDate != null ? p.EndDate > DateTimeOffset.UtcNow : true) && (p.EndDate != null ? p.EndDate > DateTimeOffset.UtcNow : true) &&
p.StartDate <= DateTimeOffset.UtcNow p.StartDate <= DateTimeOffset.UtcNow
); );
break; break;
case PullPaymentState.Archived: case PullPaymentState.Archived:
ppsQuery = ppsQuery.Where(p => p.Archived); ppsQuery = ppsQuery.Where(p => p.Archived);
@@ -225,10 +224,11 @@ namespace BTCPayServer.Controllers
{ {
var totalCompleted = pp.Payouts.Where(p => (p.State == PayoutState.Completed || var totalCompleted = pp.Payouts.Where(p => (p.State == PayoutState.Completed ||
p.State == PayoutState.InProgress) && p.IsInPeriod(pp, now)) p.State == PayoutState.InProgress) && p.IsInPeriod(pp, now))
.Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum(); .Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum();
var totalAwaiting = pp.Payouts.Where(p => (p.State == PayoutState.AwaitingPayment || var totalAwaiting = pp.Payouts.Where(p => (p.State == PayoutState.AwaitingPayment ||
p.State == PayoutState.AwaitingApproval) && p.State == PayoutState.AwaitingApproval) &&
p.IsInPeriod(pp, now)).Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum(); p.IsInPeriod(pp, now)).Select(o =>
o.GetBlob(_jsonSerializerSettings).Amount).Sum();
; ;
var ppBlob = pp.GetBlob(); var ppBlob = pp.GetBlob();
var ni = _currencyNameTable.GetCurrencyData(ppBlob.Currency, true); var ni = _currencyNameTable.GetCurrencyData(ppBlob.Currency, true);
@@ -262,15 +262,16 @@ namespace BTCPayServer.Controllers
return time; return time;
} }
[HttpGet("{pullPaymentId}/archive")] [HttpGet("stores/{storeId}/pull-payments/{pullPaymentId}/archive")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult ArchivePullPayment(string storeId, public IActionResult ArchivePullPayment(string storeId,
string pullPaymentId) string pullPaymentId)
{ {
return View("Confirm", new ConfirmModel("Archive pull payment", "Do you really want to archive the pull payment?", "Archive")); return View("Confirm",
new ConfirmModel("Archive pull payment", "Do you really want to archive the pull payment?", "Archive"));
} }
[HttpPost("{pullPaymentId}/archive")] [HttpPost("stores/{storeId}/pull-payments/{pullPaymentId}/archive")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ArchivePullPaymentPost(string storeId, public async Task<IActionResult> ArchivePullPaymentPost(string storeId,
string pullPaymentId) string pullPaymentId)
@@ -278,14 +279,15 @@ namespace BTCPayServer.Controllers
await _pullPaymentService.Cancel(new HostedServices.PullPaymentHostedService.CancelRequest(pullPaymentId)); await _pullPaymentService.Cancel(new HostedServices.PullPaymentHostedService.CancelRequest(pullPaymentId));
TempData.SetStatusMessageModel(new StatusMessageModel() TempData.SetStatusMessageModel(new StatusMessageModel()
{ {
Message = "Pull payment archived", Message = "Pull payment archived", Severity = StatusMessageModel.StatusSeverity.Success
Severity = StatusMessageModel.StatusSeverity.Success
}); });
return RedirectToAction(nameof(PullPayments), new { storeId }); return RedirectToAction(nameof(PullPayments), new { storeId });
} }
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpPost("payouts")] [HttpPost("stores/{storeId}/pull-payments/payouts")]
[HttpPost("stores/{storeId}/pull-payments/{pullPaymentId}/payouts")]
[HttpPost("stores/{storeId}/payouts")]
public async Task<IActionResult> PayoutsPost( public async Task<IActionResult> PayoutsPost(
string storeId, PayoutsModel vm, CancellationToken cancellationToken) string storeId, PayoutsModel vm, CancellationToken cancellationToken)
{ {
@@ -302,16 +304,17 @@ namespace BTCPayServer.Controllers
{ {
TempData.SetStatusMessageModel(new StatusMessageModel() TempData.SetStatusMessageModel(new StatusMessageModel()
{ {
Message = "No payout selected", Message = "No payout selected", Severity = StatusMessageModel.StatusSeverity.Error
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Payouts), new
{
storeId = storeId,
pullPaymentId = vm.PullPaymentId,
paymentMethodId = paymentMethodId.ToString()
}); });
return RedirectToAction(nameof(Payouts),
new
{
storeId = storeId,
pullPaymentId = vm.PullPaymentId,
paymentMethodId = paymentMethodId.ToString()
});
} }
var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1); var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1);
if (handler != null) if (handler != null)
{ {
@@ -321,124 +324,127 @@ namespace BTCPayServer.Controllers
TempData.SetStatusMessageModel(result); TempData.SetStatusMessageModel(result);
} }
} }
switch (command) switch (command)
{ {
case "approve-pay": case "approve-pay":
case "approve": case "approve":
{ {
await using var ctx = this._dbContextFactory.CreateContext(); await using var ctx = this._dbContextFactory.CreateContext();
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var payouts = await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken); var payouts =
await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken);
var failed = false; var failed = false;
for (int i = 0; i < payouts.Count; i++) for (int i = 0; i < payouts.Count; i++)
{
var payout = payouts[i];
if (payout.State != PayoutState.AwaitingApproval)
continue;
var rateResult = await _pullPaymentService.GetRate(payout, null, cancellationToken);
if (rateResult.BidAsk == null)
{ {
var payout = payouts[i]; this.TempData.SetStatusMessageModel(new StatusMessageModel()
if (payout.State != PayoutState.AwaitingApproval)
continue;
var rateResult = await _pullPaymentService.GetRate(payout, null, cancellationToken);
if (rateResult.BidAsk == null)
{ {
this.TempData.SetStatusMessageModel(new StatusMessageModel() Message = $"Rate unavailable: {rateResult.EvaluatedRule}",
{ Severity = StatusMessageModel.StatusSeverity.Error
Message = $"Rate unavailable: {rateResult.EvaluatedRule}", });
Severity = StatusMessageModel.StatusSeverity.Error failed = true;
}); break;
failed = true; }
break;
} var approveResult = await _pullPaymentService.Approve(
var approveResult = await _pullPaymentService.Approve(new HostedServices.PullPaymentHostedService.PayoutApproval() new HostedServices.PullPaymentHostedService.PayoutApproval()
{ {
PayoutId = payout.Id, PayoutId = payout.Id,
Revision = payout.GetBlob(_jsonSerializerSettings).Revision, Revision = payout.GetBlob(_jsonSerializerSettings).Revision,
Rate = rateResult.BidAsk.Ask Rate = rateResult.BidAsk.Ask
}); });
if (approveResult != PullPaymentHostedService.PayoutApproval.Result.Ok) if (approveResult != PullPaymentHostedService.PayoutApproval.Result.Ok)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = PullPaymentHostedService.PayoutApproval.GetErrorMessage(approveResult),
Severity = StatusMessageModel.StatusSeverity.Error
});
failed = true;
break;
}
}
if (failed)
{ {
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = PullPaymentHostedService.PayoutApproval.GetErrorMessage(approveResult),
Severity = StatusMessageModel.StatusSeverity.Error
});
failed = true;
break; break;
} }
if (command == "approve-pay") }
{
goto case "pay"; if (failed)
} {
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts approved",
Severity = StatusMessageModel.StatusSeverity.Success
});
break; break;
} }
if (command == "approve-pay")
{
goto case "pay";
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts approved", Severity = StatusMessageModel.StatusSeverity.Success
});
break;
}
case "pay": case "pay":
{
if (handler is { })
return await handler?.InitiatePayment(paymentMethodId, payoutIds);
TempData.SetStatusMessageModel(new StatusMessageModel()
{ {
if (handler is { }) Message = "Paying via this payment method is not supported",
return await handler?.InitiatePayment(paymentMethodId, payoutIds); Severity = StatusMessageModel.StatusSeverity.Error
TempData.SetStatusMessageModel(new StatusMessageModel() });
{ break;
Message = "Paying via this payment method is not supported", }
Severity = StatusMessageModel.StatusSeverity.Error
});
break;
}
case "mark-paid": case "mark-paid":
{
await using var ctx = this._dbContextFactory.CreateContext();
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var payouts =
await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken);
for (int i = 0; i < payouts.Count; i++)
{ {
await using var ctx = this._dbContextFactory.CreateContext(); var payout = payouts[i];
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; if (payout.State != PayoutState.AwaitingPayment)
var payouts = await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken); continue;
for (int i = 0; i < payouts.Count; i++)
{
var payout = payouts[i];
if (payout.State != PayoutState.AwaitingPayment)
continue;
var result = await _pullPaymentService.MarkPaid(new PayoutPaidRequest() var result =
await _pullPaymentService.MarkPaid(new PayoutPaidRequest() { PayoutId = payout.Id });
if (result != PayoutPaidRequest.PayoutPaidResult.Ok)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{ {
PayoutId = payout.Id Message = PayoutPaidRequest.GetErrorMessage(result),
Severity = StatusMessageModel.StatusSeverity.Error
}); });
if (result != PayoutPaidRequest.PayoutPaidResult.Ok) return RedirectToAction(nameof(Payouts),
{ new
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = PayoutPaidRequest.GetErrorMessage(result),
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Payouts), new
{ {
storeId = storeId, storeId = storeId,
pullPaymentId = vm.PullPaymentId, pullPaymentId = vm.PullPaymentId,
paymentMethodId = paymentMethodId.ToString() paymentMethodId = paymentMethodId.ToString()
}); });
}
} }
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts marked as paid",
Severity = StatusMessageModel.StatusSeverity.Success
});
break;
} }
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts marked as paid", Severity = StatusMessageModel.StatusSeverity.Success
});
break;
}
case "cancel": case "cancel":
await _pullPaymentService.Cancel( await _pullPaymentService.Cancel(
new PullPaymentHostedService.CancelRequest(payoutIds)); new PullPaymentHostedService.CancelRequest(payoutIds));
TempData.SetStatusMessageModel(new StatusMessageModel() TempData.SetStatusMessageModel(new StatusMessageModel()
{ {
Message = "Payouts archived", Message = "Payouts archived", Severity = StatusMessageModel.StatusSeverity.Success
Severity = StatusMessageModel.StatusSeverity.Success
}); });
break; break;
} }
@@ -458,16 +464,17 @@ namespace BTCPayServer.Controllers
{ {
var payouts = (await ctx.Payouts var payouts = (await ctx.Payouts
.Include(p => p.PullPaymentData) .Include(p => p.PullPaymentData)
.Include(p => p.PullPaymentData.StoreData) .Include(p => p.StoreData)
.Where(p => payoutIds.Contains(p.Id)) .Where(p => payoutIds.Contains(p.Id))
.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived) .Where(p => p.StoreDataId == storeId && (p.PullPaymentDataId == null || !p.PullPaymentData.Archived))
.ToListAsync(cancellationToken)) .ToListAsync(cancellationToken))
.Where(p => p.GetPaymentMethodId() == paymentMethodId) .Where(p => p.GetPaymentMethodId() == paymentMethodId)
.ToList(); .ToList();
return payouts; return payouts;
} }
[HttpGet("payouts")] [HttpGet("stores/{storeId}/pull-payments/{pullPaymentId}/payouts")]
[HttpGet("stores/{storeId}/payouts")]
public async Task<IActionResult> Payouts( public async Task<IActionResult> Payouts(
string storeId, string pullPaymentId, string paymentMethodId, PayoutState payoutState, string storeId, string pullPaymentId, string paymentMethodId, PayoutState payoutState,
int skip = 0, int count = 50) int skip = 0, int count = 50)
@@ -494,7 +501,8 @@ namespace BTCPayServer.Controllers
}); });
vm.Payouts = new List<PayoutsModel.PayoutModel>(); vm.Payouts = new List<PayoutsModel.PayoutModel>();
await using var ctx = _dbContextFactory.CreateContext(); await using var ctx = _dbContextFactory.CreateContext();
var payoutRequest = ctx.Payouts.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived); var payoutRequest =
ctx.Payouts.Where(p => p.StoreDataId == storeId && (p.PullPaymentDataId == null || !p.PullPaymentData.Archived));
if (pullPaymentId != null) if (pullPaymentId != null)
{ {
payoutRequest = payoutRequest.Where(p => p.PullPaymentDataId == vm.PullPaymentId); payoutRequest = payoutRequest.Where(p => p.PullPaymentDataId == vm.PullPaymentId);
@@ -507,6 +515,9 @@ namespace BTCPayServer.Controllers
payoutRequest = payoutRequest.Where(p => p.PaymentMethodId == pmiStr); payoutRequest = payoutRequest.Where(p => p.PaymentMethodId == pmiStr);
} }
vm.PaymentMethodCount = (await payoutRequest.GroupBy(data => data.PaymentMethodId)
.Select(datas => new {datas.Key, Count = datas.Count()}).ToListAsync())
.ToDictionary(datas => datas.Key, arg => arg.Count);
vm.PayoutStateCount = payoutRequest.GroupBy(data => data.State) vm.PayoutStateCount = payoutRequest.GroupBy(data => data.State)
.Select(e => new { e.Key, Count = e.Count() }) .Select(e => new { e.Key, Count = e.Count() })
.ToDictionary(arg => arg.Key, arg => arg.Count); .ToDictionary(arg => arg.Key, arg => arg.Count);
@@ -525,22 +536,18 @@ namespace BTCPayServer.Controllers
payoutRequest = payoutRequest.Skip(vm.Skip).Take(vm.Count); payoutRequest = payoutRequest.Skip(vm.Skip).Take(vm.Count);
var payouts = await payoutRequest.OrderByDescending(p => p.Date) var payouts = await payoutRequest.OrderByDescending(p => p.Date)
.Select(o => new .Select(o => new { Payout = o, PullPayment = o.PullPaymentData }).ToListAsync();
{
Payout = o,
PullPayment = o.PullPaymentData
}).ToListAsync();
foreach (var item in payouts) foreach (var item in payouts)
{ {
var ppBlob = item.PullPayment.GetBlob(); var ppBlob = item.PullPayment?.GetBlob();
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings); var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
var m = new PayoutsModel.PayoutModel var m = new PayoutsModel.PayoutModel
{ {
PullPaymentId = item.PullPayment.Id, PullPaymentId = item.PullPayment?.Id,
PullPaymentName = ppBlob.Name ?? item.PullPayment.Id, PullPaymentName = ppBlob?.Name ?? item.PullPayment?.Id,
Date = item.Payout.Date, Date = item.Payout.Date,
PayoutId = item.Payout.Id, PayoutId = item.Payout.Id,
Amount = _currencyNameTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob.Currency), Amount = _currencyNameTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob?.Currency ?? PaymentMethodId.Parse(item.Payout.PaymentMethodId).CryptoCode),
Destination = payoutBlob.Destination Destination = payoutBlob.Destination
}; };
var handler = _payoutHandlers var handler = _payoutHandlers

View File

@@ -13,7 +13,6 @@ using BTCPayServer.Data;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.ModelBinders; using BTCPayServer.ModelBinders;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.PayJoin; using BTCPayServer.Payments.PayJoin;
@@ -28,6 +27,8 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using NBitcoin; using NBitcoin;
using BTCPayServer.Client.Models;
using BTCPayServer.Logging;
using NBXplorer; using NBXplorer;
using NBXplorer.Client; using NBXplorer.Client;
using NBXplorer.DerivationStrategy; using NBXplorer.DerivationStrategy;
@@ -50,7 +51,6 @@ namespace BTCPayServer.Controllers
public RateFetcher RateFetcher { get; } public RateFetcher RateFetcher { get; }
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly JsonSerializerSettings _serializerSettings;
private readonly NBXplorerDashboard _dashboard; private readonly NBXplorerDashboard _dashboard;
private readonly IAuthorizationService _authorizationService; private readonly IAuthorizationService _authorizationService;
private readonly IFeeProviderFactory _feeRateProvider; private readonly IFeeProviderFactory _feeRateProvider;
@@ -61,6 +61,7 @@ namespace BTCPayServer.Controllers
private readonly DelayedTransactionBroadcaster _broadcaster; private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly PayjoinClient _payjoinClient; private readonly PayjoinClient _payjoinClient;
private readonly LabelFactory _labelFactory; private readonly LabelFactory _labelFactory;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly ApplicationDbContextFactory _dbContextFactory; private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly PullPaymentHostedService _pullPaymentService; private readonly PullPaymentHostedService _pullPaymentService;
@@ -69,6 +70,7 @@ namespace BTCPayServer.Controllers
private readonly WalletHistogramService _walletHistogramService; private readonly WalletHistogramService _walletHistogramService;
readonly CurrencyNameTable _currencyTable; readonly CurrencyNameTable _currencyTable;
public UIWalletsController(StoreRepository repo, public UIWalletsController(StoreRepository repo,
WalletRepository walletRepository, WalletRepository walletRepository,
CurrencyNameTable currencyTable, CurrencyNameTable currencyTable,
@@ -93,7 +95,8 @@ namespace BTCPayServer.Controllers
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
PullPaymentHostedService pullPaymentService, PullPaymentHostedService pullPaymentService,
IEnumerable<IPayoutHandler> payoutHandlers, IEnumerable<IPayoutHandler> payoutHandlers,
IServiceProvider serviceProvider) IServiceProvider serviceProvider,
PullPaymentHostedService pullPaymentHostedService)
{ {
_currencyTable = currencyTable; _currencyTable = currencyTable;
Repository = repo; Repository = repo;
@@ -102,7 +105,6 @@ namespace BTCPayServer.Controllers
_authorizationService = authorizationService; _authorizationService = authorizationService;
NetworkProvider = networkProvider; NetworkProvider = networkProvider;
_userManager = userManager; _userManager = userManager;
_serializerSettings = mvcJsonOptions.SerializerSettings;
_dashboard = dashboard; _dashboard = dashboard;
ExplorerClientProvider = explorerProvider; ExplorerClientProvider = explorerProvider;
_feeRateProvider = feeRateProvider; _feeRateProvider = feeRateProvider;
@@ -113,6 +115,7 @@ namespace BTCPayServer.Controllers
_broadcaster = broadcaster; _broadcaster = broadcaster;
_payjoinClient = payjoinClient; _payjoinClient = payjoinClient;
_labelFactory = labelFactory; _labelFactory = labelFactory;
_pullPaymentHostedService = pullPaymentHostedService;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings; _jsonSerializerSettings = jsonSerializerSettings;
_pullPaymentService = pullPaymentService; _pullPaymentService = pullPaymentService;
@@ -130,10 +133,10 @@ namespace BTCPayServer.Controllers
// does not work // does not work
[ModelBinder(typeof(WalletIdModelBinder))] [ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string transactionId, WalletId walletId, string transactionId,
string addlabel = null, string addlabel = null,
string addlabelclick = null, string addlabelclick = null,
string addcomment = null, string addcomment = null,
string removelabel = null) string removelabel = null)
{ {
addlabel = addlabel ?? addlabelclick; addlabel = addlabel ?? addlabelclick;
// Hack necessary when the user enter a empty comment and submit. // Hack necessary when the user enter a empty comment and submit.
@@ -184,7 +187,8 @@ namespace BTCPayServer.Controllers
{ {
if (walletTransactionInfo.Labels.Remove(removelabel)) if (walletTransactionInfo.Labels.Remove(removelabel))
{ {
var canDeleteColor = !walletTransactionsInfo.Any(txi => txi.Value.Labels.ContainsKey(removelabel)); var canDeleteColor =
!walletTransactionsInfo.Any(txi => txi.Value.Labels.ContainsKey(removelabel));
if (canDeleteColor) if (canDeleteColor)
{ {
walletBlobInfo.LabelColors.Remove(removelabel); walletBlobInfo.LabelColors.Remove(removelabel);
@@ -219,18 +223,18 @@ namespace BTCPayServer.Controllers
var stores = await Repository.GetStoresByUserId(GetUserId()); var stores = await Repository.GetStoresByUserId(GetUserId());
var onChainWallets = stores var onChainWallets = stores
.SelectMany(s => s.GetSupportedPaymentMethods(NetworkProvider) .SelectMany(s => s.GetSupportedPaymentMethods(NetworkProvider)
.OfType<DerivationSchemeSettings>() .OfType<DerivationSchemeSettings>()
.Select(d => ((Wallet: _walletProvider.GetWallet(d.Network), .Select(d => ((Wallet: _walletProvider.GetWallet(d.Network),
DerivationStrategy: d.AccountDerivation, DerivationStrategy: d.AccountDerivation,
Network: d.Network))) Network: d.Network)))
.Where(_ => _.Wallet != null && _.Network.WalletSupported) .Where(_ => _.Wallet != null && _.Network.WalletSupported)
.Select(_ => (Wallet: _.Wallet, .Select(_ => (Wallet: _.Wallet,
Store: s, Store: s,
Balance: GetBalanceString(_.Wallet, _.DerivationStrategy), Balance: GetBalanceString(_.Wallet, _.DerivationStrategy),
DerivationStrategy: _.DerivationStrategy, DerivationStrategy: _.DerivationStrategy,
Network: _.Network))) Network: _.Network)))
.ToList(); .ToList();
foreach (var wallet in onChainWallets) foreach (var wallet in onChainWallets)
{ {
@@ -242,6 +246,7 @@ namespace BTCPayServer.Controllers
{ {
walletVm.Balance = ""; walletVm.Balance = "";
} }
walletVm.CryptoCode = wallet.Network.CryptoCode; walletVm.CryptoCode = wallet.Network.CryptoCode;
walletVm.StoreId = wallet.Store.Id; walletVm.StoreId = wallet.Store.Id;
walletVm.Id = new WalletId(wallet.Store.Id, wallet.Network.CryptoCode); walletVm.Id = new WalletId(wallet.Store.Id, wallet.Network.CryptoCode);
@@ -276,18 +281,10 @@ namespace BTCPayServer.Controllers
var transactions = await wallet.FetchTransactions(paymentMethod.AccountDerivation); var transactions = await wallet.FetchTransactions(paymentMethod.AccountDerivation);
var walletBlob = await walletBlobAsync; var walletBlob = await walletBlobAsync;
var walletTransactionsInfo = await walletTransactionsInfoAsync; var walletTransactionsInfo = await walletTransactionsInfoAsync;
var model = new ListTransactionsViewModel var model = new ListTransactionsViewModel { Skip = skip, Count = count, Total = 0 };
{
Skip = skip,
Count = count,
Total = 0
};
if (labelFilter != null) if (labelFilter != null)
{ {
model.PaginationQuery = new Dictionary<string, object> model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
{
{"labelFilter", labelFilter}
};
} }
if (transactions == null) if (transactions == null)
{ {
@@ -302,7 +299,7 @@ namespace BTCPayServer.Controllers
else else
{ {
foreach (var tx in transactions.UnconfirmedTransactions.Transactions foreach (var tx in transactions.UnconfirmedTransactions.Transactions
.Concat(transactions.ConfirmedTransactions.Transactions).ToArray()) .Concat(transactions.ConfirmedTransactions.Transactions).ToArray())
{ {
var vm = new ListTransactionsViewModel.TransactionViewModel(); var vm = new ListTransactionsViewModel.TransactionViewModel();
vm.Id = tx.TransactionId.ToString(); vm.Id = tx.TransactionId.ToString();
@@ -327,7 +324,8 @@ namespace BTCPayServer.Controllers
} }
model.Total = model.Transactions.Count; model.Total = model.Transactions.Count;
model.Transactions = model.Transactions.OrderByDescending(t => t.Timestamp).Skip(skip).Take(count).ToList(); model.Transactions = model.Transactions.OrderByDescending(t => t.Timestamp).Skip(skip).Take(count)
.ToList();
} }
model.CryptoCode = walletId.CryptoCode; model.CryptoCode = walletId.CryptoCode;
@@ -354,8 +352,7 @@ namespace BTCPayServer.Controllers
} }
[HttpGet("{walletId}/receive")] [HttpGet("{walletId}/receive")]
public IActionResult WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] public IActionResult WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId)
WalletId walletId)
{ {
if (walletId?.StoreId == null) if (walletId?.StoreId == null)
return NotFound(); return NotFound();
@@ -371,7 +368,9 @@ namespace BTCPayServer.Controllers
var bip21 = network.GenerateBIP21(address?.ToString(), null); var bip21 = network.GenerateBIP21(address?.ToString(), null);
if (allowedPayjoin) if (allowedPayjoin)
{ {
bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint", new { walletId.CryptoCode }))); bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey,
Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint",
new { walletId.CryptoCode })));
} }
return View(new WalletReceiveViewModel() return View(new WalletReceiveViewModel()
{ {
@@ -384,8 +383,8 @@ namespace BTCPayServer.Controllers
[HttpPost] [HttpPost]
[Route("{walletId}/receive")] [Route("{walletId}/receive")]
public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
WalletId walletId, WalletReceiveViewModel viewModel, string command) WalletReceiveViewModel viewModel, string command)
{ {
if (walletId?.StoreId == null) if (walletId?.StoreId == null)
return NotFound(); return NotFound();
@@ -479,17 +478,13 @@ namespace BTCPayServer.Controllers
rateRules.Spread = 0.0m; rateRules.Spread = 0.0m;
var currencyPair = new Rating.CurrencyPair(paymentMethod.PaymentId.CryptoCode, storeData.DefaultCurrency); var currencyPair = new Rating.CurrencyPair(paymentMethod.PaymentId.CryptoCode, storeData.DefaultCurrency);
double.TryParse(defaultAmount, out var amount); double.TryParse(defaultAmount, out var amount);
var model = new WalletSendModel() var model = new WalletSendModel() { CryptoCode = walletId.CryptoCode };
{
CryptoCode = walletId.CryptoCode
};
if (bip21?.Any() is true) if (bip21?.Any() is true)
{ {
foreach (var link in bip21) foreach (var link in bip21)
{ {
if (!string.IsNullOrEmpty(link)) if (!string.IsNullOrEmpty(link))
{ {
LoadFromBIP21(model, link, network); LoadFromBIP21(model, link, network);
} }
} }
@@ -517,7 +512,10 @@ namespace BTCPayServer.Controllers
{ {
var result = await feeProvider.GetFeeRateAsync( var result = await feeProvider.GetFeeRateAsync(
(int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time)); (int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
return new WalletSendModel.FeeRateOption() { Target = time, FeeRate = result.SatoshiPerByte }; return new WalletSendModel.FeeRateOption()
{
Target = time, FeeRate = result.SatoshiPerByte
};
} }
catch (Exception) catch (Exception)
{ {
@@ -547,37 +545,46 @@ namespace BTCPayServer.Controllers
try try
{ {
cts.CancelAfter(TimeSpan.FromSeconds(5)); cts.CancelAfter(TimeSpan.FromSeconds(5));
var result = await RateFetcher.FetchRate(currencyPair, rateRules, cts.Token).WithCancellation(cts.Token); var result = await RateFetcher.FetchRate(currencyPair, rateRules, cts.Token)
.WithCancellation(cts.Token);
if (result.BidAsk != null) if (result.BidAsk != null)
{ {
model.Rate = result.BidAsk.Center; model.Rate = result.BidAsk.Center;
model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(currencyPair.Right, true).CurrencyDecimalDigits; model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(currencyPair.Right, true)
.CurrencyDecimalDigits;
model.Fiat = currencyPair.Right; model.Fiat = currencyPair.Right;
} }
else else
{ {
model.RateError = $"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType<object>().ToArray())})"; model.RateError =
$"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType<object>().ToArray())})";
} }
} }
catch (Exception ex) { model.RateError = ex.Message; } catch (Exception ex) { model.RateError = ex.Message; }
} }
return View(model); return View(model);
} }
private async Task<string> GetSeed(WalletId walletId, BTCPayNetwork network) private async Task<string> GetSeed(WalletId walletId, BTCPayNetwork network)
{ {
return await CanUseHotWallet() && return await CanUseHotWallet() &&
GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s && GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s &&
s.IsHotWallet && s.IsHotWallet &&
ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client && ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client &&
await client.GetMetadataAsync<string>(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is string seed && await client.GetMetadataAsync<string>(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is
!string.IsNullOrEmpty(seed) ? seed : null; string seed &&
!string.IsNullOrEmpty(seed)
? seed
: null;
} }
[HttpPost("{walletId}/send")] [HttpPost("{walletId}/send")]
public async Task<IActionResult> WalletSend( public async Task<IActionResult> WalletSend(
[ModelBinder(typeof(WalletIdModelBinder))] [ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSendModel vm, string command = "", CancellationToken cancellation = default, string bip21 = "") WalletId walletId, WalletSendModel vm, string command = "", CancellationToken cancellation = default,
string bip21 = "")
{ {
if (walletId?.StoreId == null) if (walletId?.StoreId == null)
return NotFound(); return NotFound();
@@ -606,7 +613,8 @@ namespace BTCPayServer.Controllers
var walletBlobAsync = await WalletRepository.GetWalletInfo(walletId); var walletBlobAsync = await WalletRepository.GetWalletInfo(walletId);
var walletTransactionsInfoAsync = await WalletRepository.GetWalletTransactionsInfo(walletId); var walletTransactionsInfoAsync = await WalletRepository.GetWalletTransactionsInfo(walletId);
var utxos = await _walletProvider.GetWallet(network).GetUnspentCoins(schemeSettings.AccountDerivation, cancellation); var utxos = await _walletProvider.GetWallet(network)
.GetUnspentCoins(schemeSettings.AccountDerivation, cancellation);
vm.InputsAvailable = utxos.Select(coin => vm.InputsAvailable = utxos.Select(coin =>
{ {
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info); walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info);
@@ -615,8 +623,12 @@ namespace BTCPayServer.Controllers
Outpoint = coin.OutPoint.ToString(), Outpoint = coin.OutPoint.ToString(),
Amount = coin.Value.GetValue(network), Amount = coin.Value.GetValue(network),
Comment = info?.Comment, Comment = info?.Comment,
Labels = info == null ? null : _labelFactory.ColorizeTransactionLabels(walletBlobAsync, info, Request), Labels =
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, coin.OutPoint.Hash.ToString()), info == null
? null
: _labelFactory.ColorizeTransactionLabels(walletBlobAsync, info, Request),
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink,
coin.OutPoint.Hash.ToString()),
Confirmations = coin.Confirmations Confirmations = coin.Confirmations
}; };
}).ToArray(); }).ToArray();
@@ -645,7 +657,9 @@ namespace BTCPayServer.Controllers
if (command.StartsWith("remove-output", StringComparison.InvariantCultureIgnoreCase)) if (command.StartsWith("remove-output", StringComparison.InvariantCultureIgnoreCase))
{ {
ModelState.Clear(); ModelState.Clear();
var index = int.Parse(command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1), CultureInfo.InvariantCulture); var index = int.Parse(
command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
CultureInfo.InvariantCulture);
vm.Outputs.RemoveAt(index); vm.Outputs.RemoveAt(index);
return View(vm); return View(vm);
} }
@@ -657,6 +671,8 @@ namespace BTCPayServer.Controllers
return View(vm); return View(vm);
} }
var bypassBalanceChecks = command == "schedule";
var subtractFeesOutputsCount = new List<int>(); var subtractFeesOutputsCount = new List<int>();
var substractFees = vm.Outputs.Any(o => o.SubtractFeesFromOutput); var substractFees = vm.Outputs.Any(o => o.SubtractFeesFromOutput);
for (var i = 0; i < vm.Outputs.Count; i++) for (var i = 0; i < vm.Outputs.Count; i++)
@@ -669,17 +685,20 @@ namespace BTCPayServer.Controllers
transactionOutput.DestinationAddress = transactionOutput.DestinationAddress?.Trim() ?? string.Empty; transactionOutput.DestinationAddress = transactionOutput.DestinationAddress?.Trim() ?? string.Empty;
var inputName = var inputName =
string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].", i.ToString(CultureInfo.InvariantCulture)) + string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].",
nameof(transactionOutput.DestinationAddress); i.ToString(CultureInfo.InvariantCulture)) +
nameof(transactionOutput.DestinationAddress);
try try
{ {
var address = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork); var address = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork);
if (address is TaprootAddress) if (address is TaprootAddress)
{ {
var supportTaproot = _dashboard.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanSupportTaproot; var supportTaproot = _dashboard.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities
?.CanSupportTaproot;
if (!(supportTaproot is true)) if (!(supportTaproot is true))
{ {
ModelState.AddModelError(inputName, "You need to update your full node, and/or NBXplorer (Version >= 2.1.56) to be able to send to a taproot address."); ModelState.AddModelError(inputName,
"You need to update your full node, and/or NBXplorer (Version >= 2.1.56) to be able to send to a taproot address.");
} }
} }
} }
@@ -688,7 +707,7 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(inputName, "Invalid address"); ModelState.AddModelError(inputName, "Invalid address");
} }
if (transactionOutput.Amount.HasValue) if (!bypassBalanceChecks && transactionOutput.Amount.HasValue)
{ {
transactionAmountSum += transactionOutput.Amount.Value; transactionAmountSum += transactionOutput.Amount.Value;
@@ -700,41 +719,120 @@ namespace BTCPayServer.Controllers
} }
} }
if (subtractFeesOutputsCount.Count > 1) if (!bypassBalanceChecks)
{ {
foreach (var subtractFeesOutput in subtractFeesOutputsCount) if (subtractFeesOutputsCount.Count > 1)
{ {
vm.AddModelError(model => model.Outputs[subtractFeesOutput].SubtractFeesFromOutput, foreach (var subtractFeesOutput in subtractFeesOutputsCount)
"You can only subtract fees from one output", this); {
vm.AddModelError(model => model.Outputs[subtractFeesOutput].SubtractFeesFromOutput,
"You can only subtract fees from one output", this);
}
}
else if (vm.CurrentBalance == transactionAmountSum && !substractFees)
{
ModelState.AddModelError(string.Empty,
"You are sending your entire balance, you should subtract the fees from an output");
}
if (vm.CurrentBalance < transactionAmountSum)
{
for (var i = 0; i < vm.Outputs.Count; i++)
{
vm.AddModelError(model => model.Outputs[i].Amount,
"You are sending more than what you own", this);
}
}
if (vm.FeeSatoshiPerByte is decimal fee)
{
if (fee < 0)
{
vm.AddModelError(model => model.FeeSatoshiPerByte,
"The fee rate should be above 0", this);
}
} }
}
else if (vm.CurrentBalance == transactionAmountSum && !substractFees)
{
ModelState.AddModelError(string.Empty,
"You are sending your entire balance, you should subtract the fees from an output");
} }
if (vm.CurrentBalance < transactionAmountSum)
{
for (var i = 0; i < vm.Outputs.Count; i++)
{
vm.AddModelError(model => model.Outputs[i].Amount,
"You are sending more than what you own", this);
}
}
if (vm.FeeSatoshiPerByte is decimal fee)
{
if (fee < 0)
{
vm.AddModelError(model => model.FeeSatoshiPerByte,
"The fee rate should be above 0", this);
}
}
if (!ModelState.IsValid) if (!ModelState.IsValid)
return View(vm); return View(vm);
DerivationSchemeSettings derivationScheme = GetDerivationSchemeSettings(walletId); DerivationSchemeSettings derivationScheme = GetDerivationSchemeSettings(walletId);
CreatePSBTResponse psbtResponse; CreatePSBTResponse psbtResponse;
if (command == "schedule")
{
var pmi = new PaymentMethodId(walletId.CryptoCode, BitcoinPaymentType.Instance);
var claims =
vm.Outputs.Where(output => string.IsNullOrEmpty(output.PayoutId)).Select(output => new ClaimRequest()
{
Destination = new AddressClaimDestination(
BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)),
Value = output.Amount.Value,
PaymentMethodId = pmi,
StoreId = walletId.StoreId,
PreApprove = true,
}).ToArray();
var someFailed = false;
string message = null;
string errorMessage = null;
var result = new Dictionary<ClaimRequest, ClaimRequest.ClaimResult>();
foreach (ClaimRequest claimRequest in claims)
{
var response = await _pullPaymentHostedService.Claim(claimRequest);
result.Add(claimRequest, response.Result);
if (response.Result == ClaimRequest.ClaimResult.Ok)
{
if (message is null)
{
message = "Payouts scheduled:<br/>";
}
message += $"{claimRequest.Value} to {claimRequest.Destination.ToString()}<br/>";
}
else
{
someFailed = true;
if (errorMessage is null)
{
errorMessage = "Payouts failed to be scheduled:<br/>";
}
switch (response.Result)
{
case ClaimRequest.ClaimResult.Duplicate:
errorMessage += $"{claimRequest.Value} to {claimRequest.Destination.ToString() } - address reuse<br/>";
break;
case ClaimRequest.ClaimResult.AmountTooLow:
errorMessage += $"{claimRequest.Value} to {claimRequest.Destination.ToString() } - amount too low<br/>";
break;
}
}
}
if (message is not null && errorMessage is not null)
{
message += $"<br/><br/>{errorMessage}";
}
else if(message is null && errorMessage is not null)
{
message = errorMessage;
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity =someFailed? StatusMessageModel.StatusSeverity.Warning:
StatusMessageModel.StatusSeverity.Success,
Html = message
});
return RedirectToAction("Payouts", "UIStorePullPayments",
new
{
storeId = walletId.StoreId,
PaymentMethodId = pmi.ToString(),
payoutState = PayoutState.AwaitingPayment,
});
}
try try
{ {
psbtResponse = await CreatePSBT(network, derivationScheme, vm, cancellation); psbtResponse = await CreatePSBT(network, derivationScheme, vm, cancellation);
@@ -763,23 +861,17 @@ namespace BTCPayServer.Controllers
switch (command) switch (command)
{ {
case "sign": case "sign":
return await WalletSign(walletId, new WalletPSBTViewModel() return await WalletSign(walletId, new WalletPSBTViewModel() { SigningContext = signingContext });
{
SigningContext = signingContext
});
case "analyze-psbt": case "analyze-psbt":
var name = var name =
$"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt"; $"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt";
return RedirectToWalletPSBT(new WalletPSBTViewModel return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), FileName = name });
{
PSBT = psbt.ToBase64(),
FileName = name
});
default: default:
return View(vm); return View(vm);
} }
} }
private void LoadFromBIP21(WalletSendModel vm, string bip21, BTCPayNetwork network) private void LoadFromBIP21(WalletSendModel vm, string bip21, BTCPayNetwork network)
{ {
vm.Outputs ??= new List<WalletSendModel.TransactionOutput>(); vm.Outputs ??= new List<WalletSendModel.TransactionOutput>();
@@ -791,7 +883,10 @@ namespace BTCPayServer.Controllers
{ {
Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC), Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC),
DestinationAddress = uriBuilder.Address.ToString(), DestinationAddress = uriBuilder.Address.ToString(),
SubtractFeesFromOutput = false SubtractFeesFromOutput = false,
PayoutId = uriBuilder.UnknownParameters.ContainsKey("payout")
? uriBuilder.UnknownParameters["payout"]
: null
}); });
if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message)) if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message))
{ {
@@ -811,9 +906,9 @@ namespace BTCPayServer.Controllers
try try
{ {
vm.Outputs.Add(new WalletSendModel.TransactionOutput() vm.Outputs.Add(new WalletSendModel.TransactionOutput()
{ {
DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString() DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString()
} }
); );
} }
catch catch
@@ -831,23 +926,22 @@ namespace BTCPayServer.Controllers
private IActionResult ViewVault(WalletId walletId, SigningContextModel signingContext) private IActionResult ViewVault(WalletId walletId, SigningContextModel signingContext)
{ {
return View(nameof(WalletSendVault), new WalletSendVaultModel() return View(nameof(WalletSendVault),
{ new WalletSendVaultModel()
SigningContext = signingContext, {
WalletId = walletId.ToString(), SigningContext = signingContext,
WebsocketPath = this.Url.Action(nameof(UIVaultController.VaultBridgeConnection), "UIVault", new { walletId = walletId.ToString() }) WalletId = walletId.ToString(),
}); WebsocketPath = this.Url.Action(nameof(UIVaultController.VaultBridgeConnection), "UIVault",
new { walletId = walletId.ToString() })
});
} }
[HttpPost] [HttpPost]
[Route("{walletId}/vault")] [Route("{walletId}/vault")]
public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
WalletId walletId, WalletSendVaultModel model) WalletSendVaultModel model)
{ {
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel() return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel() { SigningContext = model.SigningContext });
{
SigningContext = model.SigningContext
});
} }
private IActionResult RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm) private IActionResult RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm)
@@ -886,7 +980,8 @@ namespace BTCPayServer.Controllers
redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT); redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT);
redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT); redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT);
redirectVm.FormParameters.Add("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21); redirectVm.FormParameters.Add("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21);
redirectVm.FormParameters.Add("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)); redirectVm.FormParameters.Add("SigningContext.EnforceLowR",
signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture));
redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress); redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress);
} }
@@ -897,29 +992,21 @@ namespace BTCPayServer.Controllers
AspController = "UIWallets", AspController = "UIWallets",
AspAction = nameof(WalletPSBT), AspAction = nameof(WalletPSBT),
RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } }, RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } },
FormParameters = FormParameters = { { "psbt", vm.PSBT }, { "fileName", vm.FileName }, { "command", "decode" }, }
{
{ "psbt", vm.PSBT },
{ "fileName", vm.FileName },
{ "command", "decode" },
}
}; };
return View("PostRedirect", redirectVm); return View("PostRedirect", redirectVm);
} }
[HttpGet("{walletId}/psbt/seed")] [HttpGet("{walletId}/psbt/seed")]
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
WalletId walletId, SigningContextModel signingContext) SigningContextModel signingContext)
{ {
return View(nameof(SignWithSeed), new SignWithSeedViewModel return View(nameof(SignWithSeed), new SignWithSeedViewModel { SigningContext = signingContext });
{
SigningContext = signingContext
});
} }
[HttpPost("{walletId}/psbt/seed")] [HttpPost("{walletId}/psbt/seed")]
public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
WalletId walletId, SignWithSeedViewModel viewModel) SignWithSeedViewModel viewModel)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
@@ -957,7 +1044,8 @@ namespace BTCPayServer.Controllers
RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath(); RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath();
if (rootedKeyPath == null) if (rootedKeyPath == null)
{ {
ModelState.AddModelError(nameof(viewModel.SeedOrKey), "The master fingerprint and/or account key path of your seed are not set in the wallet settings."); ModelState.AddModelError(nameof(viewModel.SeedOrKey),
"The master fingerprint and/or account key path of your seed are not set in the wallet settings.");
return View(nameof(SignWithSeed), viewModel); return View(nameof(SignWithSeed), viewModel);
} }
// The user gave the root key, let's try to rebase the PSBT, and derive the account private key // The user gave the root key, let's try to rebase the PSBT, and derive the account private key
@@ -968,7 +1056,8 @@ namespace BTCPayServer.Controllers
} }
else else
{ {
ModelState.AddModelError(nameof(viewModel.SeedOrKey), "The master fingerprint does not match the one set in your wallet settings. Probable causes are: wrong seed, wrong passphrase or wrong fingerprint in your wallet settings."); ModelState.AddModelError(nameof(viewModel.SeedOrKey),
"The master fingerprint does not match the one set in your wallet settings. Probable causes are: wrong seed, wrong passphrase or wrong fingerprint in your wallet settings.");
return View(nameof(SignWithSeed), viewModel); return View(nameof(SignWithSeed), viewModel);
} }
@@ -979,17 +1068,15 @@ namespace BTCPayServer.Controllers
var changed = psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath)); var changed = psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath));
if (!changed) if (!changed)
{ {
var update = new UpdatePSBTRequest() var update = new UpdatePSBTRequest() { PSBT = psbt, DerivationScheme = settings.AccountDerivation };
{
PSBT = psbt,
DerivationScheme = settings.AccountDerivation
};
update.RebaseKeyPaths = settings.GetPSBTRebaseKeyRules().ToList(); update.RebaseKeyPaths = settings.GetPSBTRebaseKeyRules().ToList();
psbt = (await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(update))?.PSBT; psbt = (await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(update))?.PSBT;
changed = psbt is not null && psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath)); changed = psbt is not null && psbt.PSBTChanged(() =>
psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath));
if (!changed) if (!changed)
{ {
ModelState.AddModelError(nameof(viewModel.SeedOrKey), "Impossible to sign the transaction. Probable causes: Incorrect account key path in wallet settings or PSBT already signed."); ModelState.AddModelError(nameof(viewModel.SeedOrKey),
"Impossible to sign the transaction. Probable causes: Incorrect account key path in wallet settings or PSBT already signed.");
return View(nameof(SignWithSeed), viewModel); return View(nameof(SignWithSeed), viewModel);
} }
} }
@@ -1021,8 +1108,10 @@ namespace BTCPayServer.Controllers
var vm = new RescanWalletModel(); var vm = new RescanWalletModel();
vm.IsFullySync = _dashboard.IsFullySynched(walletId.CryptoCode, out var unused); vm.IsFullySync = _dashboard.IsFullySynched(walletId.CryptoCode, out var unused);
vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded; vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings))
vm.IsSupportedByCurrency = _dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true; .Succeeded;
vm.IsSupportedByCurrency =
_dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true;
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation); var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation);
if (scanProgress != null) if (scanProgress != null)
@@ -1040,12 +1129,15 @@ namespace BTCPayServer.Controllers
vm.RemainingTime = TimeSpan.FromSeconds(scanProgress.Progress.RemainingSeconds).PrettyPrint(); vm.RemainingTime = TimeSpan.FromSeconds(scanProgress.Progress.RemainingSeconds).PrettyPrint();
} }
} }
if (scanProgress.Status == ScanUTXOStatus.Complete) if (scanProgress.Status == ScanUTXOStatus.Complete)
{ {
vm.LastSuccess = scanProgress.Progress; vm.LastSuccess = scanProgress.Progress;
vm.TimeOfScan = (scanProgress.Progress.CompletedAt.Value - scanProgress.Progress.StartedAt).PrettyPrint(); vm.TimeOfScan = (scanProgress.Progress.CompletedAt.Value - scanProgress.Progress.StartedAt)
.PrettyPrint();
} }
} }
return View(vm); return View(vm);
} }
@@ -1063,12 +1155,13 @@ namespace BTCPayServer.Controllers
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
try try
{ {
await explorer.ScanUTXOSetAsync(paymentMethod.AccountDerivation, vm.BatchSize, vm.GapLimit, vm.StartingIndex); await explorer.ScanUTXOSetAsync(paymentMethod.AccountDerivation, vm.BatchSize, vm.GapLimit,
vm.StartingIndex);
} }
catch (NBXplorerException ex) when (ex.Error.Code == "scanutxoset-in-progress") catch (NBXplorerException ex) when (ex.Error.Code == "scanutxoset-in-progress")
{ {
} }
return RedirectToAction(); return RedirectToAction();
} }
@@ -1077,7 +1170,8 @@ namespace BTCPayServer.Controllers
return GetCurrentStore().GetDerivationSchemeSettings(NetworkProvider, walletId.CryptoCode); return GetCurrentStore().GetDerivationSchemeSettings(NetworkProvider, walletId.CryptoCode);
} }
private static async Task<IMoney> GetBalanceAsMoney(BTCPayWallet wallet, DerivationStrategyBase derivationStrategy) private static async Task<IMoney> GetBalanceAsMoney(BTCPayWallet wallet,
DerivationStrategyBase derivationStrategy)
{ {
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try try
@@ -1117,60 +1211,68 @@ namespace BTCPayServer.Controllers
switch (command) switch (command)
{ {
case "cpfp": case "cpfp":
{
selectedTransactions ??= Array.Empty<string>();
if (selectedTransactions.Length == 0)
{ {
selectedTransactions ??= Array.Empty<string>(); TempData[WellKnownTempData.ErrorMessage] = $"No transaction selected";
if (selectedTransactions.Length == 0) return RedirectToAction(nameof(WalletTransactions), new { walletId });
{ }
TempData[WellKnownTempData.ErrorMessage] = $"No transaction selected";
return RedirectToAction(nameof(WalletTransactions), new { walletId }); var parameters = new MultiValueDictionary<string, string>();
} parameters.Add("walletId", walletId.ToString());
var parameters = new MultiValueDictionary<string, string>(); int i = 0;
parameters.Add("walletId", walletId.ToString()); foreach (var tx in selectedTransactions)
int i = 0; {
foreach (var tx in selectedTransactions) parameters.Add($"transactionHashes[{i}]", tx);
{ i++;
parameters.Add($"transactionHashes[{i}]", tx); }
i++;
} parameters.Add("returnUrl", Url.Action(nameof(WalletTransactions), new { walletId }));
parameters.Add("returnUrl", Url.Action(nameof(WalletTransactions), new { walletId })); return View("PostRedirect",
return View("PostRedirect", new PostRedirectViewModel new PostRedirectViewModel
{ {
AspController = "UIWallets", AspController = "UIWallets",
AspAction = nameof(UIWalletsController.WalletCPFP), AspAction = nameof(UIWalletsController.WalletCPFP),
RouteParameters = { { "walletId", walletId.ToString() } }, RouteParameters = { { "walletId", walletId.ToString() } },
FormParameters = parameters FormParameters = parameters
}); });
} }
case "prune": case "prune":
{
var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
.PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken);
if (result.TotalPruned == 0)
{ {
var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode).PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken); TempData[WellKnownTempData.SuccessMessage] = "The wallet is already pruned";
if (result.TotalPruned == 0) }
{ else
TempData[WellKnownTempData.SuccessMessage] = "The wallet is already pruned"; {
} TempData[WellKnownTempData.SuccessMessage] =
else $"The wallet has been successfully pruned ({result.TotalPruned} transactions have been removed from the history)";
{ }
TempData[WellKnownTempData.SuccessMessage] =
$"The wallet has been successfully pruned ({result.TotalPruned} transactions have been removed from the history)";
}
return RedirectToAction(nameof(WalletTransactions), new { walletId }); return RedirectToAction(nameof(WalletTransactions), new { walletId });
} }
case "clear" when User.IsInRole(Roles.ServerAdmin): case "clear" when User.IsInRole(Roles.ServerAdmin):
{
if (Version.TryParse(_dashboard.Get(walletId.CryptoCode)?.Status?.Version ?? "0.0.0.0",
out var v) &&
v < new Version(2, 2, 4))
{ {
if (Version.TryParse(_dashboard.Get(walletId.CryptoCode)?.Status?.Version ?? "0.0.0.0", out var v) && TempData[WellKnownTempData.ErrorMessage] =
v < new Version(2, 2, 4)) "This version of NBXplorer doesn't support this operation, please upgrade to 2.2.4 or above";
{
TempData[WellKnownTempData.ErrorMessage] = "This version of NBXplorer doesn't support this operation, please upgrade to 2.2.4 or above";
}
else
{
await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
.WipeAsync(derivationScheme.AccountDerivation, cancellationToken);
TempData[WellKnownTempData.SuccessMessage] = "The transactions have been wiped out, to restore your balance, rescan the wallet.";
}
return RedirectToAction(nameof(WalletTransactions), new { walletId });
} }
else
{
await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode)
.WipeAsync(derivationScheme.AccountDerivation, cancellationToken);
TempData[WellKnownTempData.SuccessMessage] =
"The transactions have been wiped out, to restore your balance, rescan the wallet.";
}
return RedirectToAction(nameof(WalletTransactions), new { walletId });
}
default: default:
return NotFound(); return NotFound();
} }
@@ -1199,7 +1301,6 @@ namespace BTCPayServer.Controllers
public class SendToAddressResult public class SendToAddressResult
{ {
[JsonProperty("psbt")] [JsonProperty("psbt")] public string PSBT { get; set; }
public string PSBT { get; set; }
} }
} }

View File

@@ -4,9 +4,10 @@ namespace BTCPayServer.Data
{ {
public static class InvoiceDataExtensions public static class InvoiceDataExtensions
{ {
public static InvoiceEntity GetBlob(this Data.InvoiceData invoiceData, BTCPayNetworkProvider networks) public static InvoiceEntity GetBlob(this InvoiceData invoiceData, BTCPayNetworkProvider networks)
{ {
var entity = NBitcoin.JsonConverters.Serializer.ToObject<InvoiceEntity>(ZipUtils.Unzip(invoiceData.Blob), null);
var entity = InvoiceRepository.FromBytes<InvoiceEntity>(invoiceData.Blob);
entity.Networks = networks; entity.Networks = networks;
if (entity.Metadata is null) if (entity.Metadata is null)
{ {

View File

@@ -6,7 +6,7 @@ namespace BTCPayServer.Data
{ {
public static class PaymentDataExtensions public static class PaymentDataExtensions
{ {
public static PaymentEntity GetBlob(this Data.PaymentData paymentData, BTCPayNetworkProvider networks) public static PaymentEntity GetBlob(this PaymentData paymentData, BTCPayNetworkProvider networks)
{ {
var unziped = ZipUtils.Unzip(paymentData.Blob); var unziped = ZipUtils.Unzip(paymentData.Blob);
var cryptoCode = "BTC"; var cryptoCode = "BTC";

View File

@@ -5,6 +5,7 @@ using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer; using BTCPayServer;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
@@ -243,8 +244,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
&& data.State == PayoutState.AwaitingPayment) && data.State == PayoutState.AwaitingPayment)
.ToListAsync(); .ToListAsync();
var pullPaymentIds = payouts.Select(data => data.PullPaymentDataId).Distinct().ToArray(); var pullPaymentIds = payouts.Select(data => data.PullPaymentDataId).Distinct().Where(s => s!= null).ToArray();
var storeId = payouts.First().PullPaymentData.StoreId; var storeId = payouts.First().StoreDataId;
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode); var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
List<string> bip21 = new List<string>(); List<string> bip21 = new List<string>();
foreach (var payout in payouts) foreach (var payout in payouts)
@@ -261,10 +262,14 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
{ {
case UriClaimDestination uriClaimDestination: case UriClaimDestination uriClaimDestination:
uriClaimDestination.BitcoinUrl.Amount = new Money(blob.CryptoAmount.Value, MoneyUnit.BTC); uriClaimDestination.BitcoinUrl.Amount = new Money(blob.CryptoAmount.Value, MoneyUnit.BTC);
bip21.Add(uriClaimDestination.ToString()); var newUri = new UriBuilder(uriClaimDestination.BitcoinUrl.Uri);
BTCPayServerClient.AppendPayloadToQuery(newUri, new KeyValuePair<string, object>("payout", payout.Id));
bip21.Add(newUri.Uri.ToString());
break; break;
case AddressClaimDestination addressClaimDestination: case AddressClaimDestination addressClaimDestination:
bip21.Add(network.GenerateBIP21(addressClaimDestination.Address.ToString(), new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)).ToString()); var bip21New = network.GenerateBIP21(addressClaimDestination.Address.ToString(), new Money(blob.CryptoAmount.Value, MoneyUnit.BTC));
bip21New.QueryParams.Add("payout", payout.Id);
bip21.Add(bip21New.ToString());
break; break;
} }
} }
@@ -326,7 +331,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
} }
} }
if (proof.TransactionId is null && !proof.Candidates.Contains(proof.TransactionId)) if (proof.TransactionId is not null && !proof.Candidates.Contains(proof.TransactionId))
{ {
proof.TransactionId = null; proof.TransactionId = null;
} }
@@ -366,8 +371,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
await using var ctx = _dbContextFactory.CreateContext(); await using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts var payouts = await ctx.Payouts
.Include(o => o.StoreData)
.Include(o => o.PullPaymentData) .Include(o => o.PullPaymentData)
.ThenInclude(o => o.StoreData)
.Where(p => p.State == PayoutState.AwaitingPayment) .Where(p => p.State == PayoutState.AwaitingPayment)
.Where(p => p.PaymentMethodId == paymentMethodId.ToString()) .Where(p => p.PaymentMethodId == paymentMethodId.ToString())
#pragma warning disable CA1307 // Specify StringComparison #pragma warning disable CA1307 // Specify StringComparison
@@ -386,7 +391,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
BTCPayServer.Extensions.RoundUp(payoutBlob.CryptoAmount.Value, network.Divisibility)) BTCPayServer.Extensions.RoundUp(payoutBlob.CryptoAmount.Value, network.Divisibility))
return; return;
var derivationSchemeSettings = payout.PullPaymentData.StoreData var derivationSchemeSettings = payout.StoreData
.GetDerivationSchemeSettings(_btcPayNetworkProvider, newTransaction.CryptoCode).AccountDerivation; .GetDerivationSchemeSettings(_btcPayNetworkProvider, newTransaction.CryptoCode).AccountDerivation;
var storeWalletMatched = (await _explorerClientProvider.GetExplorerClient(newTransaction.CryptoCode) var storeWalletMatched = (await _explorerClientProvider.GetExplorerClient(newTransaction.CryptoCode)
@@ -403,19 +408,19 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
if (isInternal) if (isInternal)
{ {
payout.State = PayoutState.InProgress; payout.State = PayoutState.InProgress;
var walletId = new WalletId(payout.PullPaymentData.StoreId, newTransaction.CryptoCode); var walletId = new WalletId(payout.StoreDataId, newTransaction.CryptoCode);
_eventAggregator.Publish(new UpdateTransactionLabel(walletId, _eventAggregator.Publish(new UpdateTransactionLabel(walletId,
newTransaction.NewTransactionEvent.TransactionData.TransactionHash, newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
UpdateTransactionLabel.PayoutTemplate(payout.Id, payout.PullPaymentDataId, walletId.ToString()))); UpdateTransactionLabel.PayoutTemplate(payout.Id, payout.PullPaymentDataId, walletId.ToString())));
} }
else else
{ {
await _notificationSender.SendNotification(new StoreScope(payout.PullPaymentData.StoreId), await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId),
new ExternalPayoutTransactionNotification() new ExternalPayoutTransactionNotification()
{ {
PaymentMethod = payout.PaymentMethodId, PaymentMethod = payout.PaymentMethodId,
PayoutId = payout.Id, PayoutId = payout.Id,
StoreId = payout.PullPaymentData.StoreId StoreId = payout.StoreDataId
}); });
} }
@@ -431,7 +436,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
} }
} }
private void SetProofBlob(PayoutData data, PayoutTransactionOnChainBlob blob) public void SetProofBlob(PayoutData data, PayoutTransactionOnChainBlob blob)
{ {
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, _jsonSerializerSettings.GetSerializer(data.GetPaymentMethodId().CryptoCode))); var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, _jsonSerializerSettings.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
// We only update the property if the bytes actually changed, this prevent from hammering the DB too much // We only update the property if the bytes actually changed, this prevent from hammering the DB too much

View File

@@ -1,3 +1,4 @@
#nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -15,8 +16,8 @@ public interface IPayoutHandler
public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination); public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination);
//Allows payout handler to parse payout destinations on its own //Allows payout handler to parse payout destinations on its own
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination); public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination);
public (bool valid, string error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob pullPaymentBlob); public (bool valid, string? error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob? pullPaymentBlob);
public async Task<(IClaimDestination destination, string error)> ParseAndValidateClaimDestination(PaymentMethodId paymentMethodId, string destination, PullPaymentBlob pullPaymentBlob) public async Task<(IClaimDestination? destination, string? error)> ParseAndValidateClaimDestination(PaymentMethodId paymentMethodId, string destination, PullPaymentBlob? pullPaymentBlob)
{ {
var res = await ParseClaimDestination(paymentMethodId, destination); var res = await ParseClaimDestination(paymentMethodId, destination);
if (res.destination is null) if (res.destination is null)

View File

@@ -108,7 +108,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
if (claimDestination is not BoltInvoiceClaimDestination bolt) if (claimDestination is not BoltInvoiceClaimDestination bolt)
return (true, null); return (true, null);
var invoice = bolt.PaymentRequest; var invoice = bolt.PaymentRequest;
if ((invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow) < pullPaymentBlob.BOLT11Expiration) if (pullPaymentBlob is not null && (invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow) < pullPaymentBlob.BOLT11Expiration)
{ {
return (false, return (false,
$"The BOLT11 invoice must have an expiry date of at least {(long)pullPaymentBlob.BOLT11Expiration.TotalDays} days from submission (Provided was only {(invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow).Days})."); $"The BOLT11 invoice must have an expiry date of at least {(long)pullPaymentBlob.BOLT11Expiration.TotalDays} days from submission (Provided was only {(invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow).Days}).");

View File

@@ -19,9 +19,9 @@ namespace BTCPayServer.Data
if (includePullPayment) if (includePullPayment)
query = query.Include(p => p.PullPaymentData); query = query.Include(p => p.PullPaymentData);
if (includeStore) if (includeStore)
query = query.Include(p => p.PullPaymentData.StoreData); query = query.Include(p => p.StoreData);
var payout = await query.Where(p => p.Id == payoutId && var payout = await query.Where(p => p.Id == payoutId &&
p.PullPaymentData.StoreId == storeId).FirstOrDefaultAsync(); p.StoreDataId == storeId).FirstOrDefaultAsync();
if (payout is null) if (payout is null)
return null; return null;
return payout; return payout;

View File

@@ -2,6 +2,7 @@ namespace BTCPayServer.Events
{ {
public class SettingsChanged<T> public class SettingsChanged<T>
{ {
public string SettingsName { get; set; }
public T Settings { get; set; } public T Settings { get; set; }
public override string ToString() public override string ToString()
{ {

View File

@@ -1,5 +1,6 @@
using BTCPayServer; using BTCPayServer;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -63,13 +64,13 @@ namespace Microsoft.AspNetCore.Mvc
scheme, host, pathbase); scheme, host, pathbase);
} }
public static string PayoutLink(this LinkGenerator urlHelper, string walletIdOrStoreId, string pullPaymentId, string scheme, HostString host, string pathbase) public static string PayoutLink(this LinkGenerator urlHelper, string walletIdOrStoreId, string pullPaymentId, PayoutState payoutState,string scheme, HostString host, string pathbase)
{ {
WalletId.TryParse(walletIdOrStoreId, out var wallet); WalletId.TryParse(walletIdOrStoreId, out var wallet);
return urlHelper.GetUriByAction( return urlHelper.GetUriByAction(
action: nameof(UIStorePullPaymentsController.Payouts), action: nameof(UIStorePullPaymentsController.Payouts),
controller: "UIStorePullPayments", controller: "UIStorePullPayments",
values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId }, values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId, payoutState },
scheme, host, pathbase); scheme, host, pathbase);
} }
} }

View File

@@ -13,11 +13,17 @@ namespace BTCPayServer.HostedServices
private CancellationTokenSource _Cts = new CancellationTokenSource(); private CancellationTokenSource _Cts = new CancellationTokenSource();
protected Task[] _Tasks; protected Task[] _Tasks;
public readonly Logs Logs; public readonly Logs Logs;
public BaseAsyncService(Logs logs)
protected BaseAsyncService(Logs logs)
{ {
Logs = logs; Logs = logs;
} }
protected BaseAsyncService(ILogger logger)
{
Logs = new Logs() { PayServer = logger, Events = logger, Configuration = logger};
}
public virtual Task StartAsync(CancellationToken cancellationToken) public virtual Task StartAsync(CancellationToken cancellationToken)
{ {
_Tasks = InitializeTasks(); _Tasks = InitializeTasks();

View File

@@ -26,6 +26,12 @@ namespace BTCPayServer.HostedServices
Logs = logs; Logs = logs;
} }
public EventHostedServiceBase(EventAggregator eventAggregator, ILogger logger)
{
_EventAggregator = eventAggregator;
Logs = new Logs() { PayServer = logger, Events = logger, Configuration = logger};
}
readonly Channel<object> _Events = Channel.CreateUnbounded<object>(); readonly Channel<object> _Events = Channel.CreateUnbounded<object>();
public async Task ProcessEvents(CancellationToken cancellationToken) public async Task ProcessEvents(CancellationToken cancellationToken)
{ {

View File

@@ -37,6 +37,7 @@ namespace BTCPayServer.HostedServices
public TimeSpan? Period { get; set; } public TimeSpan? Period { get; set; }
public TimeSpan? BOLT11Expiration { get; set; } public TimeSpan? BOLT11Expiration { get; set; }
} }
public class PullPaymentHostedService : BaseAsyncService public class PullPaymentHostedService : BaseAsyncService
{ {
public class CancelRequest public class CancelRequest
@@ -46,15 +47,18 @@ namespace BTCPayServer.HostedServices
ArgumentNullException.ThrowIfNull(pullPaymentId); ArgumentNullException.ThrowIfNull(pullPaymentId);
PullPaymentId = pullPaymentId; PullPaymentId = pullPaymentId;
} }
public CancelRequest(string[] payoutIds) public CancelRequest(string[] payoutIds)
{ {
ArgumentNullException.ThrowIfNull(payoutIds); ArgumentNullException.ThrowIfNull(payoutIds);
PayoutIds = payoutIds; PayoutIds = payoutIds;
} }
public string PullPaymentId { get; set; } public string PullPaymentId { get; set; }
public string[] PayoutIds { get; set; } public string[] PayoutIds { get; set; }
internal TaskCompletionSource<bool> Completion { get; set; } internal TaskCompletionSource<bool> Completion { get; set; }
} }
public class PayoutApproval public class PayoutApproval
{ {
public enum Result public enum Result
@@ -65,6 +69,7 @@ namespace BTCPayServer.HostedServices
TooLowAmount, TooLowAmount,
OldRevision OldRevision
} }
public string PayoutId { get; set; } public string PayoutId { get; set; }
public int Revision { get; set; } public int Revision { get; set; }
public decimal Rate { get; set; } public decimal Rate { get; set; }
@@ -89,6 +94,7 @@ namespace BTCPayServer.HostedServices
} }
} }
} }
public async Task<string> CreatePullPayment(CreatePullPayment create) public async Task<string> CreatePullPayment(CreatePullPayment create)
{ {
ArgumentNullException.ThrowIfNull(create); ArgumentNullException.ThrowIfNull(create);
@@ -96,7 +102,9 @@ namespace BTCPayServer.HostedServices
throw new ArgumentException("Amount out of bound", nameof(create)); throw new ArgumentException("Amount out of bound", nameof(create));
using var ctx = this._dbContextFactory.CreateContext(); using var ctx = this._dbContextFactory.CreateContext();
var o = new Data.PullPaymentData(); var o = new Data.PullPaymentData();
o.StartDate = create.StartsAt is DateTimeOffset date ? date : DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1.0); o.StartDate = create.StartsAt is DateTimeOffset date
? date
: DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1.0);
o.EndDate = create.ExpiresAt is DateTimeOffset date2 ? new DateTimeOffset?(date2) : null; o.EndDate = create.ExpiresAt is DateTimeOffset date2 ? new DateTimeOffset?(date2) : null;
o.Period = create.Period is TimeSpan period ? (long?)period.TotalSeconds : null; o.Period = create.Period is TimeSpan period ? (long?)period.TotalSeconds : null;
o.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)); o.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20));
@@ -136,19 +144,21 @@ namespace BTCPayServer.HostedServices
class PayoutRequest class PayoutRequest
{ {
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource, ClaimRequest request) public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource,
ClaimRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(completionSource); ArgumentNullException.ThrowIfNull(completionSource);
Completion = completionSource; Completion = completionSource;
ClaimRequest = request; ClaimRequest = request;
} }
public TaskCompletionSource<ClaimRequest.ClaimResponse> Completion { get; set; } public TaskCompletionSource<ClaimRequest.ClaimResponse> Completion { get; set; }
public ClaimRequest ClaimRequest { get; } public ClaimRequest ClaimRequest { get; }
} }
public PullPaymentHostedService(ApplicationDbContextFactory dbContextFactory, public PullPaymentHostedService(ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
CurrencyNameTable currencyNameTable,
EventAggregator eventAggregator, EventAggregator eventAggregator,
BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider networkProvider,
NotificationSender notificationSender, NotificationSender notificationSender,
@@ -159,7 +169,6 @@ namespace BTCPayServer.HostedServices
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings; _jsonSerializerSettings = jsonSerializerSettings;
_currencyNameTable = currencyNameTable;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_networkProvider = networkProvider; _networkProvider = networkProvider;
_notificationSender = notificationSender; _notificationSender = notificationSender;
@@ -171,7 +180,6 @@ namespace BTCPayServer.HostedServices
Channel<object> _Channel; Channel<object> _Channel;
private readonly ApplicationDbContextFactory _dbContextFactory; private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly CurrencyNameTable _currencyNameTable;
private readonly EventAggregator _eventAggregator; private readonly EventAggregator _eventAggregator;
private readonly BTCPayNetworkProvider _networkProvider; private readonly BTCPayNetworkProvider _networkProvider;
private readonly NotificationSender _notificationSender; private readonly NotificationSender _notificationSender;
@@ -187,6 +195,7 @@ namespace BTCPayServer.HostedServices
{ {
payoutHandler.StartBackgroundCheck(Subscribe); payoutHandler.StartBackgroundCheck(Subscribe);
} }
return new[] { Loop() }; return new[] { Loop() };
} }
@@ -211,14 +220,17 @@ namespace BTCPayServer.HostedServices
{ {
await HandleApproval(approv); await HandleApproval(approv);
} }
if (o is CancelRequest cancel) if (o is CancelRequest cancel)
{ {
await HandleCancel(cancel); await HandleCancel(cancel);
} }
if (o is InternalPayoutPaidRequest paid) if (o is InternalPayoutPaidRequest paid)
{ {
await HandleMarkPaid(paid); await HandleMarkPaid(paid);
} }
foreach (IPayoutHandler payoutHandler in _payoutHandlers) foreach (IPayoutHandler payoutHandler in _payoutHandlers)
{ {
try try
@@ -235,14 +247,16 @@ namespace BTCPayServer.HostedServices
public Task<RateResult> GetRate(PayoutData payout, string explicitRateRule, CancellationToken cancellationToken) public Task<RateResult> GetRate(PayoutData payout, string explicitRateRule, CancellationToken cancellationToken)
{ {
var ppBlob = payout.PullPaymentData.GetBlob(); var ppBlob = payout.PullPaymentData?.GetBlob();
var currencyPair = new Rating.CurrencyPair(payout.GetPaymentMethodId().CryptoCode, ppBlob.Currency); var payoutPaymentMethod = payout.GetPaymentMethodId();
var currencyPair = new Rating.CurrencyPair(payoutPaymentMethod.CryptoCode,
ppBlob?.Currency ?? payoutPaymentMethod.CryptoCode);
Rating.RateRule rule = null; Rating.RateRule rule = null;
try try
{ {
if (explicitRateRule is null) if (explicitRateRule is null)
{ {
var storeBlob = payout.PullPaymentData.StoreData.GetStoreBlob(); var storeBlob = payout.StoreData.GetStoreBlob();
var rules = storeBlob.GetRateRules(_networkProvider); var rules = storeBlob.GetRateRules(_networkProvider);
rules.Spread = 0.0m; rules.Spread = 0.0m;
rule = rules.GetRuleFor(currencyPair); rule = rules.GetRuleFor(currencyPair);
@@ -256,60 +270,73 @@ namespace BTCPayServer.HostedServices
{ {
throw new FormatException("Invalid RateRule"); throw new FormatException("Invalid RateRule");
} }
return _rateFetcher.FetchRate(rule, cancellationToken); return _rateFetcher.FetchRate(rule, cancellationToken);
} }
public Task<PayoutApproval.Result> Approve(PayoutApproval approval) public Task<PayoutApproval.Result> Approve(PayoutApproval approval)
{ {
approval.Completion = new TaskCompletionSource<PayoutApproval.Result>(TaskCreationOptions.RunContinuationsAsynchronously); approval.Completion =
new TaskCompletionSource<PayoutApproval.Result>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_Channel.Writer.TryWrite(approval)) if (!_Channel.Writer.TryWrite(approval))
throw new ObjectDisposedException(nameof(PullPaymentHostedService)); throw new ObjectDisposedException(nameof(PullPaymentHostedService));
return approval.Completion.Task; return approval.Completion.Task;
} }
private async Task HandleApproval(PayoutApproval req) private async Task HandleApproval(PayoutApproval req)
{ {
try try
{ {
using var ctx = _dbContextFactory.CreateContext(); using var ctx = _dbContextFactory.CreateContext();
var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId).FirstOrDefaultAsync(); var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId)
.FirstOrDefaultAsync();
if (payout is null) if (payout is null)
{ {
req.Completion.SetResult(PayoutApproval.Result.NotFound); req.Completion.SetResult(PayoutApproval.Result.NotFound);
return; return;
} }
if (payout.State != PayoutState.AwaitingApproval) if (payout.State != PayoutState.AwaitingApproval)
{ {
req.Completion.SetResult(PayoutApproval.Result.InvalidState); req.Completion.SetResult(PayoutApproval.Result.InvalidState);
return; return;
} }
var payoutBlob = payout.GetBlob(this._jsonSerializerSettings); var payoutBlob = payout.GetBlob(this._jsonSerializerSettings);
if (payoutBlob.Revision != req.Revision) if (payoutBlob.Revision != req.Revision)
{ {
req.Completion.SetResult(PayoutApproval.Result.OldRevision); req.Completion.SetResult(PayoutApproval.Result.OldRevision);
return; return;
} }
if (!PaymentMethodId.TryParse(payout.PaymentMethodId, out var paymentMethod)) if (!PaymentMethodId.TryParse(payout.PaymentMethodId, out var paymentMethod))
{ {
req.Completion.SetResult(PayoutApproval.Result.NotFound); req.Completion.SetResult(PayoutApproval.Result.NotFound);
return; return;
} }
payout.State = PayoutState.AwaitingPayment; payout.State = PayoutState.AwaitingPayment;
if (paymentMethod.CryptoCode == payout.PullPaymentData.GetBlob().Currency) if (payout.PullPaymentData is null || paymentMethod.CryptoCode == payout.PullPaymentData.GetBlob().Currency)
req.Rate = 1.0m; req.Rate = 1.0m;
var cryptoAmount = payoutBlob.Amount / req.Rate; var cryptoAmount = payoutBlob.Amount / req.Rate;
var payoutHandler = _payoutHandlers.FindPayoutHandler(paymentMethod); var payoutHandler = _payoutHandlers.FindPayoutHandler(paymentMethod);
if (payoutHandler is null) if (payoutHandler is null)
throw new InvalidOperationException($"No payout handler for {paymentMethod}"); throw new InvalidOperationException($"No payout handler for {paymentMethod}");
var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination); var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination);
decimal minimumCryptoAmount = await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest.destination); decimal minimumCryptoAmount =
await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest.destination);
if (cryptoAmount < minimumCryptoAmount) if (cryptoAmount < minimumCryptoAmount)
{ {
req.Completion.TrySetResult(PayoutApproval.Result.TooLowAmount); req.Completion.TrySetResult(PayoutApproval.Result.TooLowAmount);
return; return;
} }
payoutBlob.CryptoAmount = BTCPayServer.Extensions.RoundUp(cryptoAmount, _networkProvider.GetNetwork(paymentMethod.CryptoCode).Divisibility);
payoutBlob.CryptoAmount = Extensions.RoundUp(cryptoAmount,
_networkProvider.GetNetwork(paymentMethod.CryptoCode).Divisibility);
payout.SetBlob(payoutBlob, _jsonSerializerSettings); payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
req.Completion.SetResult(PayoutApproval.Result.Ok); req.Completion.SetResult(PayoutApproval.Result.Ok);
} }
catch (Exception ex) catch (Exception ex)
@@ -317,26 +344,31 @@ namespace BTCPayServer.HostedServices
req.Completion.TrySetException(ex); req.Completion.TrySetException(ex);
} }
} }
private async Task HandleMarkPaid(InternalPayoutPaidRequest req) private async Task HandleMarkPaid(InternalPayoutPaidRequest req)
{ {
try try
{ {
await using var ctx = _dbContextFactory.CreateContext(); await using var ctx = _dbContextFactory.CreateContext();
var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.Request.PayoutId).FirstOrDefaultAsync(); var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.Request.PayoutId)
.FirstOrDefaultAsync();
if (payout is null) if (payout is null)
{ {
req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.NotFound); req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.NotFound);
return; return;
} }
if (payout.State != PayoutState.AwaitingPayment) if (payout.State != PayoutState.AwaitingPayment)
{ {
req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.InvalidState); req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.InvalidState);
return; return;
} }
if (req.Request.Proof != null) if (req.Request.Proof != null)
{ {
payout.SetProofBlob(req.Request.Proof); payout.SetProofBlob(req.Request.Proof);
} }
payout.State = PayoutState.Completed; payout.State = PayoutState.Completed;
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.Ok); req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.Ok);
@@ -353,40 +385,67 @@ namespace BTCPayServer.HostedServices
{ {
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
await using var ctx = _dbContextFactory.CreateContext(); await using var ctx = _dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(req.ClaimRequest.PullPaymentId); var withoutPullPayment = req.ClaimRequest.PullPaymentId is null;
var pp = string.IsNullOrEmpty(req.ClaimRequest.PullPaymentId)
? null
: await ctx.PullPayments.FindAsync(req.ClaimRequest.PullPaymentId);
if (pp is null || pp.Archived) if (!withoutPullPayment && (pp is null || pp.Archived))
{ {
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Archived)); req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Archived));
return; return;
} }
if (pp.IsExpired(now))
PullPaymentBlob ppBlob = null;
if (!withoutPullPayment)
{ {
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Expired)); if (pp.IsExpired(now))
{
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Expired));
return;
}
if (!pp.HasStarted(now))
{
req.Completion.TrySetResult(
new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.NotStarted));
return;
}
ppBlob = pp.GetBlob();
if (!ppBlob.SupportedPaymentMethods.Contains(req.ClaimRequest.PaymentMethodId))
{
req.Completion.TrySetResult(
new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported));
return;
}
}
if (req.ClaimRequest.PreApprove && !withoutPullPayment &&
ppBlob.Currency != req.ClaimRequest.PaymentMethodId.CryptoCode)
{
req.Completion.TrySetResult(
new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported));
return; return;
} }
if (!pp.HasStarted(now))
{
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.NotStarted));
return;
}
var ppBlob = pp.GetBlob();
var payoutHandler = var payoutHandler =
_payoutHandlers.FindPayoutHandler(req.ClaimRequest.PaymentMethodId); _payoutHandlers.FindPayoutHandler(req.ClaimRequest.PaymentMethodId);
if (!ppBlob.SupportedPaymentMethods.Contains(req.ClaimRequest.PaymentMethodId) || payoutHandler is null) if (payoutHandler is null)
{ {
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported)); req.Completion.TrySetResult(
new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported));
return; return;
} }
if (req.ClaimRequest.Destination.Id != null) if (req.ClaimRequest.Destination.Id != null)
{ {
if (await ctx.Payouts.AnyAsync(data => if (await ctx.Payouts.AnyAsync(data =>
data.Destination.Equals(req.ClaimRequest.Destination.Id) && data.Destination.Equals(req.ClaimRequest.Destination.Id) &&
data.State != PayoutState.Completed && data.State != PayoutState.Cancelled data.State != PayoutState.Completed && data.State != PayoutState.Cancelled
)) ))
{ {
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Duplicate)); req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Duplicate));
return; return;
} }
@@ -400,39 +459,42 @@ namespace BTCPayServer.HostedServices
return; return;
} }
var payouts = (await ctx.Payouts.GetPayoutInPeriod(pp, now) var payoutsRaw = withoutPullPayment
.Where(p => p.State != PayoutState.Cancelled) ? null
.ToListAsync()) : await ctx.Payouts.GetPayoutInPeriod(pp, now)
.Select(o => new .Where(p => p.State != PayoutState.Cancelled).ToListAsync();
{
Entity = o, var payouts = payoutsRaw?.Select(o => new { Entity = o, Blob = o.GetBlob(_jsonSerializerSettings) });
Blob = o.GetBlob(_jsonSerializerSettings) var limit = ppBlob?.Limit ?? 0;
}); var totalPayout = payouts?.Select(p => p.Blob.Amount)?.Sum();
var limit = ppBlob.Limit; var claimed = req.ClaimRequest.Value is decimal v ? v : limit - (totalPayout ?? 0);
var totalPayout = payouts.Select(p => p.Blob.Amount).Sum(); if (totalPayout is not null && totalPayout + claimed > limit)
var claimed = req.ClaimRequest.Value is decimal v ? v : limit - totalPayout;
if (totalPayout + claimed > limit)
{ {
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Overdraft)); req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Overdraft));
return; return;
} }
var payout = new PayoutData()
{ if (!withoutPullPayment && (claimed < ppBlob.MinimumClaim || claimed == 0.0m))
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = now,
State = PayoutState.AwaitingApproval,
PullPaymentDataId = req.ClaimRequest.PullPaymentId,
PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(),
Destination = req.ClaimRequest.Destination.Id
};
if (claimed < ppBlob.MinimumClaim || claimed == 0.0m)
{ {
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow)); req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow));
return; return;
} }
var payout = new PayoutData()
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = now,
State =
req.ClaimRequest.PreApprove ? PayoutState.AwaitingPayment : PayoutState.AwaitingApproval,
PullPaymentDataId = req.ClaimRequest.PullPaymentId,
PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(),
Destination = req.ClaimRequest.Destination.Id,
StoreDataId = req.ClaimRequest.StoreId ?? pp?.StoreId
};
var payoutBlob = new PayoutBlob() var payoutBlob = new PayoutBlob()
{ {
Amount = claimed, Amount = claimed,
CryptoAmount = req.ClaimRequest.PreApprove ? claimed : null,
Destination = req.ClaimRequest.Destination.ToString() Destination = req.ClaimRequest.Destination.ToString()
}; };
payout.SetBlob(payoutBlob, _jsonSerializerSettings); payout.SetBlob(payoutBlob, _jsonSerializerSettings);
@@ -442,13 +504,15 @@ namespace BTCPayServer.HostedServices
await payoutHandler.TrackClaim(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination); await payoutHandler.TrackClaim(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout)); req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout));
await _notificationSender.SendNotification(new StoreScope(pp.StoreId), new PayoutNotification() await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId),
{ new PayoutNotification()
StoreId = pp.StoreId, {
Currency = ppBlob.Currency, StoreId = payout.StoreDataId,
PaymentMethod = payout.PaymentMethodId, Currency = ppBlob?.Currency ?? req.ClaimRequest.PaymentMethodId.CryptoCode,
PayoutId = pp.Id Status = payout.State,
}); PaymentMethod = payout.PaymentMethodId,
PayoutId = payout.Id
});
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
@@ -460,6 +524,7 @@ namespace BTCPayServer.HostedServices
req.Completion.TrySetException(ex); req.Completion.TrySetException(ex);
} }
} }
private async Task HandleCancel(CancelRequest cancel) private async Task HandleCancel(CancelRequest cancel)
{ {
try try
@@ -471,15 +536,15 @@ namespace BTCPayServer.HostedServices
ctx.PullPayments.Attach(new Data.PullPaymentData() { Id = cancel.PullPaymentId, Archived = true }) ctx.PullPayments.Attach(new Data.PullPaymentData() { Id = cancel.PullPaymentId, Archived = true })
.Property(o => o.Archived).IsModified = true; .Property(o => o.Archived).IsModified = true;
payouts = await ctx.Payouts payouts = await ctx.Payouts
.Where(p => p.PullPaymentDataId == cancel.PullPaymentId) .Where(p => p.PullPaymentDataId == cancel.PullPaymentId)
.ToListAsync(); .ToListAsync();
} }
else else
{ {
var payoutIds = cancel.PayoutIds.ToHashSet(); var payoutIds = cancel.PayoutIds.ToHashSet();
payouts = await ctx.Payouts payouts = await ctx.Payouts
.Where(p => payoutIds.Contains(p.Id)) .Where(p => payoutIds.Contains(p.Id))
.ToListAsync(); .ToListAsync();
} }
foreach (var payout in payouts) foreach (var payout in payouts)
@@ -487,6 +552,7 @@ namespace BTCPayServer.HostedServices
if (payout.State != PayoutState.Completed && payout.State != PayoutState.InProgress) if (payout.State != PayoutState.Completed && payout.State != PayoutState.InProgress)
payout.State = PayoutState.Cancelled; payout.State = PayoutState.Cancelled;
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
cancel.Completion.TrySetResult(true); cancel.Completion.TrySetResult(true);
} }
@@ -495,6 +561,7 @@ namespace BTCPayServer.HostedServices
cancel.Completion.TrySetException(ex); cancel.Completion.TrySetException(ex);
} }
} }
public Task Cancel(CancelRequest cancelRequest) public Task Cancel(CancelRequest cancelRequest)
{ {
CancellationToken.ThrowIfCancellationRequested(); CancellationToken.ThrowIfCancellationRequested();
@@ -508,7 +575,8 @@ namespace BTCPayServer.HostedServices
public Task<ClaimRequest.ClaimResponse> Claim(ClaimRequest request) public Task<ClaimRequest.ClaimResponse> Claim(ClaimRequest request)
{ {
CancellationToken.ThrowIfCancellationRequested(); CancellationToken.ThrowIfCancellationRequested();
var cts = new TaskCompletionSource<ClaimRequest.ClaimResponse>(TaskCreationOptions.RunContinuationsAsynchronously); var cts = new TaskCompletionSource<ClaimRequest.ClaimResponse>(TaskCreationOptions
.RunContinuationsAsynchronously);
if (!_Channel.Writer.TryWrite(new PayoutRequest(cts, request))) if (!_Channel.Writer.TryWrite(new PayoutRequest(cts, request)))
throw new ObjectDisposedException(nameof(PullPaymentHostedService)); throw new ObjectDisposedException(nameof(PullPaymentHostedService));
return cts.Task; return cts.Task;
@@ -524,7 +592,8 @@ namespace BTCPayServer.HostedServices
public Task<PayoutPaidRequest.PayoutPaidResult> MarkPaid(PayoutPaidRequest request) public Task<PayoutPaidRequest.PayoutPaidResult> MarkPaid(PayoutPaidRequest request)
{ {
CancellationToken.ThrowIfCancellationRequested(); CancellationToken.ThrowIfCancellationRequested();
var cts = new TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult>(TaskCreationOptions.RunContinuationsAsynchronously); var cts = new TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult>(TaskCreationOptions
.RunContinuationsAsynchronously);
if (!_Channel.Writer.TryWrite(new InternalPayoutPaidRequest(cts, request))) if (!_Channel.Writer.TryWrite(new InternalPayoutPaidRequest(cts, request)))
throw new ObjectDisposedException(nameof(PullPaymentHostedService)); throw new ObjectDisposedException(nameof(PullPaymentHostedService));
return cts.Task; return cts.Task;
@@ -533,17 +602,18 @@ namespace BTCPayServer.HostedServices
class InternalPayoutPaidRequest class InternalPayoutPaidRequest
{ {
public InternalPayoutPaidRequest(TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult> completionSource, PayoutPaidRequest request) public InternalPayoutPaidRequest(TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult> completionSource,
PayoutPaidRequest request)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(completionSource); ArgumentNullException.ThrowIfNull(completionSource);
Completion = completionSource; Completion = completionSource;
Request = request; Request = request;
} }
public TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult> Completion { get; set; } public TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult> Completion { get; set; }
public PayoutPaidRequest Request { get; } public PayoutPaidRequest Request { get; }
} }
} }
public class PayoutPaidRequest public class PayoutPaidRequest
@@ -554,6 +624,7 @@ namespace BTCPayServer.HostedServices
NotFound, NotFound,
InvalidState InvalidState
} }
public string PayoutId { get; set; } public string PayoutId { get; set; }
public ManualPayoutProof Proof { get; set; } public ManualPayoutProof Proof { get; set; }
@@ -571,7 +642,6 @@ namespace BTCPayServer.HostedServices
throw new NotSupportedException(); throw new NotSupportedException();
} }
} }
} }
public class ClaimRequest public class ClaimRequest
@@ -599,8 +669,10 @@ namespace BTCPayServer.HostedServices
default: default:
throw new NotSupportedException("Unsupported ClaimResult"); throw new NotSupportedException("Unsupported ClaimResult");
} }
return null; return null;
} }
public class ClaimResponse public class ClaimResponse
{ {
public ClaimResponse(ClaimResult result, PayoutData payoutData = null) public ClaimResponse(ClaimResult result, PayoutData payoutData = null)
@@ -608,9 +680,11 @@ namespace BTCPayServer.HostedServices
Result = result; Result = result;
PayoutData = payoutData; PayoutData = payoutData;
} }
public ClaimResult Result { get; set; } public ClaimResult Result { get; set; }
public PayoutData PayoutData { get; set; } public PayoutData PayoutData { get; set; }
} }
public enum ClaimResult public enum ClaimResult
{ {
Ok, Ok,
@@ -627,6 +701,7 @@ namespace BTCPayServer.HostedServices
public string PullPaymentId { get; set; } public string PullPaymentId { get; set; }
public decimal? Value { get; set; } public decimal? Value { get; set; }
public IClaimDestination Destination { get; set; } public IClaimDestination Destination { get; set; }
public string StoreId { get; set; }
public bool PreApprove { get; set; }
} }
} }

View File

@@ -21,6 +21,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.Payments.PayJoin; using BTCPayServer.Payments.PayJoin;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.Plugins; using BTCPayServer.Plugins;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Security.Bitpay; using BTCPayServer.Security.Bitpay;
@@ -322,7 +323,8 @@ namespace BTCPayServer.Hosting
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>(); .ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddSingleton<IPayoutHandler, BitcoinLikePayoutHandler>(); services.AddSingleton<BitcoinLikePayoutHandler>();
services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<BitcoinLikePayoutHandler>());
services.AddSingleton<IPayoutHandler, LightningLikePayoutHandler>(); services.AddSingleton<IPayoutHandler, LightningLikePayoutHandler>();
services.AddHttpClient(LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient) services.AddHttpClient(LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient)
@@ -402,6 +404,7 @@ namespace BTCPayServer.Hosting
services.AddScoped<BTCPayServerClient, LocalBTCPayServerClient>(); services.AddScoped<BTCPayServerClient, LocalBTCPayServerClient>();
//also provide a factory that can impersonate user/store id //also provide a factory that can impersonate user/store id
services.AddSingleton<IBTCPayServerClientFactory, BTCPayServerClientFactory>(); services.AddSingleton<IBTCPayServerClientFactory, BTCPayServerClientFactory>();
services.AddPayoutProcesors();
services.AddAPIKeyAuthentication(); services.AddAPIKeyAuthentication();
services.AddBtcPayServerAuthenticationSchemes(); services.AddBtcPayServerAuthenticationSchemes();

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -23,9 +24,14 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer; using NBXplorer;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using PeterO.Cbor; using PeterO.Cbor;
using PayoutData = BTCPayServer.Data.PayoutData;
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Hosting namespace BTCPayServer.Hosting
{ {
@@ -41,6 +47,7 @@ namespace BTCPayServer.Hosting
private readonly IEnumerable<IPayoutHandler> _payoutHandlers; private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly LightningAddressService _lightningAddressService; private readonly LightningAddressService _lightningAddressService;
private readonly ILogger<MigrationStartupTask> _logger;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
public IOptions<LightningNetworkOptions> LightningOptions { get; } public IOptions<LightningNetworkOptions> LightningOptions { get; }
@@ -56,9 +63,8 @@ namespace BTCPayServer.Hosting
IEnumerable<IPayoutHandler> payoutHandlers, IEnumerable<IPayoutHandler> payoutHandlers,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
LightningAddressService lightningAddressService, LightningAddressService lightningAddressService,
Logs logs) ILogger<MigrationStartupTask> logger)
{ {
Logs = logs;
_DBContextFactory = dbContextFactory; _DBContextFactory = dbContextFactory;
_StoreRepository = storeRepository; _StoreRepository = storeRepository;
_NetworkProvider = networkProvider; _NetworkProvider = networkProvider;
@@ -67,6 +73,7 @@ namespace BTCPayServer.Hosting
_payoutHandlers = payoutHandlers; _payoutHandlers = payoutHandlers;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_lightningAddressService = lightningAddressService; _lightningAddressService = lightningAddressService;
_logger = logger;
_userManager = userManager; _userManager = userManager;
LightningOptions = lightningOptions; LightningOptions = lightningOptions;
} }
@@ -182,12 +189,17 @@ namespace BTCPayServer.Hosting
{ {
await MigrateLighingAddressDatabaseMigration(); await MigrateLighingAddressDatabaseMigration();
settings.LighingAddressDatabaseMigration = true; settings.LighingAddressDatabaseMigration = true;
}
if (!settings.AddStoreToPayout)
{
await MigrateAddStoreToPayout();
settings.AddStoreToPayout = true;
await _Settings.UpdateSetting(settings); await _Settings.UpdateSetting(settings);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Logs.PayServer.LogError(ex, "Error on the MigrationStartupTask"); _logger.LogError(ex, "Error on the MigrationStartupTask");
throw; throw;
} }
} }
@@ -244,6 +256,41 @@ namespace BTCPayServer.Hosting
} }
} }
private async Task MigrateAddStoreToPayout()
{
await using var ctx = _DBContextFactory.CreateContext();
if (ctx.Database.IsNpgsql())
{
await ctx.Database.ExecuteSqlRawAsync(@"
WITH cte AS (
SELECT DISTINCT p.""Id"", pp.""StoreId"" FROM ""Payouts"" p
JOIN ""PullPayments"" pp ON pp.""Id"" = p.""PullPaymentDataId""
WHERE p.""StoreDataId"" IS NULL
)
UPDATE ""Payouts"" p
SET ""StoreDataId""=cte.""StoreId""
FROM cte
WHERE cte.""Id""=p.""Id""
");
}
else
{
var queryable = ctx.Payouts.Where(data => data.StoreDataId == null);
var count = await queryable.CountAsync();
_logger.LogInformation($"Migrating {count} payouts to have a store id explicitly");
for (int i = 0; i < count; i+=1000)
{
await queryable.Include(data => data.PullPaymentData).Skip(i).Take(1000)
.ForEachAsync(data => data.StoreDataId = data.PullPaymentData.StoreId);
await ctx.SaveChangesAsync();
_logger.LogInformation($"Migrated {i+1000}/{count} payouts to have a store id explicitly");
}
}
}
private async Task AddInitialUserBlob() private async Task AddInitialUserBlob()
{ {
await using var ctx = _DBContextFactory.CreateContext(); await using var ctx = _DBContextFactory.CreateContext();

View File

@@ -11,6 +11,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string PullPaymentId { get; set; } public string PullPaymentId { get; set; }
public string Command { get; set; } public string Command { get; set; }
public Dictionary<PayoutState, int> PayoutStateCount { get; set; } public Dictionary<PayoutState, int> PayoutStateCount { get; set; }
public Dictionary<string, int> PaymentMethodCount { get; set; }
public string PaymentMethodId { get; set; } public string PaymentMethodId { get; set; }
public List<PayoutModel> Payouts { get; set; } public List<PayoutModel> Payouts { get; set; }

View File

@@ -18,8 +18,7 @@ namespace BTCPayServer.Models.WalletViewModels
public TimeSpan Target { get; set; } public TimeSpan Target { get; set; }
public decimal FeeRate { get; set; } public decimal FeeRate { get; set; }
} }
public List<TransactionOutput> Outputs { get; set; } = new List<TransactionOutput>(); public List<TransactionOutput> Outputs { get; set; } = new();
public class TransactionOutput public class TransactionOutput
{ {
[Display(Name = "Destination Address")] [Display(Name = "Destination Address")]
@@ -33,6 +32,8 @@ namespace BTCPayServer.Models.WalletViewModels
[Display(Name = "Subtract fees from amount")] [Display(Name = "Subtract fees from amount")]
public bool SubtractFeesFromOutput { get; set; } public bool SubtractFeesFromOutput { get; set; }
public string PayoutId { get; set; }
} }
public decimal CurrentBalance { get; set; } public decimal CurrentBalance { get; set; }
public decimal ImmatureBalance { get; set; } public decimal ImmatureBalance { get; set; }

View File

@@ -0,0 +1,88 @@
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Data.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.PayoutProcessors.Settings;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using PayoutData = BTCPayServer.Data.PayoutData;
using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData;
namespace BTCPayServer.PayoutProcessors;
public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T:AutomatedPayoutBlob
{
protected readonly StoreRepository _storeRepository;
protected readonly PayoutProcessorData _PayoutProcesserSettings;
protected readonly ApplicationDbContextFactory _applicationDbContextFactory;
protected readonly BTCPayNetworkProvider _btcPayNetworkProvider;
protected readonly PaymentMethodId PaymentMethodId;
protected BaseAutomatedPayoutProcessor(
ILoggerFactory logger,
StoreRepository storeRepository,
PayoutProcessorData payoutProcesserSettings,
ApplicationDbContextFactory applicationDbContextFactory,
BTCPayNetworkProvider btcPayNetworkProvider) : base(logger.CreateLogger($"{payoutProcesserSettings.Processor}:{payoutProcesserSettings.StoreId}:{payoutProcesserSettings.PaymentMethod}"))
{
_storeRepository = storeRepository;
_PayoutProcesserSettings = payoutProcesserSettings;
PaymentMethodId = _PayoutProcesserSettings.GetPaymentMethodId();
_applicationDbContextFactory = applicationDbContextFactory;
_btcPayNetworkProvider = btcPayNetworkProvider;
}
internal override Task[] InitializeTasks()
{
return new[] { CreateLoopTask(Act) };
}
protected abstract Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts);
private async Task Act()
{
Logs.PayServer.LogInformation($"Starting to process");
var store = await _storeRepository.FindStore(_PayoutProcesserSettings.StoreId);
var paymentMethod = store?.GetEnabledPaymentMethods(_btcPayNetworkProvider)?.FirstOrDefault(
method =>
method.PaymentId == PaymentMethodId);
if (paymentMethod is not null)
{
var payouts = await GetRelevantPayouts();
Logs.PayServer.LogInformation($"{payouts.Length} found to process");
await Process(paymentMethod, payouts);
}
else
{
Logs.PayServer.LogInformation($"Payment method not configured.");
}
var blob = GetBlob(_PayoutProcesserSettings);
Logs.PayServer.LogInformation($"Sleeping for {blob.Interval}");
await Task.Delay(blob.Interval, CancellationToken);
}
public static T GetBlob(PayoutProcessorData data)
{
return InvoiceRepository.FromBytes<T>(data.Blob);
}
private async Task<PayoutData[]> GetRelevantPayouts()
{
await using var context = _applicationDbContextFactory.CreateContext();
var pmi = _PayoutProcesserSettings.PaymentMethod;
return await context.Payouts
.Where(data => data.State == PayoutState.AwaitingPayment)
.Where(data => data.PaymentMethodId == pmi)
.Where(data => data.StoreDataId == _PayoutProcesserSettings.StoreId)
.OrderBy(data => data.Date)
.ToArrayAsync();
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using BTCPayServer.Data.Data;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
namespace BTCPayServer.PayoutProcessors;
public interface IPayoutProcessorFactory
{
public string Processor { get;}
public string FriendlyName { get;}
public string ConfigureLink(string storeId, PaymentMethodId paymentMethodId, HttpRequest request);
public IEnumerable<PaymentMethodId> GetSupportedPaymentMethods();
public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings);
}

View File

@@ -0,0 +1,149 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Data.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors.Settings;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using LNURL;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using PayoutData = BTCPayServer.Data.PayoutData;
using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData;
namespace BTCPayServer.PayoutProcessors.Lightning;
public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<AutomatedPayoutBlob>
{
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly UserService _userService;
private readonly IOptions<LightningNetworkOptions> _options;
private readonly LightningLikePayoutHandler _payoutHandler;
private readonly BTCPayNetwork _network;
public LightningAutomatedPayoutProcessor(
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
LightningClientFactoryService lightningClientFactoryService,
IEnumerable<IPayoutHandler> payoutHandlers,
UserService userService,
ILoggerFactory logger, IOptions<LightningNetworkOptions> options,
StoreRepository storeRepository, PayoutProcessorData payoutProcesserSettings,
ApplicationDbContextFactory applicationDbContextFactory, BTCPayNetworkProvider btcPayNetworkProvider) :
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory,
btcPayNetworkProvider)
{
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_lightningClientFactoryService = lightningClientFactoryService;
_userService = userService;
_options = options;
_payoutHandler = (LightningLikePayoutHandler)payoutHandlers.FindPayoutHandler(PaymentMethodId);
_network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(_PayoutProcesserSettings.GetPaymentMethodId().CryptoCode);
}
protected override async Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts)
{
await using var ctx = _applicationDbContextFactory.CreateContext();
var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod;
if (lightningSupportedPaymentMethod.IsInternalNode &&
!(await Task.WhenAll((await _storeRepository.GetStoreUsers(_PayoutProcesserSettings.StoreId))
.Where(user => user.Role == StoreRoles.Owner).Select(user => user.Id)
.Select(s => _userService.IsAdminUser(s)))).Any(b => b))
{
return;
}
var client =
lightningSupportedPaymentMethod.CreateLightningClient(_network, _options.Value,
_lightningClientFactoryService);
foreach (var payoutData in payouts)
{
var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings);
var claim = await _payoutHandler.ParseClaimDestination(PaymentMethodId, blob.Destination);
try
{
switch (claim.destination)
{
case LNURLPayClaimDestinaton lnurlPayClaimDestinaton:
var endpoint = LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out var tag);
var httpClient = _payoutHandler.CreateClient(endpoint);
var lnurlInfo =
(LNURLPayRequest)await LNURL.LNURL.FetchInformation(endpoint, "payRequest",
httpClient);
var lm = new LightMoney(blob.CryptoAmount.Value, LightMoneyUnit.BTC);
if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable)
{
continue;
}
else
{
try
{
var lnurlPayRequestCallbackResponse =
await lnurlInfo.SendRequest(lm, _network.NBitcoinNetwork, httpClient);
if (await TrypayBolt(client, blob, payoutData,
lnurlPayRequestCallbackResponse
.GetPaymentRequest(_network.NBitcoinNetwork)))
{
ctx.Attach(payoutData);
payoutData.State = PayoutState.Completed;
}
}
catch (LNUrlException)
{
continue;
}
}
break;
case BoltInvoiceClaimDestination item1:
if (await TrypayBolt(client, blob, payoutData, item1.PaymentRequest))
{
ctx.Attach(payoutData);
payoutData.State = PayoutState.Completed;
}
break;
}
}
catch (Exception e)
{
Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}");
}
}
await ctx.SaveChangesAsync();
}
//we group per store and init the transfers by each
async Task<bool> TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData,
BOLT11PaymentRequest bolt11PaymentRequest)
{
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
if (boltAmount != payoutBlob.CryptoAmount)
{
return false;
}
var result = await lightningClient.Pay(bolt11PaymentRequest.ToString());
return result.Result == PayResult.Ok;
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data.Data;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace BTCPayServer.PayoutProcessors.Lightning;
public class LightningAutomatedPayoutSenderFactory : IPayoutProcessorFactory
{
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly IServiceProvider _serviceProvider;
private readonly LinkGenerator _linkGenerator;
public LightningAutomatedPayoutSenderFactory(BTCPayNetworkProvider btcPayNetworkProvider, IServiceProvider serviceProvider, LinkGenerator linkGenerator)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_serviceProvider = serviceProvider;
_linkGenerator = linkGenerator;
}
public string FriendlyName { get; } = "Automated Lightning Sender";
public string ConfigureLink(string storeId, PaymentMethodId paymentMethodId, HttpRequest request)
{
return _linkGenerator.GetUriByAction("Configure",
"UILightningAutomatedPayoutProcessors",new
{
storeId,
cryptoCode = paymentMethodId.CryptoCode
}, request.Scheme, request.Host, request.PathBase);
}
public string Processor => ProcessorName;
public static string ProcessorName => nameof(LightningAutomatedPayoutSenderFactory);
public IEnumerable<PaymentMethodId> GetSupportedPaymentMethods()
{
return _btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>()
.Where(network => network.SupportLightning)
.Select(network =>
new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance));
}
public async Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
{
if (settings.Processor != Processor)
{
throw new NotSupportedException("This processor cannot handle the provided requirements");
}
return ActivatorUtilities.CreateInstance<LightningAutomatedPayoutProcessor>(_serviceProvider, settings);
}
}

View File

@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data.Data;
using BTCPayServer.Payments;
using BTCPayServer.PayoutProcessors.OnChain;
using BTCPayServer.PayoutProcessors.Settings;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.PayoutProcessors.Lightning;
public class UILightningAutomatedPayoutProcessorsController : Controller
{
private readonly EventAggregator _eventAggregator;
private readonly LightningAutomatedPayoutSenderFactory _lightningAutomatedPayoutSenderFactory;
private readonly PayoutProcessorService _payoutProcessorService;
public UILightningAutomatedPayoutProcessorsController(
EventAggregator eventAggregator,
LightningAutomatedPayoutSenderFactory lightningAutomatedPayoutSenderFactory,
PayoutProcessorService payoutProcessorService)
{
_eventAggregator = eventAggregator;
_lightningAutomatedPayoutSenderFactory = lightningAutomatedPayoutSenderFactory;
_payoutProcessorService = payoutProcessorService;
}
[HttpGet("~/stores/{storeId}/payout-processors/lightning-automated/{cryptocode}")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Configure(string storeId, string cryptoCode)
{
if (!_lightningAutomatedPayoutSenderFactory.GetSupportedPaymentMethods().Any(id =>
id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = $"This processor cannot handle {cryptoCode}."
});
return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors");
}
var activeProcessor =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] { storeId },
Processors = new []{ _lightningAutomatedPayoutSenderFactory.Processor},
PaymentMethods = new[]
{
new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString()
}
}))
.FirstOrDefault();
return View (new LightningTransferViewModel(activeProcessor is null? new AutomatedPayoutBlob() : OnChainAutomatedPayoutProcessor.GetBlob(activeProcessor)));
}
[HttpPost("~/stores/{storeId}/payout-processors/lightning-automated/{cryptocode}")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Configure(string storeId, string cryptoCode, LightningTransferViewModel automatedTransferBlob)
{
if (!_lightningAutomatedPayoutSenderFactory.GetSupportedPaymentMethods().Any(id =>
id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = $"This processor cannot handle {cryptoCode}."
});
return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors");
}
var activeProcessor =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] { storeId },
Processors = new []{ _lightningAutomatedPayoutSenderFactory.Processor},
PaymentMethods = new[]
{
new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString()
}
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();
activeProcessor.Blob = InvoiceRepository.ToBytes(automatedTransferBlob.ToBlob());
activeProcessor.StoreId = storeId;
activeProcessor.PaymentMethod = new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString();
activeProcessor.Processor = _lightningAutomatedPayoutSenderFactory.Processor;
var tcs = new TaskCompletionSource();
_eventAggregator.Publish(new PayoutProcessorUpdated()
{
Data = activeProcessor,
Id = activeProcessor.Id,
Processed = tcs
});
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = $"Processor updated."
});
await tcs.Task;
return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors", new {storeId});
}
public class LightningTransferViewModel
{
public LightningTransferViewModel()
{
}
public LightningTransferViewModel(AutomatedPayoutBlob blob)
{
IntervalMinutes = blob.Interval.TotalMinutes;
}
public double IntervalMinutes { get; set; }
public AutomatedPayoutBlob ToBlob()
{
return new AutomatedPayoutBlob() { Interval = TimeSpan.FromMinutes(IntervalMinutes) };
}
}
}

View File

@@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Data.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.PayoutProcessors.Settings;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBXplorer;
using NBXplorer.DerivationStrategy;
using PayoutData = BTCPayServer.Data.PayoutData;
using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData;
namespace BTCPayServer.PayoutProcessors.OnChain
{
public class OnChainAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<AutomatedPayoutBlob>
{
private readonly ExplorerClientProvider _explorerClientProvider;
private readonly BTCPayWalletProvider _btcPayWalletProvider;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly BitcoinLikePayoutHandler _bitcoinLikePayoutHandler;
private readonly EventAggregator _eventAggregator;
public OnChainAutomatedPayoutProcessor(
ApplicationDbContextFactory applicationDbContextFactory,
ExplorerClientProvider explorerClientProvider,
BTCPayWalletProvider btcPayWalletProvider,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
ILoggerFactory logger,
BitcoinLikePayoutHandler bitcoinLikePayoutHandler,
EventAggregator eventAggregator,
StoreRepository storeRepository,
PayoutProcessorData payoutProcesserSettings,
BTCPayNetworkProvider btcPayNetworkProvider) :
base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory,
btcPayNetworkProvider)
{
_explorerClientProvider = explorerClientProvider;
_btcPayWalletProvider = btcPayWalletProvider;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_bitcoinLikePayoutHandler = bitcoinLikePayoutHandler;
_eventAggregator = eventAggregator;
}
protected override async Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts)
{
var storePaymentMethod = paymentMethod as DerivationSchemeSettings;
if (storePaymentMethod?.IsHotWallet is not true)
{
Logs.PayServer.LogInformation($"Wallet is not a hot wallet.");
return;
}
if (!_explorerClientProvider.IsAvailable(PaymentMethodId.CryptoCode))
{
Logs.PayServer.LogInformation($"{paymentMethod.PaymentId.CryptoCode} node is not available");
return;
}
var explorerClient = _explorerClientProvider.GetExplorerClient(PaymentMethodId.CryptoCode);
var paymentMethodId = PaymentMethodId.Parse(PaymentMethodId.CryptoCode);
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
var extKeyStr = await explorerClient.GetMetadataAsync<string>(
storePaymentMethod.AccountDerivation,
WellknownMetadataKeys.AccountHDKey);
if (extKeyStr == null)
{
Logs.PayServer.LogInformation($"Wallet keys not found.");
return;
}
var wallet = _btcPayWalletProvider.GetWallet(PaymentMethodId.CryptoCode);
var reccoins = (await wallet.GetUnspentCoins(storePaymentMethod.AccountDerivation)).ToArray();
var coins = reccoins.Select(coin => coin.Coin).ToArray();
var accountKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork);
var keys = reccoins.Select(coin => accountKey.Derive(coin.KeyPath).PrivateKey).ToArray();
Transaction workingTx = null;
decimal? failedAmount = null;
var changeAddress = await explorerClient.GetUnusedAsync(
storePaymentMethod.AccountDerivation, DerivationFeature.Change, 0, true);
var feeRate = await explorerClient.GetFeeRateAsync(1, new FeeRate(1m));
var transfersProcessing = new List<PayoutData>();
foreach (var transferRequest in payouts)
{
var blob = transferRequest.GetBlob(_btcPayNetworkJsonSerializerSettings);
if (failedAmount.HasValue && blob.CryptoAmount >= failedAmount)
{
continue;
}
var claimDestination =
await _bitcoinLikePayoutHandler.ParseClaimDestination(paymentMethodId, blob.Destination);
if (!string.IsNullOrEmpty(claimDestination.error))
{
Logs.PayServer.LogInformation($"Could not process payout {transferRequest.Id} because {claimDestination.error}.");
continue;
}
var bitcoinClaimDestination = (IBitcoinLikeClaimDestination)claimDestination.destination;
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder()
.AddCoins(coins)
.AddKeys(keys);
if (workingTx is not null)
{
foreach (var txout in workingTx.Outputs.Where(txout =>
!txout.IsTo(changeAddress.Address)))
{
txBuilder.Send(txout.ScriptPubKey, txout.Value);
}
}
txBuilder.Send(bitcoinClaimDestination.Address,
new Money(blob.CryptoAmount.Value, MoneyUnit.BTC));
try
{
txBuilder.SetChange(changeAddress.Address);
txBuilder.SendEstimatedFees(feeRate.FeeRate);
workingTx = txBuilder.BuildTransaction(true);
transfersProcessing.Add(transferRequest);
}
catch (NotEnoughFundsException e)
{
Logs.PayServer.LogInformation($"Could not process payout {transferRequest.Id} because of not enough funds. ({e.Missing.GetValue(network)})");
failedAmount = blob.CryptoAmount;
//keep going, we prioritize withdraws by time but if there is some other we can fit, we should
}
}
if (workingTx is not null)
{
try
{
await using var context = _applicationDbContextFactory.CreateContext();
var txHash = workingTx.GetHash();
Logs.PayServer.LogInformation($"Processing {transfersProcessing.Count} payouts in tx {txHash}");
foreach (PayoutData payoutData in transfersProcessing)
{
context.Attach(payoutData);
payoutData.State = PayoutState.InProgress;
_bitcoinLikePayoutHandler.SetProofBlob(payoutData,
new PayoutTransactionOnChainBlob()
{
Accounted = true,
TransactionId = txHash,
Candidates = new HashSet<uint256>() { txHash }
});
await context.SaveChangesAsync();
}
TaskCompletionSource<bool> tcs = new();
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(20));
var task = _eventAggregator.WaitNext<NewOnChainTransactionEvent>(
e => e.NewTransactionEvent.TransactionData.TransactionHash == txHash,
cts.Token);
var broadcastResult = await explorerClient.BroadcastAsync(workingTx, cts.Token);
if (!broadcastResult.Success)
{
tcs.SetResult(false);
}
var walletId = new WalletId(_PayoutProcesserSettings.StoreId, PaymentMethodId.CryptoCode);
foreach (PayoutData payoutData in transfersProcessing)
{
_eventAggregator.Publish(new UpdateTransactionLabel(walletId,
txHash,
UpdateTransactionLabel.PayoutTemplate(payoutData.Id, payoutData.PullPaymentDataId,
walletId.ToString())));
}
await Task.WhenAny(tcs.Task, task);
}
catch (OperationCanceledException)
{
}
catch(Exception e)
{
Logs.PayServer.LogError(e, "Could not finalize and broadcast");
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.PayoutProcessors.OnChain;
public class OnChainAutomatedPayoutSenderFactory : EventHostedServiceBase, IPayoutProcessorFactory
{
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly IServiceProvider _serviceProvider;
private readonly LinkGenerator _linkGenerator;
public string FriendlyName { get; } = "Automated Bitcoin Sender";
public OnChainAutomatedPayoutSenderFactory(EventAggregator eventAggregator,
ILogger<OnChainAutomatedPayoutSenderFactory> logger,
BTCPayNetworkProvider btcPayNetworkProvider, IServiceProvider serviceProvider, LinkGenerator linkGenerator) : base(eventAggregator, logger)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_serviceProvider = serviceProvider;
_linkGenerator = linkGenerator;
}
public string Processor => ProcessorName;
public static string ProcessorName => nameof(OnChainAutomatedPayoutSenderFactory);
public string ConfigureLink(string storeId, PaymentMethodId paymentMethodId, HttpRequest request)
{
return _linkGenerator.GetUriByAction("Configure",
"UIOnChainAutomatedPayoutProcessors",new
{
storeId,
cryptoCode = paymentMethodId.CryptoCode
}, request.Scheme, request.Host, request.PathBase);
}
public IEnumerable<PaymentMethodId> GetSupportedPaymentMethods()
{
return _btcPayNetworkProvider.GetAll().OfType<BTCPayNetwork>()
.Where(network => !network.ReadonlyWallet && network.WalletSupported)
.Select(network =>
new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance));
}
public async Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
{
if (settings.Processor != Processor)
{
throw new NotSupportedException("This processor cannot handle the provided requirements");
}
return ActivatorUtilities.CreateInstance<OnChainAutomatedPayoutProcessor>(_serviceProvider, settings);
}
}

View File

@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data.Data;
using BTCPayServer.Payments;
using BTCPayServer.PayoutProcessors.Settings;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.PayoutProcessors.OnChain;
public class UIOnChainAutomatedPayoutProcessorsController : Controller
{
private readonly EventAggregator _eventAggregator;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly OnChainAutomatedPayoutSenderFactory _onChainAutomatedPayoutSenderFactory;
private readonly PayoutProcessorService _payoutProcessorService;
public UIOnChainAutomatedPayoutProcessorsController(
EventAggregator eventAggregator,
BTCPayNetworkProvider btcPayNetworkProvider,
OnChainAutomatedPayoutSenderFactory onChainAutomatedPayoutSenderFactory,
PayoutProcessorService payoutProcessorService)
{
_eventAggregator = eventAggregator;
_btcPayNetworkProvider = btcPayNetworkProvider;
_onChainAutomatedPayoutSenderFactory = onChainAutomatedPayoutSenderFactory;
_payoutProcessorService = payoutProcessorService;
}
[HttpGet("~/stores/{storeId}/payout-processors/onchain-automated/{cryptocode}")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Configure(string storeId, string cryptoCode)
{
if (!_onChainAutomatedPayoutSenderFactory.GetSupportedPaymentMethods().Any(id =>
id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = $"This processor cannot handle {cryptoCode}."
});
return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors");
}
var wallet = HttpContext.GetStoreData().GetDerivationSchemeSettings(_btcPayNetworkProvider, cryptoCode);
if (wallet?.IsHotWallet is not true)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = $"Either your {cryptoCode} wallet is not configured, or it is not a hot wallet. This processor cannot function until a hot wallet is configured in your store."
});
}
var activeProcessor =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] { storeId },
Processors = new []{ _onChainAutomatedPayoutSenderFactory.Processor},
PaymentMethods = new[]
{
new PaymentMethodId(cryptoCode, BitcoinPaymentType.Instance).ToString()
}
}))
.FirstOrDefault();
return View (new OnChainTransferViewModel(activeProcessor is null? new AutomatedPayoutBlob() : OnChainAutomatedPayoutProcessor.GetBlob(activeProcessor)));
}
[HttpPost("~/stores/{storeId}/payout-processors/onchain-automated/{cryptocode}")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Configure(string storeId, string cryptoCode, OnChainTransferViewModel automatedTransferBlob)
{
if (!_onChainAutomatedPayoutSenderFactory.GetSupportedPaymentMethods().Any(id =>
id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase)))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = $"This processor cannot handle {cryptoCode}."
});
return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors");
}
var activeProcessor =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] { storeId },
Processors = new []{ OnChainAutomatedPayoutSenderFactory.ProcessorName},
PaymentMethods = new[]
{
new PaymentMethodId(cryptoCode, BitcoinPaymentType.Instance).ToString()
}
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();
activeProcessor.Blob = InvoiceRepository.ToBytes(automatedTransferBlob.ToBlob());
activeProcessor.StoreId = storeId;
activeProcessor.PaymentMethod = new PaymentMethodId(cryptoCode, BitcoinPaymentType.Instance).ToString();
activeProcessor.Processor = _onChainAutomatedPayoutSenderFactory.Processor;
var tcs = new TaskCompletionSource();
_eventAggregator.Publish(new PayoutProcessorUpdated()
{
Data = activeProcessor,
Id = activeProcessor.Id,
Processed = tcs
});
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = $"Processor updated."
});
await tcs.Task;
return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors", new {storeId});
}
public class OnChainTransferViewModel
{
public OnChainTransferViewModel()
{
}
public OnChainTransferViewModel(AutomatedPayoutBlob blob)
{
IntervalMinutes = blob.Interval.TotalMinutes;
}
public double IntervalMinutes { get; set; }
public AutomatedPayoutBlob ToBlob()
{
return new AutomatedPayoutBlob() { Interval = TimeSpan.FromMinutes(IntervalMinutes) };
}
}
}

View File

@@ -0,0 +1,165 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Data.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
namespace BTCPayServer.PayoutProcessors;
public class PayoutProcessorUpdated
{
public string Id { get; set; }
public PayoutProcessorData Data { get; set; }
public TaskCompletionSource Processed { get; set; }
}
public class PayoutProcessorService : EventHostedServiceBase
{
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly IEnumerable<IPayoutProcessorFactory> _payoutProcessorFactories;
private ConcurrentDictionary<string, IHostedService> Services { get; set; } = new();
public PayoutProcessorService(
ApplicationDbContextFactory applicationDbContextFactory,
EventAggregator eventAggregator,
Logs logs,
IEnumerable<IPayoutProcessorFactory> payoutProcessorFactories) : base(eventAggregator, logs)
{
_applicationDbContextFactory = applicationDbContextFactory;
_payoutProcessorFactories = payoutProcessorFactories;
}
public class PayoutProcessorQuery
{
public string[] Stores { get; set; }
public string[] Processors { get; set; }
public string[] PaymentMethods { get; set; }
}
public async Task<List<PayoutProcessorData>> GetProcessors(PayoutProcessorQuery query)
{
await using var context = _applicationDbContextFactory.CreateContext();
var queryable = context.PayoutProcessors.AsQueryable();
if (query.Processors is not null)
{
queryable = queryable.Where(data => query.Processors.Contains(data.Processor));
}
if (query.Stores is not null)
{
queryable = queryable.Where(data => query.Stores.Contains(data.StoreId));
}
if (query.PaymentMethods is not null)
{
queryable = queryable.Where(data => query.PaymentMethods.Contains(data.PaymentMethod));
}
return await queryable.ToListAsync();
}
private async Task RemoveProcessor(string id)
{
await using var context = _applicationDbContextFactory.CreateContext();
var item = await context.FindAsync<PayoutProcessorData>(id);
if (item is not null)
context.Remove(item);
await context.SaveChangesAsync();
await StopProcessor(id, CancellationToken.None);
}
private async Task AddOrUpdateProcessor(PayoutProcessorData data)
{
await using var context = _applicationDbContextFactory.CreateContext();
if (string.IsNullOrEmpty(data.Id))
{
await context.AddAsync(data);
}
else
{
context.Update(data);
}
await context.SaveChangesAsync();
await StartOrUpdateProcessor(data, CancellationToken.None);
}
protected override void SubscribeToEvents()
{
base.SubscribeToEvents();
Subscribe<PayoutProcessorUpdated>();
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
await base.StartAsync(cancellationToken);
var activeProcessors = await GetProcessors(new PayoutProcessorQuery());
var tasks = activeProcessors.Select(data => StartOrUpdateProcessor(data, cancellationToken));
await Task.WhenAll(tasks);
}
private async Task StopProcessor(string id, CancellationToken cancellationToken)
{
if (Services.Remove(id, out var currentService))
{
await currentService.StopAsync(cancellationToken);
}
}
private async Task StartOrUpdateProcessor(PayoutProcessorData data, CancellationToken cancellationToken)
{
var matchedProcessor = _payoutProcessorFactories.FirstOrDefault(factory =>
factory.Processor == data.Processor);
if (matchedProcessor is not null)
{
await StopProcessor(data.Id, cancellationToken);
var processor = await matchedProcessor.ConstructProcessor(data);
await processor.StartAsync(cancellationToken);
Services.TryAdd(data.Id, processor);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
await StopAllService(cancellationToken);
}
private async Task StopAllService(CancellationToken cancellationToken)
{
foreach (KeyValuePair<string,IHostedService> service in Services)
{
await service.Value.StopAsync(cancellationToken);
}
Services.Clear();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
await base.ProcessEvent(evt, cancellationToken);
if (evt is PayoutProcessorUpdated processorUpdated)
{
if (processorUpdated.Data is null)
{
await RemoveProcessor(processorUpdated.Id);
}
else
{
await AddOrUpdateProcessor(processorUpdated.Data);
}
processorUpdated.Processed?.SetResult();
}
}
}

View File

@@ -0,0 +1,26 @@
using BTCPayServer.Data.Data;
using BTCPayServer.Payments;
using BTCPayServer.PayoutProcessors.Lightning;
using BTCPayServer.PayoutProcessors.OnChain;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.PayoutProcessors;
public static class PayoutProcessorsExtensions
{
public static void AddPayoutProcesors(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton<OnChainAutomatedPayoutSenderFactory>();
serviceCollection.AddSingleton<IPayoutProcessorFactory>(provider => provider.GetRequiredService<OnChainAutomatedPayoutSenderFactory>());
serviceCollection.AddSingleton<LightningAutomatedPayoutSenderFactory>();
serviceCollection.AddSingleton<IPayoutProcessorFactory>(provider => provider.GetRequiredService<LightningAutomatedPayoutSenderFactory>());
serviceCollection.AddHostedService<PayoutProcessorService>();
serviceCollection.AddSingleton<PayoutProcessorService>();
serviceCollection.AddHostedService(s=> s.GetRequiredService<PayoutProcessorService>());
}
public static PaymentMethodId GetPaymentMethodId(this PayoutProcessorData data)
{
return PaymentMethodId.Parse(data.PaymentMethod);
}
}

View File

@@ -0,0 +1,8 @@
using System;
namespace BTCPayServer.PayoutProcessors.Settings;
public class AutomatedPayoutBlob
{
public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1);
}

View File

@@ -0,0 +1,90 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Data.Data;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.PayoutProcessors;
public class UIPayoutProcessorsController : Controller
{
private readonly EventAggregator _eventAggregator;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly IEnumerable<IPayoutProcessorFactory> _payoutProcessorFactories;
private readonly PayoutProcessorService _payoutProcessorService;
public UIPayoutProcessorsController(
EventAggregator eventAggregator,
BTCPayNetworkProvider btcPayNetworkProvider,
IEnumerable<IPayoutProcessorFactory> payoutProcessorFactories,
PayoutProcessorService payoutProcessorService)
{
_eventAggregator = eventAggregator;
_btcPayNetworkProvider = btcPayNetworkProvider;
_payoutProcessorFactories = payoutProcessorFactories;
_payoutProcessorService = payoutProcessorService;
;
}
[HttpGet("~/stores/{storeId}/payout-processors")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ConfigureStorePayoutProcessors(string storeId)
{
var activeProcessors =
(await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery() { Stores = new[] { storeId } }))
.GroupBy(data => data.Processor);
var paymentMethods = HttpContext.GetStoreData().GetEnabledPaymentMethods(_btcPayNetworkProvider)
.Select(method => method.PaymentId).ToList();
return View(_payoutProcessorFactories.Select(factory =>
{
var conf = activeProcessors.FirstOrDefault(datas => datas.Key == factory.Processor)
?.ToDictionary(data => data.GetPaymentMethodId(), data => data) ??
new Dictionary<PaymentMethodId, PayoutProcessorData>();
foreach (PaymentMethodId supportedPaymentMethod in factory.GetSupportedPaymentMethods())
{
conf.TryAdd(supportedPaymentMethod, null);
}
return new StorePayoutProcessorsView() { Factory = factory, Configured = conf };
}).ToList());
}
[HttpPost("~/stores/{storeId}/payout-processors/{id}/remove")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Remove(string storeId, string id)
{
var tcs = new TaskCompletionSource();
_eventAggregator.Publish(new PayoutProcessorUpdated()
{
Data = null,
Id = id,
Processed = tcs
});
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Payout Processor removed"
});
await tcs.Task;
return RedirectToAction("ConfigureStorePayoutProcessors",new {storeId});
}
public class StorePayoutProcessorsView
{
public Dictionary<PaymentMethodId, PayoutProcessorData> Configured { get; set; }
public IPayoutProcessorFactory Factory { get; set; }
}
}

View File

@@ -59,7 +59,7 @@ namespace BTCPayServer.Plugins
var respObj = JObject.Parse(resp)["tree"] as JArray; var respObj = JObject.Parse(resp)["tree"] as JArray;
var detectedPlugins = respObj.Where(token => token["path"].ToString().EndsWith(".btcpay")); var detectedPlugins = respObj.Where(token => token["path"].ToString().EndsWith(".btcpay", StringComparison.OrdinalIgnoreCase));
List<Task<AvailablePlugin>> result = new List<Task<AvailablePlugin>>(); List<Task<AvailablePlugin>> result = new List<Task<AvailablePlugin>>();
foreach (JToken detectedPlugin in detectedPlugins) foreach (JToken detectedPlugin in detectedPlugins)

View File

@@ -280,7 +280,9 @@ namespace BTCPayServer.Services.Altcoins.Zcash.UI
} }
}; };
#pragma warning disable CA1416 // Validate platform compatibility
process.Start(); process.Start();
#pragma warning restore CA1416 // Validate platform compatibility
process.WaitForExit(); process.WaitForExit();
} }

View File

@@ -770,18 +770,21 @@ namespace BTCPayServer.Services.Invoices
return status; return status;
} }
internal static byte[] ToBytes<T>(T obj, BTCPayNetworkBase network = null) public static byte[] ToBytes<T>(T obj, BTCPayNetworkBase network = null)
{ {
return ZipUtils.Zip(ToJsonString(obj, network)); return ZipUtils.Zip(ToJsonString(obj, network));
} }
public static T FromBytes<T>(byte[] blob, BTCPayNetworkBase network = null)
{
return network == null
? JsonConvert.DeserializeObject<T>(ZipUtils.Unzip(blob), DefaultSerializerSettings)
: network.ToObject<T>(ZipUtils.Unzip(blob));
}
public static string ToJsonString<T>(T data, BTCPayNetworkBase network) public static string ToJsonString<T>(T data, BTCPayNetworkBase network)
{ {
if (network == null) return network == null ? JsonConvert.SerializeObject(data, DefaultSerializerSettings) : network.ToString(data);
{
return JsonConvert.SerializeObject(data, DefaultSerializerSettings);
}
return network.ToString(data);
} }
} }

View File

@@ -90,11 +90,12 @@ namespace BTCPayServer.Services.Labels
} }
else if (uncoloredLabel is PayoutLabel payoutLabel) else if (uncoloredLabel is PayoutLabel payoutLabel)
{ {
coloredLabel.Tooltip = $"Paid a payout of a pull payment ({payoutLabel.PullPaymentId})"; coloredLabel.Tooltip =
coloredLabel.Link = string.IsNullOrEmpty(payoutLabel.PullPaymentId) || string.IsNullOrEmpty(payoutLabel.WalletId) $"Paid a payout{(payoutLabel.PullPaymentId is null ? string.Empty : $" of a pull payment ({payoutLabel.PullPaymentId})")}";
coloredLabel.Link = string.IsNullOrEmpty(payoutLabel.WalletId)
? null ? null
: _linkGenerator.PayoutLink(payoutLabel.WalletId, : _linkGenerator.PayoutLink(payoutLabel.WalletId,
payoutLabel.PullPaymentId, request.Scheme, request.Host, payoutLabel.PullPaymentId, PayoutState.Completed, request.Scheme, request.Host,
request.PathBase); request.PathBase);
} }
return coloredLabel; return coloredLabel;

View File

@@ -30,5 +30,6 @@ namespace BTCPayServer.Services
public bool AddInitialUserBlob { get; set; } public bool AddInitialUserBlob { get; set; }
public bool LighingAddressSettingRename { get; set; } public bool LighingAddressSettingRename { get; set; }
public bool LighingAddressDatabaseMigration { get; set; } public bool LighingAddressDatabaseMigration { get; set; }
public bool AddStoreToPayout { get; set; }
} }
} }

View File

@@ -1,4 +1,6 @@
using System;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Models.NotificationViewModels; using BTCPayServer.Models.NotificationViewModels;
@@ -32,7 +34,12 @@ namespace BTCPayServer.Services.Notifications.Blobs
protected override void FillViewModel(PayoutNotification notification, NotificationViewModel vm) protected override void FillViewModel(PayoutNotification notification, NotificationViewModel vm)
{ {
vm.Body = "A new payout is awaiting for approval"; vm.Body = (notification.Status ?? PayoutState.AwaitingApproval) switch
{
PayoutState.AwaitingApproval => $"A new payout is awaiting for approval",
PayoutState.AwaitingPayment => $"A new payout is awaiting for payment",
_ => throw new ArgumentOutOfRangeException()
};
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIStorePullPaymentsController.Payouts), vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIStorePullPaymentsController.Payouts),
"UIStorePullPayments", "UIStorePullPayments",
new { storeId = notification.StoreId, paymentMethodId = notification.PaymentMethod }, _options.RootPath); new { storeId = notification.StoreId, paymentMethodId = notification.PaymentMethod }, _options.RootPath);
@@ -45,5 +52,6 @@ namespace BTCPayServer.Services.Notifications.Blobs
public string Currency { get; set; } public string Currency { get; set; }
public override string Identifier => TYPE; public override string Identifier => TYPE;
public override string NotificationType => TYPE; public override string NotificationType => TYPE;
public PayoutState? Status { get; set; }
} }
} }

View File

@@ -52,7 +52,8 @@ namespace BTCPayServer.Services
_memoryCache.Set(GetCacheKey(name), obj); _memoryCache.Set(GetCacheKey(name), obj);
_EventAggregator.Publish(new SettingsChanged<T>() _EventAggregator.Publish(new SettingsChanged<T>()
{ {
Settings = obj Settings = obj,
SettingsName = name
}); });
} }

View File

@@ -0,0 +1,32 @@
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.PayoutProcessors.Lightning.UILightningAutomatedPayoutProcessorsController.LightningTransferViewModel
@{
ViewData["NavPartialName"] = "../UIStores/_Nav";
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage("PayoutProcessors", "Lightning Payout Processor", Context.GetStoreData().Id);
}
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
</div>
<p>Payout Processors allow BTCPay Server to handle payouts awaiting payment in an automated way.</p>
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All" class="text-danger"></div>
}
<form method="post">
<div class="form-group">
<label asp-for="IntervalMinutes" class="form-label"></label>
<input asp-for="IntervalMinutes" class="form-control">
</div>
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form>
</div>
</div>
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,32 @@
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.PayoutProcessors.OnChain.UIOnChainAutomatedPayoutProcessorsController.OnChainTransferViewModel
@{
ViewData["NavPartialName"] = "../UIStores/_Nav";
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage("PayoutProcessors", "OnChain Payout Processor", Context.GetStoreData().Id);
}
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
</div>
<p>Payout Processors allow BTCPay Server to handle payouts awaiting payment in an automated way.</p>
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All" class="text-danger"></div>
}
<form method="post">
<div class="form-group">
<label asp-for="IntervalMinutes" class="form-label"></label>
<input asp-for="IntervalMinutes" class="form-control">
</div>
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form>
</div>
</div>
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,76 @@
@using BTCPayServer.Abstractions.Extensions
@model List<BTCPayServer.PayoutProcessors.UIPayoutProcessorsController.StorePayoutProcessorsView>
@{
ViewData["NavPartialName"] = "../UIStores/_Nav";
Layout = "../Shared/_NavLayout.cshtml";
var storeId = Context.GetStoreData().Id;
ViewData.SetActivePage("PayoutProcessors", "Payout Processors", storeId);
}
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
</div>
<p>Payout Processors allow BTCPay Server to handle payouts in an automated way.</p>
@if (Model.Any())
{
foreach (var processorsView in Model)
{
<div class="row">
<h4>@processorsView.Factory.FriendlyName</h4>
<div class="row">
<div class="col">
<table class="table table-hover">
<thead>
<tr>
<th>Payment Method</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var conf in processorsView.Configured)
{
<tr>
<td>
@conf.Key.ToPrettyString()
</td>
<td class="text-end">
@if (conf.Value is null)
{
<a href="@processorsView.Factory.ConfigureLink(storeId, conf.Key, Context.Request)">Configure</a>
}
else
{
<a href="@processorsView.Factory.ConfigureLink(storeId, conf.Key, Context.Request)">Modify</a>
<a asp-action="Remove" asp-route-storeId="@storeId" asp-route-id="@conf.Value.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The Payout Processor @processorsView.Factory.Processor for @conf.Key.CryptoCode will be removed from your store." >Remove</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
}
else
{
<p class="text-secondary mt-3">
There are no processors available.
</p>
}
</div>
</div>
<partial name="_Confirm" model="@(new ConfirmModel("Delete payout processor", "This payout processor will be removed from this store.", "Delete"))" />
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
}

View File

@@ -3,10 +3,14 @@
@using BTCPayServer.Views.Stores @using BTCPayServer.Views.Stores
@using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Client @using BTCPayServer.Client
@using BTCPayServer.PayoutProcessors
@model BTCPayServer.Models.WalletViewModels.PayoutsModel @model BTCPayServer.Models.WalletViewModels.PayoutsModel
@inject IEnumerable<IPayoutHandler> PayoutHandlers; @inject IEnumerable<IPayoutHandler> PayoutHandlers;
@inject PayoutProcessorService _payoutProcessorService;
@inject IEnumerable<IPayoutProcessorFactory> _payoutProcessorFactories;
@{ @{
var storeId = Context.GetRouteValue("storeId") as string;
ViewData.SetActivePage(StoreNavPages.Payouts, $"Payouts{(string.IsNullOrEmpty(Model.PullPaymentName) ? string.Empty : " for pull payment " + Model.PullPaymentName)}", Context.GetStoreData().Id); ViewData.SetActivePage(StoreNavPages.Payouts, $"Payouts{(string.IsNullOrEmpty(Model.PullPaymentName) ? string.Empty : " for pull payment " + Model.PullPaymentName)}", Context.GetStoreData().Id);
Model.PaginationQuery ??= new Dictionary<string, object>(); Model.PaginationQuery ??= new Dictionary<string, object>();
Model.PaginationQuery.Add("pullPaymentId", Model.PullPaymentId); Model.PaginationQuery.Add("pullPaymentId", Model.PullPaymentId);
@@ -19,7 +23,6 @@
if (payoutHandler is null) if (payoutHandler is null)
return; return;
stateActions.AddRange(payoutHandler.GetPayoutSpecificActions().Where(pair => pair.Key == Model.PayoutState).SelectMany(pair => pair.Value)); stateActions.AddRange(payoutHandler.GetPayoutSpecificActions().Where(pair => pair.Key == Model.PayoutState).SelectMany(pair => pair.Value));
} }
switch (Model.PayoutState) switch (Model.PayoutState)
{ {
@@ -47,13 +50,28 @@
</script> </script>
} }
<partial name="_StatusMessage" /> <partial name="_StatusMessage"/>
@{
}
@if (_payoutProcessorFactories.Any(factory => factory.GetSupportedPaymentMethods().Contains(paymentMethodId)) && !(await _payoutProcessorService.GetProcessors(new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] {storeId},
PaymentMethods = new[] {Model.PaymentMethodId}
})).Any())
{
<div class="alert alert-info text-break" role="alert">
protip: BTCPay Server has detected that there are supported but unconfigured Payout Processors for this payout payment method. Payout processors can potentially help automate the the workflow of these payouts so that you do not need to manually handle them.
<a class="alert-link p-0" asp-action="ConfigureStorePayoutProcessors" asp-controller="UIPayoutProcessors" asp-route-storeId="@storeId">Configure now</a>
</div>
}
<h2 class="mt-1 mb-4">@ViewData["Title"]</h2> <h2 class="mt-1 mb-4">@ViewData["Title"]</h2>
<form method="post"> <form method="post">
<input type="hidden" asp-for="PaymentMethodId" /> <input type="hidden" asp-for="PaymentMethodId"/>
<input type="hidden" asp-for="PayoutState" /> <input type="hidden" asp-for="PayoutState"/>
<div class="d-flex justify-content-between mb-4"> <div class="d-flex justify-content-between mb-4">
<ul class="nav mb-1"> <ul class="nav mb-1">
@foreach (var state in Model.PaymentMethods) @foreach (var state in Model.PaymentMethods)
@@ -65,7 +83,13 @@
asp-route-pullPaymentId="@Model.PullPaymentId" asp-route-pullPaymentId="@Model.PullPaymentId"
class="btcpay-pill @(state.ToString() == Model.PaymentMethodId ? "active" : "")" class="btcpay-pill @(state.ToString() == Model.PaymentMethodId ? "active" : "")"
id="@state.ToString()-view" id="@state.ToString()-view"
role="tab">@state.ToPrettyString()</a> role="tab">
@state.ToPrettyString()
@if (Model.PaymentMethodCount.TryGetValue(state.ToString(), out var count) && count > 0)
{
<span>(@count)</span>
}
</a>
</li> </li>
} }
</ul> </ul>
@@ -93,7 +117,9 @@
asp-route-payoutState="@state.Key" asp-route-payoutState="@state.Key"
asp-route-pullPaymentId="@Model.PullPaymentId" asp-route-pullPaymentId="@Model.PullPaymentId"
asp-route-paymentMethodId="@Model.PaymentMethodId" asp-route-paymentMethodId="@Model.PaymentMethodId"
class="nav-link @(state.Key == Model.PayoutState ? "active" : "")" role="tab">@state.Key.GetStateString() (@state.Value)</a> class="nav-link @(state.Key == Model.PayoutState ? "active" : "")" role="tab">
@state.Key.GetStateString() (@state.Value)
</a>
} }
</div> </div>
</nav> </nav>
@@ -103,60 +129,60 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover">
<thead class="thead-inverse"> <thead class="thead-inverse">
<tr> <tr>
<th permission="@Policies.CanModifyStoreSettings"> <th permission="@Policies.CanModifyStoreSettings">
<input id="@Model.PayoutState-selectAllCheckbox" type="checkbox" class="form-check-input selectAll" data-payout-state="@Model.PayoutState.ToString()" /> <input id="@Model.PayoutState-selectAllCheckbox" type="checkbox" class="form-check-input selectAll" data-payout-state="@Model.PayoutState.ToString()"/>
</th> </th>
<th style="min-width: 90px;" class="col-md-auto"> <th style="min-width: 90px;" class="col-md-auto">
Date Date
</th> </th>
<th class="text-start">Source</th> <th class="text-start">Source</th>
<th class="text-start">Destination</th> <th class="text-start">Destination</th>
<th class="text-end">Amount</th> <th class="text-end">Amount</th>
@if (Model.PayoutState != PayoutState.AwaitingApproval) @if (Model.PayoutState != PayoutState.AwaitingApproval)
{ {
<th class="text-end">Transaction</th> <th class="text-end">Transaction</th>
} }
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (int i = 0; i < Model.Payouts.Count; i++) @for (int i = 0; i < Model.Payouts.Count; i++)
{ {
var pp = Model.Payouts[i]; var pp = Model.Payouts[i];
<tr class="payout"> <tr class="payout">
<td permission="@Policies.CanModifyStoreSettings"> <td permission="@Policies.CanModifyStoreSettings">
<span> <span>
<input type="checkbox" class="selection-item-@Model.PayoutState.ToString() form-check-input" asp-for="Payouts[i].Selected" /> <input type="checkbox" class="selection-item-@Model.PayoutState.ToString() form-check-input" asp-for="Payouts[i].Selected"/>
<input type="hidden" asp-for="Payouts[i].PayoutId" /> <input type="hidden" asp-for="Payouts[i].PayoutId"/>
</span> </span>
</td>
<td>
<span>@pp.Date.ToBrowserDate()</span>
</td>
<td class="mw-100">
<span>@pp.PullPaymentName</span>
</td>
<td title="@pp.Destination">
<span class="text-break">@pp.Destination</span>
</td>
<td class="text-end text-nowrap">
<span>@pp.Amount</span>
</td>
@if (Model.PayoutState != PayoutState.AwaitingApproval)
{
<td class="text-end">
@if (!(pp.ProofLink is null))
{
<a class="transaction-link" href="@pp.ProofLink" rel="noreferrer noopener">Link</a>
}
</td> </td>
<td> }
<span>@pp.Date.ToBrowserDate()</span> </tr>
</td> }
<td class="mw-100">
<span>@pp.PullPaymentName</span>
</td>
<td title="@pp.Destination">
<span class="text-break">@pp.Destination</span>
</td>
<td class="text-end text-nowrap">
<span>@pp.Amount</span>
</td>
@if (Model.PayoutState != PayoutState.AwaitingApproval)
{
<td class="text-end">
@if (!(pp.ProofLink is null))
{
<a class="transaction-link" href="@pp.ProofLink" rel="noreferrer noopener">Link</a>
}
</td>
}
</tr>
}
</tbody> </tbody>
</table> </table>
</div> </div>
<vc:pager view-model="Model" /> <vc:pager view-model="Model"/>
} }
else else
{ {

View File

@@ -16,6 +16,7 @@
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Users))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="UIStores" asp-action="StoreUsers" asp-route-storeId="@storeId">Users</a> <a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Users))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="UIStores" asp-action="StoreUsers" asp-route-storeId="@storeId">Users</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Integrations))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Integrations)" asp-controller="UIStores" asp-action="Integrations" asp-route-storeId="@storeId">Integrations</a> <a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Integrations))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Integrations)" asp-controller="UIStores" asp-action="Integrations" asp-route-storeId="@storeId">Integrations</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@storeId">Webhooks</a> <a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@storeId">Webhooks</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-PayoutProcessors" class="nav-link @ViewData.IsActivePage("PayoutProcessors")" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@storeId">Payout Processors</a>
<vc:ui-extension-point location="store-nav" model="@Model"/> <vc:ui-extension-point location="store-nav" model="@Model"/>
</div> </div>
</nav> </nav>

View File

@@ -94,6 +94,7 @@
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
@for (var index = 0; index < Model.Outputs.Count; index++) @for (var index = 0; index < Model.Outputs.Count; index++)
{ {
<input type="hidden" asp-for="Outputs[index].PayoutId" />
<div class="list-group-item transaction-output-form px-0 pt-0 pb-3 mb-3"> <div class="list-group-item transaction-output-form px-0 pt-0 pb-3 mb-3">
<div class="form-group"> <div class="form-group">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
@@ -231,6 +232,7 @@
</div> </div>
<div class="form-group d-flex gap-3 mt-2"> <div class="form-group d-flex gap-3 mt-2">
<button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary">Sign transaction</button> <button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary">Sign transaction</button>
<button type="submit" id="ScheduleTransaction" name="command" value="schedule" class="btn btn-secondary">Schedule transaction</button>
<a class="btn btn-secondary" asp-controller="UIWallets" asp-action="WalletPSBT" asp-route-walletId="@walletId" id="PSBT">PSBT</a> <a class="btn btn-secondary" asp-controller="UIWallets" asp-action="WalletPSBT" asp-route-walletId="@walletId" id="PSBT">PSBT</a>
<button type="button" id="bip21parse" class="btn btn-secondary" title="Paste BIP21/Address"><i class="fa fa-paste"></i></button> <button type="button" id="bip21parse" class="btn btn-secondary" title="Paste BIP21/Address"><i class="fa fa-paste"></i></button>
<button type="button" id="scanqrcode" class="btn btn-secondary only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan BIP21/Address with camera"><i class="fa fa-camera"></i></button> <button type="button" id="scanqrcode" class="btn btn-secondary only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan BIP21/Address with camera"><i class="fa fa-camera"></i></button>

View File

@@ -0,0 +1,668 @@
{
"paths": {
"/api/v1/stores/{storeId}/payout-processors": {
"get": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Get store configured payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Get store configured payout processors",
"operationId": "StorePayoutProcessors_GetStorePayoutProcessors",
"responses": {
"200": {
"description": "configured payout processors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PayoutProcessorData"
}
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canviewstoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/payout-processors/{processor}/{paymentMethod}": {
"delete": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Remove store configured payout processor",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store",
"schema": {
"type": "string"
}
},
{
"name": "processor",
"in": "path",
"required": true,
"description": "The processor",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethod",
"in": "path",
"required": true,
"description": "The payment method",
"schema": {
"type": "string"
}
}
],
"description": "Remove store configured payout processor",
"operationId": "StorePayoutProcessors_RemoveStorePayoutProcessor",
"responses": {
"200": {
"description": "removed"
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/payout-processors": {
"get": {
"tags": [
"Payout Processors"
],
"summary": "Get payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Get payout processors available in this instance",
"operationId": "PayoutProcessors_GetPayoutProcessors",
"responses": {
"200": {
"description": "available payout processors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PayoutProcessorData"
}
}
}
}
}
},
"security": [
{
"API_Key": [],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/payout-processors/OnChainAutomatedTransferSenderFactory/{paymentMethod}": {
"get": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Get configured store onchain automated payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethod",
"in": "path",
"required": true,
"description": "A specific payment method to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Get configured store onchain automated payout processors",
"operationId": "GreenfieldStoreAutomatedOnChainPayoutProcessorsController_GetStoreOnChainAutomatedPayoutProcessors",
"responses": {
"200": {
"description": "configured processors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OnChainAutomatedTransferSettings"
}
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canviewstoresettings"
],
"Basic": []
}
]
},
"put": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Update configured store onchain automated payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethod",
"in": "path",
"required": true,
"description": "A specific payment method to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Update configured store onchain automated payout processors",
"operationId": "GreenfieldStoreAutomatedOnChainPayoutProcessorsController_UpdateStoreOnChainAutomatedPayoutProcessor",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateOnChainAutomatedTransferSettings"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "configured processor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnChainAutomatedTransferSettings"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canviewstoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/payout-processors/LightningAutomatedTransferSenderFactory/{paymentMethod}": {
"get": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Get configured store Lightning automated payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethod",
"in": "path",
"required": true,
"description": "A specific payment method to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Get configured store Lightning automated payout processors",
"operationId": "GreenfieldStoreAutomatedLightningPayoutProcessorsController_GetStoreLightningAutomatedPayoutProcessors",
"responses": {
"200": {
"description": "configured processors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LightningAutomatedTransferSettings"
}
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canviewstoresettings"
],
"Basic": []
}
]
},
"put": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Update configured store Lightning automated payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethod",
"in": "path",
"required": true,
"description": "A specific payment method to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Update configured store Lightning automated payout processors",
"operationId": "GreenfieldStoreAutomatedLightningPayoutProcessorsController_UpdateStoreLightningAutomatedPayoutProcessor",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateLightningAutomatedTransferSettings"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "configured processor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LightningAutomatedTransferSettings"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canviewstoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/payout-processors/OnChainAutomatedTransferSenderFactory": {
"get": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Get configured store onchain automated payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Get configured store onchain automated payout processors",
"operationId": "GreenfieldStoreAutomatedOnChainPayoutProcessorsController_GetStoreOnChainAutomatedPayoutProcessors",
"responses": {
"200": {
"description": "configured processors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/OnChainAutomatedTransferSettings"
}
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canviewstoresettings"
],
"Basic": []
}
]
},
"put": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Update configured store onchain automated payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethod",
"in": "path",
"required": true,
"description": "A specific payment method to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Update configured store onchain automated payout processors",
"operationId": "GreenfieldStoreAutomatedOnChainPayoutProcessorsController_UpdateStoreOnChainAutomatedPayoutProcessor",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateOnChainAutomatedTransferSettings"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "configured processor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnChainAutomatedTransferSettings"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canviewstoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/payout-processors/LightningAutomatedTransferSenderFactory": {
"get": {
"tags": [
"Stores (Payout Processors)"
],
"summary": "Get configured store Lightning automated payout processors",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
}
],
"description": "Get configured store Lightning automated payout processors",
"operationId": "GreenfieldStoreAutomatedLightningPayoutProcessorsController_GetStoreLightningAutomatedPayoutProcessors",
"responses": {
"200": {
"description": "configured processors",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LightningAutomatedTransferSettings"
}
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified store"
},
"404": {
"description": "The key is not found for this store"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canviewstoresettings"
],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"PayoutProcessorData": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"description": "unique identifier of the payout processor",
"type": "string"
},
"friendlyName": {
"description": "Human name of the payout processor",
"type": "string"
},
"paymentMethods": {
"nullable": true,
"description": "Supported, payment methods by this processor",
"type": "array",
"items": {
"type": "string"
}
}
}
},
"UpdateLightningAutomatedTransferSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"intervalSeconds": {
"description": "How often should the processor run",
"allOf": [
{
"$ref": "#/components/schemas/TimeSpanSeconds"
}
]
}
}
},
"LightningAutomatedTransferSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"paymentMethod": {
"description": "payment method of the payout processor",
"type": "string"
},
"intervalSeconds": {
"description": "How often should the processor run",
"allOf": [
{
"$ref": "#/components/schemas/TimeSpanSeconds"
}
]
}
}
},
"UpdateOnChainAutomatedTransferSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"intervalSeconds": {
"description": "How often should the processor run",
"allOf": [
{
"$ref": "#/components/schemas/TimeSpanSeconds"
}
]
}
}
},
"OnChainAutomatedTransferSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"paymentMethod": {
"description": "payment method of the payout processor",
"type": "string"
},
"intervalSeconds": {
"description": "How often should the processor run",
"allOf": [
{
"$ref": "#/components/schemas/TimeSpanSeconds"
}
]
}
}
}
}
},
"tags": [
{
"name": "Stores (Payout Processors)"
},
{
"name": "Payout Processors"
}
]
}

View File

@@ -7,7 +7,9 @@
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The store ID", "description": "The store ID",
"schema": { "type": "string" } "schema": {
"type": "string"
}
} }
], ],
"get": { "get": {
@@ -38,7 +40,9 @@
} }
} }
}, },
"tags": [ "Pull payments (Management)" ], "tags": [
"Pull payments (Management)"
],
"security": [ "security": [
{ {
"API_Key": [ "API_Key": [
@@ -142,7 +146,9 @@
} }
} }
}, },
"tags": [ "Pull payments (Management)" ], "tags": [
"Pull payments (Management)"
],
"security": [ "security": [
{ {
"API_Key": [ "API_Key": [
@@ -160,7 +166,9 @@
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the pull payment", "description": "The ID of the pull payment",
"schema": { "type": "string" } "schema": {
"type": "string"
}
} }
], ],
"get": { "get": {
@@ -182,7 +190,9 @@
"description": "Pull payment not found" "description": "Pull payment not found"
} }
}, },
"tags": [ "Pull payments (Public)" ], "tags": [
"Pull payments (Public)"
],
"security": [] "security": []
} }
}, },
@@ -193,14 +203,18 @@
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the store", "description": "The ID of the store",
"schema": { "type": "string" } "schema": {
"type": "string"
}
}, },
{ {
"name": "pullPaymentId", "name": "pullPaymentId",
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the pull payment", "description": "The ID of the pull payment",
"schema": { "type": "string" } "schema": {
"type": "string"
}
} }
], ],
"delete": { "delete": {
@@ -215,7 +229,9 @@
"description": "The pull payment has not been found, or does not belong to this store" "description": "The pull payment has not been found, or does not belong to this store"
} }
}, },
"tags": [ "Pull payments (Management)" ], "tags": [
"Pull payments (Management)"
],
"security": [ "security": [
{ {
"API_Key": [ "API_Key": [
@@ -233,7 +249,9 @@
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the pull payment", "description": "The ID of the pull payment",
"schema": { "type": "string" } "schema": {
"type": "string"
}
} }
], ],
"get": { "get": {
@@ -267,7 +285,9 @@
"description": "Pull payment not found" "description": "Pull payment not found"
} }
}, },
"tags": [ "Pull payments (Public)" ], "tags": [
"Pull payments (Public)"
],
"security": [] "security": []
}, },
"post": { "post": {
@@ -321,7 +341,9 @@
} }
} }
}, },
"tags": [ "Pull payments (Public)" ], "tags": [
"Pull payments (Public)"
],
"security": [] "security": []
} }
}, },
@@ -332,14 +354,18 @@
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the pull payment", "description": "The ID of the pull payment",
"schema": { "type": "string" } "schema": {
"type": "string"
}
}, },
{ {
"name": "payoutId", "name": "payoutId",
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the pull payment payout", "description": "The ID of the pull payment payout",
"schema": { "type": "string" } "schema": {
"type": "string"
}
} }
], ],
"get": { "get": {
@@ -361,7 +387,122 @@
"description": "Pull payment payout not found" "description": "Pull payment payout not found"
} }
}, },
"tags": [ "Pull payments (Public)", "Pull payments payout (Public)" ], "tags": [
"Pull payments (Public)",
"Pull payments payout (Public)"
],
"security": []
}
},
"/api/v1/stores/{storeId}/payouts": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The ID of the store",
"schema": {
"type": "string"
}
}
],
"post": {
"summary": "Create Payout ",
"description": "Create a new payout",
"operationId": "Payouts_CreatePayoutThroughStore",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreatePayoutThroughStoreRequest"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "A new payout has been created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayoutData"
}
}
}
},
"404": {
"description": "store not found"
},
"422": {
"description": "Unable to validate the request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"400": {
"description": "Wellknown error codes are: `duplicate-destination`, `expired`, `not-started`, `archived`, `overdraft`, `amount-too-low`, `payment-method-not-supported`",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
},
"tags": [
"Stores (Payouts)"
],
"security": [
{
"API_Key": [
"btcpay.store.canmanagepullpayments"
],
"Basic": []
}
]
},
"get": {
"summary": "Get Store Payouts",
"operationId": "PullPayments_GetStorePayouts",
"description": "Get payouts",
"parameters": [
{
"name": "includeCancelled",
"in": "query",
"required": false,
"description": "Whether this should list cancelled payouts",
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": {
"200": {
"description": "The payouts of the store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayoutDataList"
}
}
}
},
"404": {
"description": "Pull payment not found"
}
},
"tags": [
"Stores (Payouts)"
],
"security": [] "security": []
} }
}, },
@@ -372,18 +513,21 @@
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the store", "description": "The ID of the store",
"schema": { "type": "string" } "schema": {
"type": "string"
}
}, },
{ {
"name": "payoutId", "name": "payoutId",
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the payout", "description": "The ID of the payout",
"schema": { "type": "string" } "schema": {
"type": "string"
}
} }
], ],
"post": { "post": {
"summary": "Approve Payout", "summary": "Approve Payout",
"operationId": "PullPayments_ApprovePayout", "operationId": "PullPayments_ApprovePayout",
"description": "Approve a payout", "description": "Approve a payout",
@@ -443,7 +587,9 @@
"description": "The payout is not found" "description": "The payout is not found"
} }
}, },
"tags": [ "Pull payments (Management)" ], "tags": [
"Stores (Payouts)"
],
"security": [ "security": [
{ {
"API_Key": [ "API_Key": [
@@ -465,7 +611,9 @@
"description": "The payout is not found" "description": "The payout is not found"
} }
}, },
"tags": [ "Pull payments (Management)" ], "tags": [
"Stores (Payouts)"
],
"security": [ "security": [
{ {
"API_Key": [ "API_Key": [
@@ -483,18 +631,21 @@
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the store", "description": "The ID of the store",
"schema": { "type": "string" } "schema": {
"type": "string"
}
}, },
{ {
"name": "payoutId", "name": "payoutId",
"in": "path", "in": "path",
"required": true, "required": true,
"description": "The ID of the payout", "description": "The ID of the payout",
"schema": { "type": "string" } "schema": {
"type": "string"
}
} }
], ],
"post": { "post": {
"summary": "Mark Payout as Paid", "summary": "Mark Payout as Paid",
"operationId": "PullPayments_MarkPayoutPaid", "operationId": "PullPayments_MarkPayoutPaid",
"description": "Mark a payout as paid", "description": "Mark a payout as paid",
@@ -526,7 +677,9 @@
"description": "The payout is not found" "description": "The payout is not found"
} }
}, },
"tags": [ "Pull payments (Management)" ], "tags": [
"Stores (Payouts)"
],
"security": [ "security": [
{ {
"API_Key": [ "API_Key": [
@@ -573,6 +726,26 @@
} }
} }
}, },
"CreatePayoutThroughStoreRequest": {
"allOf": [
{
"$ref": "#/components/schemas/CreatePayoutRequest"
},
{
"type": "object",
"properties": {
"pullPaymentId": {
"type": "string",
"description": "The pull payment to create this for. Optional."
},
"approved": {
"type": "boolean",
"description": "Whether to approve this payout automatically upon creation"
}
}
}
]
},
"PayoutData": { "PayoutData": {
"type": "object", "type": "object",
"properties": { "properties": {