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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 3862 additions and 557 deletions

View file

@ -33,7 +33,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" 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" />
</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);
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)
{
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);
}
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)
{
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.JsonConverters;
using NBitcoin;
@ -15,6 +14,6 @@ namespace BTCPayServer.Client.Models
public float? MaxFeePercent { get; set; }
[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.Linq;
using BTCPayServer.Data.Data;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@ -62,6 +63,7 @@ namespace BTCPayServer.Data
public DbSet<WebhookDeliveryData> WebhookDeliveries { get; set; }
public DbSet<WebhookData> Webhooks { get; set; }
public DbSet<LightningAddressData> LightningAddresses{ get; set; }
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@ -88,7 +90,7 @@ namespace BTCPayServer.Data
InvoiceData.OnModelCreating(builder);
NotificationData.OnModelCreating(builder);
//OffchainTransactionData.OnModelCreating(builder);
Data.PairedSINData.OnModelCreating(builder);
BTCPayServer.Data.PairedSINData.OnModelCreating(builder);
PairingCodeData.OnModelCreating(builder);
//PayjoinLock.OnModelCreating(builder);
PaymentRequestData.OnModelCreating(builder);
@ -103,11 +105,12 @@ namespace BTCPayServer.Data
//StoreData.OnModelCreating(builder);
U2FDevice.OnModelCreating(builder);
Fido2Credential.OnModelCreating(builder);
Data.UserStore.OnModelCreating(builder);
BTCPayServer.Data.UserStore.OnModelCreating(builder);
//WalletData.OnModelCreating(builder);
WalletTransactionData.OnModelCreating(builder);
WebhookDeliveryData.OnModelCreating(builder);
LightningAddressData.OnModelCreating(builder);
PayoutProcessorData.OnModelCreating(builder);
//WebhookData.OnModelCreating(builder);

View file

@ -14,6 +14,7 @@ namespace BTCPayServer.Data
public string Id { get; set; }
public DateTimeOffset Date { get; set; }
public string PullPaymentDataId { get; set; }
public string StoreDataId { get; set; }
public PullPaymentData PullPaymentData { get; set; }
[MaxLength(20)]
public PayoutState State { get; set; }
@ -25,12 +26,16 @@ namespace BTCPayServer.Data
#nullable enable
public string? Destination { get; set; }
#nullable restore
public StoreData StoreData { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
{
builder.Entity<PayoutData>()
.HasOne(o => o.PullPaymentData)
.WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade);
builder.Entity<PayoutData>()
.HasOne(o => o.StoreData)
.WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade);
builder.Entity<PayoutData>()
.Property(o => o.State)
.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.ComponentModel.DataAnnotations.Schema;
using BTCPayServer.Client.Models;
using BTCPayServer.Data.Data;
using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData;
namespace BTCPayServer.Data
{
@ -42,5 +44,7 @@ namespace BTCPayServer.Data
public List<PairedSINData> PairedSINs { get; set; }
public IEnumerable<APIKeyData> APIKeys { 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 BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
@ -548,12 +548,17 @@ namespace BTCPayServer.Migrations
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("StoreDataId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PullPaymentDataId");
b.HasIndex("State");
b.HasIndex("StoreDataId");
b.HasIndex("Destination", "State");
b.ToTable("Payouts");
@ -848,6 +853,31 @@ namespace BTCPayServer.Migrations
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 =>
{
b.Property<string>("Id")
@ -1149,7 +1179,14 @@ namespace BTCPayServer.Migrations
.HasForeignKey("PullPaymentDataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("Payouts")
.HasForeignKey("StoreDataId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("PullPaymentData");
b.Navigation("StoreData");
});
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
@ -1271,6 +1308,16 @@ namespace BTCPayServer.Migrations
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 =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@ -1375,8 +1422,12 @@ namespace BTCPayServer.Migrations
b.Navigation("PaymentRequests");
b.Navigation("Payouts");
b.Navigation("PullPayments");
b.Navigation("PayoutProcessors");
b.Navigation("UserStores");
});

View file

@ -449,10 +449,10 @@ namespace BTCPayServer.Tests
await tester.StartAsync();
var acc = tester.NewAccount();
acc.Register();
acc.CreateStore();
await acc.CreateStoreAsync();
var storeId = (await acc.RegisterDerivationSchemeAsync("BTC", importKeysToNBX: true)).StoreId;
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",
Description = "Test description",
@ -2340,5 +2340,126 @@ namespace BTCPayServer.Tests
await adminClient.SendEmail(admin.StoreId,
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"
expose:
- "80"
links:
depends_on:
- dev
- selenium
extra_hosts:
@ -46,7 +46,7 @@ services:
dev:
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" ]
links:
depends_on:
- nbxplorer
- postgres
- customer_lightningd
@ -79,7 +79,7 @@ services:
connect=bitcoind:39388
fallbackfee=0.0002
rpcallowip=0.0.0.0/0
links:
depends_on:
- nbxplorer
- postgres
- customer_lnd
@ -117,7 +117,7 @@ services:
NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
NBXPLORER_NOAUTH: 1
NBXPLORER_EXPOSERPC: 1
links:
depends_on:
- bitcoind
- litecoind
- elementsd-liquid
@ -176,7 +176,7 @@ services:
volumes:
- "bitcoin_datadir:/etc/bitcoin"
- "customer_lightningd_datadir:/root/.lightning"
links:
depends_on:
- bitcoind
lightning-charged:
@ -197,7 +197,7 @@ services:
- "9735" # Lightning
ports:
- "54938:9112" # Charge
links:
depends_on:
- bitcoind
- merchant_lightningd
@ -224,7 +224,7 @@ services:
volumes:
- "bitcoin_datadir:/etc/bitcoin"
- "merchant_lightningd_datadir:/root/.lightning"
links:
depends_on:
- bitcoind
postgres:
image: postgres:13.4
@ -266,7 +266,7 @@ services:
volumes:
- "merchant_lnd_datadir:/data"
- "bitcoin_datadir:/deps/.bitcoin"
links:
depends_on:
- bitcoind
customer_lnd:
@ -301,7 +301,7 @@ services:
volumes:
- "customer_lnd_datadir:/root/.lnd"
- "bitcoin_datadir:/deps/.bitcoin"
links:
depends_on:
- bitcoind
tor:

View file

@ -30,7 +30,7 @@ services:
TESTS_SOCKSENDPOINT: "tor:9050"
expose:
- "80"
links:
depends_on:
- dev
- selenium
extra_hosts:
@ -44,7 +44,7 @@ services:
dev:
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" ]
links:
depends_on:
- nbxplorer
- postgres
- customer_lightningd
@ -76,7 +76,7 @@ services:
connect=bitcoind:39388
rpcallowip=0.0.0.0/0
fallbackfee=0.0002
links:
depends_on:
- nbxplorer
- postgres
- customer_lnd
@ -106,7 +106,7 @@ services:
NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer
NBXPLORER_EXPOSERPC: 1
NBXPLORER_NOAUTH: 1
links:
depends_on:
- bitcoind
@ -163,7 +163,7 @@ services:
volumes:
- "bitcoin_datadir:/etc/bitcoin"
- "customer_lightningd_datadir:/root/.lightning"
links:
depends_on:
- bitcoind
lightning-charged:
@ -184,7 +184,7 @@ services:
- "9735" # Lightning
ports:
- "54938:9112" # Charge
links:
depends_on:
- bitcoind
- merchant_lightningd
@ -211,7 +211,7 @@ services:
volumes:
- "bitcoin_datadir:/etc/bitcoin"
- "merchant_lightningd_datadir:/root/.lightning"
links:
depends_on:
- bitcoind
postgres:
@ -256,7 +256,7 @@ services:
volumes:
- "merchant_lnd_datadir:/data"
- "bitcoin_datadir:/deps/.bitcoin"
links:
depends_on:
- bitcoind
customer_lnd:
@ -292,7 +292,7 @@ services:
volumes:
- "customer_lnd_datadir:/root/.lnd"
- "bitcoin_datadir:/deps/.bitcoin"
links:
depends_on:
- bitcoind
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 CurrencyNameTable _currencyNameTable;
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService,
@ -39,7 +38,6 @@ namespace BTCPayServer.Controllers.Greenfield
ApplicationDbContextFactory dbContextFactory,
CurrencyNameTable currencyNameTable,
Services.BTCPayNetworkJsonSerializerSettings serializerSettings,
BTCPayNetworkProvider networkProvider,
IEnumerable<IPayoutHandler> payoutHandlers)
{
_pullPaymentService = pullPaymentService;
@ -47,7 +45,6 @@ namespace BTCPayServer.Controllers.Greenfield
_dbContextFactory = dbContextFactory;
_currencyNameTable = currencyNameTable;
_serializerSettings = serializerSettings;
_networkProvider = networkProvider;
_payoutHandlers = payoutHandlers;
}
@ -191,9 +188,8 @@ namespace BTCPayServer.Controllers.Greenfield
if (pp is null)
return PullPaymentNotFound();
var payouts = pp.Payouts.Where(p => p.State != PayoutState.Cancelled || includeCancelled).ToList();
var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false);
return base.Ok(payouts
.Select(p => ToModel(p, cd)).ToList());
.Select(ToModel).ToList());
}
[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);
if (payout is null)
return PayoutNotFound();
var cd = _currencyNameTable.GetCurrencyData(payout.PullPaymentData.GetBlob().Currency, false);
return base.Ok(ToModel(payout, cd));
return base.Ok(ToModel(payout));
}
private Client.Models.PayoutData ToModel(Data.PayoutData p, CurrencyData cd)
private Client.Models.PayoutData ToModel(Data.PayoutData p)
{
var blob = p.GetBlob(_serializerSettings);
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})");
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,
PullPaymentId = pullPaymentId,
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)
{
case ClaimRequest.ClaimResult.Ok:
@ -304,7 +368,8 @@ namespace BTCPayServer.Controllers.Greenfield
default:
throw new NotSupportedException("Unsupported ClaimResult");
}
return Ok(ToModel(result.PayoutData, cd));
return Ok(ToModel(result.PayoutData));
}
[HttpDelete("~/api/v1/stores/{storeId}/pull-payments/{pullPaymentId}")]
@ -319,6 +384,20 @@ namespace BTCPayServer.Controllers.Greenfield
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}")]
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> CancelPayout(string storeId, string payoutId)
@ -361,8 +440,6 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(approvePayoutRequest.RateRule), "Invalid RateRule");
return this.CreateValidationError(ModelState);
}
var ppBlob = payout.PullPaymentData.GetBlob();
var cd = _currencyNameTable.GetCurrencyData(ppBlob.Currency, false);
var result = await _pullPaymentService.Approve(new PullPaymentHostedService.PayoutApproval()
{
PayoutId = payoutId,
@ -373,7 +450,7 @@ namespace BTCPayServer.Controllers.Greenfield
switch (result)
{
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:
return this.CreateAPIError("invalid-state", errorMessage);
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 GreenfieldStoreEmailController _greenfieldStoreEmailController;
private readonly GreenfieldStoreUsersController _greenfieldStoreUsersController;
private readonly GreenfieldStorePayoutProcessorsController _greenfieldStorePayoutProcessorsController;
private readonly GreenfieldPayoutProcessorsController _greenfieldPayoutProcessorsController;
private readonly GreenfieldStoreAutomatedOnChainPayoutProcessorsController _greenfieldStoreAutomatedOnChainPayoutProcessorsController;
private readonly GreenfieldStoreAutomatedLightningPayoutProcessorsController _greenfieldStoreAutomatedLightningPayoutProcessorsController;
private readonly IServiceProvider _serviceProvider;
public BTCPayServerClientFactory(StoreRepository storeRepository,
@ -90,6 +94,10 @@ namespace BTCPayServer.Controllers.Greenfield
GreenfieldStorePaymentMethodsController storePaymentMethodsController,
GreenfieldStoreEmailController greenfieldStoreEmailController,
GreenfieldStoreUsersController greenfieldStoreUsersController,
GreenfieldStorePayoutProcessorsController greenfieldStorePayoutProcessorsController,
GreenfieldPayoutProcessorsController greenfieldPayoutProcessorsController,
GreenfieldStoreAutomatedOnChainPayoutProcessorsController greenfieldStoreAutomatedOnChainPayoutProcessorsController,
GreenfieldStoreAutomatedLightningPayoutProcessorsController greenfieldStoreAutomatedLightningPayoutProcessorsController,
IServiceProvider serviceProvider)
{
_storeRepository = storeRepository;
@ -115,6 +123,10 @@ namespace BTCPayServer.Controllers.Greenfield
_storePaymentMethodsController = storePaymentMethodsController;
_greenfieldStoreEmailController = greenfieldStoreEmailController;
_greenfieldStoreUsersController = greenfieldStoreUsersController;
_greenfieldStorePayoutProcessorsController = greenfieldStorePayoutProcessorsController;
_greenfieldPayoutProcessorsController = greenfieldPayoutProcessorsController;
_greenfieldStoreAutomatedOnChainPayoutProcessorsController = greenfieldStoreAutomatedOnChainPayoutProcessorsController;
_greenfieldStoreAutomatedLightningPayoutProcessorsController = greenfieldStoreAutomatedLightningPayoutProcessorsController;
_serviceProvider = serviceProvider;
}
@ -189,6 +201,10 @@ namespace BTCPayServer.Controllers.Greenfield
_storePaymentMethodsController,
_greenfieldStoreEmailController,
_greenfieldStoreUsersController,
_greenfieldStorePayoutProcessorsController,
_greenfieldPayoutProcessorsController,
_greenfieldStoreAutomatedOnChainPayoutProcessorsController,
_greenfieldStoreAutomatedLightningPayoutProcessorsController,
new LocalHttpContextAccessor() { HttpContext = context }
);
}
@ -196,7 +212,7 @@ namespace BTCPayServer.Controllers.Greenfield
public class LocalHttpContextAccessor : IHttpContextAccessor
{
public HttpContext? HttpContext { get; set; }
public HttpContext HttpContext { get; set; }
}
public class LocalBTCPayServerClient : BTCPayServerClient
@ -223,6 +239,10 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly UIHomeController _homeController;
private readonly GreenfieldStorePaymentMethodsController _storePaymentMethodsController;
private readonly GreenfieldStoreEmailController _greenfieldStoreEmailController;
private readonly GreenfieldStorePayoutProcessorsController _greenfieldStorePayoutProcessorsController;
private readonly GreenfieldPayoutProcessorsController _greenfieldPayoutProcessorsController;
private readonly GreenfieldStoreAutomatedOnChainPayoutProcessorsController _greenfieldStoreAutomatedOnChainPayoutProcessorsController;
private readonly GreenfieldStoreAutomatedLightningPayoutProcessorsController _greenfieldStoreAutomatedLightningPayoutProcessorsController;
private readonly GreenfieldStoreUsersController _greenfieldStoreUsersController;
public LocalBTCPayServerClient(
@ -247,6 +267,10 @@ namespace BTCPayServer.Controllers.Greenfield
GreenfieldStorePaymentMethodsController storePaymentMethodsController,
GreenfieldStoreEmailController greenfieldStoreEmailController,
GreenfieldStoreUsersController greenfieldStoreUsersController,
GreenfieldStorePayoutProcessorsController greenfieldStorePayoutProcessorsController,
GreenfieldPayoutProcessorsController greenfieldPayoutProcessorsController,
GreenfieldStoreAutomatedOnChainPayoutProcessorsController greenfieldStoreAutomatedOnChainPayoutProcessorsController,
GreenfieldStoreAutomatedLightningPayoutProcessorsController greenfieldStoreAutomatedLightningPayoutProcessorsController,
IHttpContextAccessor httpContextAccessor) : base(new Uri("https://dummy.local"), "", "")
{
_chainPaymentMethodsController = chainPaymentMethodsController;
@ -269,6 +293,10 @@ namespace BTCPayServer.Controllers.Greenfield
_storePaymentMethodsController = storePaymentMethodsController;
_greenfieldStoreEmailController = greenfieldStoreEmailController;
_greenfieldStoreUsersController = greenfieldStoreUsersController;
_greenfieldStorePayoutProcessorsController = greenfieldStorePayoutProcessorsController;
_greenfieldPayoutProcessorsController = greenfieldPayoutProcessorsController;
_greenfieldStoreAutomatedOnChainPayoutProcessorsController = greenfieldStoreAutomatedOnChainPayoutProcessorsController;
_greenfieldStoreAutomatedLightningPayoutProcessorsController = greenfieldStoreAutomatedLightningPayoutProcessorsController;
var controllers = new[]
{
@ -277,7 +305,11 @@ namespace BTCPayServer.Controllers.Greenfield
storeLightningNetworkPaymentMethodsController, greenFieldInvoiceController, storeWebhooksController,
greenFieldServerInfoController, greenfieldPullPaymentController, storesController, homeController,
lightningNodeApiController, storeLightningNodeApiController as ControllerBase,
storePaymentMethodsController, greenfieldStoreEmailController, greenfieldStoreUsersController
storePaymentMethodsController, greenfieldStoreEmailController, greenfieldStoreUsersController,
lightningNodeApiController, storeLightningNodeApiController as ControllerBase, storePaymentMethodsController,
greenfieldStoreEmailController, greenfieldStorePayoutProcessorsController, greenfieldPayoutProcessorsController,
greenfieldStoreAutomatedOnChainPayoutProcessorsController,
greenfieldStoreAutomatedLightningPayoutProcessorsController,
};
var authoverride = new DefaultAuthorizationService(
@ -1115,5 +1147,67 @@ namespace BTCPayServer.Controllers.Greenfield
{
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.Threading.Tasks;
using BTCPayServer.Data;
@ -30,15 +31,17 @@ public class LightningAddressService
query.Usernames = query.Usernames?.Select(NormalizeUsername)?.ToArray();
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)
{
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();
#pragma warning restore CS8619 // Nullability of reference types in value doesn't match target type.
}
public async Task<LightningAddressData?> ResolveByAddress(string username)
@ -77,7 +80,7 @@ public class LightningAddressService
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();
var x = (await GetCore(context, new LightningAddressQuery() {Usernames = new[] {username}})).FirstOrDefault();

View file

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

View file

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

View file

@ -25,7 +25,6 @@ using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
[Route("stores/{storeId}/pull-payments")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
public class UIStorePullPaymentsController : Controller
@ -44,6 +43,7 @@ namespace BTCPayServer.Controllers
return HttpContext.GetStoreData();
}
}
public UIStorePullPaymentsController(BTCPayNetworkProvider btcPayNetworkProvider,
IEnumerable<IPayoutHandler> payoutHandlers,
CurrencyNameTable currencyNameTable,
@ -59,7 +59,7 @@ namespace BTCPayServer.Controllers
_jsonSerializerSettings = jsonSerializerSettings;
}
[HttpGet("new")]
[HttpGet("stores/{storeId}/pull-payments/new")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewPullPayment(string storeId)
{
@ -76,18 +76,19 @@ namespace BTCPayServer.Controllers
});
return RedirectToAction(nameof(UIStoresController.GeneralSettings), "UIStores", new { storeId });
}
return View(new NewPullPaymentModel
{
Name = "",
Currency = CurrentStore.GetStoreBlob().DefaultCurrency,
CustomCSSLink = "",
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)]
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
// 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");
}
if (_currencyNameTable.GetCurrencyData(model.Currency, false) is null)
@ -141,13 +143,12 @@ namespace BTCPayServer.Controllers
});
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Pull payment request created",
Severity = StatusMessageModel.StatusSeverity.Success
Message = "Pull payment request created", Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction(nameof(PullPayments), new { storeId = storeId });
}
[HttpGet("")]
[HttpGet("stores/{storeId}/pull-payments")]
public async Task<IActionResult> PullPayments(
string storeId,
PullPaymentState pullPaymentState,
@ -190,20 +191,18 @@ namespace BTCPayServer.Controllers
var vm = this.ParseListQuery(new PullPaymentsModel
{
Skip = skip,
Count = count,
Total = await ppsQuery.CountAsync(),
ActiveState = pullPaymentState
Skip = skip, Count = count, Total = await ppsQuery.CountAsync(), ActiveState = pullPaymentState
});
switch (pullPaymentState) {
switch (pullPaymentState)
{
case PullPaymentState.Active:
ppsQuery = ppsQuery
.Where(
p => !p.Archived &&
(p.EndDate != null ? p.EndDate > DateTimeOffset.UtcNow : true) &&
p.StartDate <= DateTimeOffset.UtcNow
);
(p.EndDate != null ? p.EndDate > DateTimeOffset.UtcNow : true) &&
p.StartDate <= DateTimeOffset.UtcNow
);
break;
case PullPaymentState.Archived:
ppsQuery = ppsQuery.Where(p => p.Archived);
@ -225,10 +224,11 @@ namespace BTCPayServer.Controllers
{
var totalCompleted = pp.Payouts.Where(p => (p.State == PayoutState.Completed ||
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 ||
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 ni = _currencyNameTable.GetCurrencyData(ppBlob.Currency, true);
@ -262,15 +262,16 @@ namespace BTCPayServer.Controllers
return time;
}
[HttpGet("{pullPaymentId}/archive")]
[HttpGet("stores/{storeId}/pull-payments/{pullPaymentId}/archive")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult ArchivePullPayment(string storeId,
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)]
public async Task<IActionResult> ArchivePullPaymentPost(string storeId,
string pullPaymentId)
@ -278,14 +279,15 @@ namespace BTCPayServer.Controllers
await _pullPaymentService.Cancel(new HostedServices.PullPaymentHostedService.CancelRequest(pullPaymentId));
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Pull payment archived",
Severity = StatusMessageModel.StatusSeverity.Success
Message = "Pull payment archived", Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction(nameof(PullPayments), new { storeId });
}
[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(
string storeId, PayoutsModel vm, CancellationToken cancellationToken)
{
@ -302,16 +304,17 @@ namespace BTCPayServer.Controllers
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "No payout selected",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Payouts), new
{
storeId = storeId,
pullPaymentId = vm.PullPaymentId,
paymentMethodId = paymentMethodId.ToString()
Message = "No payout selected", Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Payouts),
new
{
storeId = storeId,
pullPaymentId = vm.PullPaymentId,
paymentMethodId = paymentMethodId.ToString()
});
}
var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1);
if (handler != null)
{
@ -321,124 +324,127 @@ namespace BTCPayServer.Controllers
TempData.SetStatusMessageModel(result);
}
}
switch (command)
{
case "approve-pay":
case "approve":
{
await using var ctx = this._dbContextFactory.CreateContext();
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var payouts = await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken);
{
await using var ctx = this._dbContextFactory.CreateContext();
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var payouts =
await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken);
var failed = false;
for (int i = 0; i < payouts.Count; i++)
var failed = false;
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];
if (payout.State != PayoutState.AwaitingApproval)
continue;
var rateResult = await _pullPaymentService.GetRate(payout, null, cancellationToken);
if (rateResult.BidAsk == null)
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = $"Rate unavailable: {rateResult.EvaluatedRule}",
Severity = StatusMessageModel.StatusSeverity.Error
});
failed = true;
break;
}
var approveResult = await _pullPaymentService.Approve(new HostedServices.PullPaymentHostedService.PayoutApproval()
Message = $"Rate unavailable: {rateResult.EvaluatedRule}",
Severity = StatusMessageModel.StatusSeverity.Error
});
failed = true;
break;
}
var approveResult = await _pullPaymentService.Approve(
new HostedServices.PullPaymentHostedService.PayoutApproval()
{
PayoutId = payout.Id,
Revision = payout.GetBlob(_jsonSerializerSettings).Revision,
Rate = rateResult.BidAsk.Ask
});
if (approveResult != PullPaymentHostedService.PayoutApproval.Result.Ok)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = PullPaymentHostedService.PayoutApproval.GetErrorMessage(approveResult),
Severity = StatusMessageModel.StatusSeverity.Error
});
failed = true;
break;
}
}
if (failed)
if (approveResult != PullPaymentHostedService.PayoutApproval.Result.Ok)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = PullPaymentHostedService.PayoutApproval.GetErrorMessage(approveResult),
Severity = StatusMessageModel.StatusSeverity.Error
});
failed = true;
break;
}
if (command == "approve-pay")
{
goto case "pay";
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts approved",
Severity = StatusMessageModel.StatusSeverity.Success
});
}
if (failed)
{
break;
}
if (command == "approve-pay")
{
goto case "pay";
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts approved", Severity = StatusMessageModel.StatusSeverity.Success
});
break;
}
case "pay":
{
if (handler is { })
return await handler?.InitiatePayment(paymentMethodId, payoutIds);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
if (handler is { })
return await handler?.InitiatePayment(paymentMethodId, payoutIds);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Paying via this payment method is not supported",
Severity = StatusMessageModel.StatusSeverity.Error
});
break;
}
Message = "Paying via this payment method is not supported",
Severity = StatusMessageModel.StatusSeverity.Error
});
break;
}
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();
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var payouts = await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken);
for (int i = 0; i < payouts.Count; i++)
{
var payout = payouts[i];
if (payout.State != PayoutState.AwaitingPayment)
continue;
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)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = PayoutPaidRequest.GetErrorMessage(result),
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Payouts), new
return RedirectToAction(nameof(Payouts),
new
{
storeId = storeId,
pullPaymentId = vm.PullPaymentId,
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":
await _pullPaymentService.Cancel(
new PullPaymentHostedService.CancelRequest(payoutIds));
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts archived",
Severity = StatusMessageModel.StatusSeverity.Success
Message = "Payouts archived", Severity = StatusMessageModel.StatusSeverity.Success
});
break;
}
@ -458,16 +464,17 @@ namespace BTCPayServer.Controllers
{
var payouts = (await ctx.Payouts
.Include(p => p.PullPaymentData)
.Include(p => p.PullPaymentData.StoreData)
.Include(p => p.StoreData)
.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))
.Where(p => p.GetPaymentMethodId() == paymentMethodId)
.ToList();
return payouts;
}
[HttpGet("payouts")]
[HttpGet("stores/{storeId}/pull-payments/{pullPaymentId}/payouts")]
[HttpGet("stores/{storeId}/payouts")]
public async Task<IActionResult> Payouts(
string storeId, string pullPaymentId, string paymentMethodId, PayoutState payoutState,
int skip = 0, int count = 50)
@ -494,7 +501,8 @@ namespace BTCPayServer.Controllers
});
vm.Payouts = new List<PayoutsModel.PayoutModel>();
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)
{
payoutRequest = payoutRequest.Where(p => p.PullPaymentDataId == vm.PullPaymentId);
@ -507,6 +515,9 @@ namespace BTCPayServer.Controllers
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)
.Select(e => new { e.Key, Count = e.Count() })
.ToDictionary(arg => arg.Key, arg => arg.Count);
@ -525,22 +536,18 @@ namespace BTCPayServer.Controllers
payoutRequest = payoutRequest.Skip(vm.Skip).Take(vm.Count);
var payouts = await payoutRequest.OrderByDescending(p => p.Date)
.Select(o => new
{
Payout = o,
PullPayment = o.PullPaymentData
}).ToListAsync();
.Select(o => new { Payout = o, PullPayment = o.PullPaymentData }).ToListAsync();
foreach (var item in payouts)
{
var ppBlob = item.PullPayment.GetBlob();
var ppBlob = item.PullPayment?.GetBlob();
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
var m = new PayoutsModel.PayoutModel
{
PullPaymentId = item.PullPayment.Id,
PullPaymentName = ppBlob.Name ?? item.PullPayment.Id,
PullPaymentId = item.PullPayment?.Id,
PullPaymentName = ppBlob?.Name ?? item.PullPayment?.Id,
Date = item.Payout.Date,
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
};
var handler = _payoutHandlers

View file

@ -13,7 +13,6 @@ using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.PayJoin;
@ -28,6 +27,8 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using NBitcoin;
using BTCPayServer.Client.Models;
using BTCPayServer.Logging;
using NBXplorer;
using NBXplorer.Client;
using NBXplorer.DerivationStrategy;
@ -50,7 +51,6 @@ namespace BTCPayServer.Controllers
public RateFetcher RateFetcher { get; }
private readonly UserManager<ApplicationUser> _userManager;
private readonly JsonSerializerSettings _serializerSettings;
private readonly NBXplorerDashboard _dashboard;
private readonly IAuthorizationService _authorizationService;
private readonly IFeeProviderFactory _feeRateProvider;
@ -61,6 +61,7 @@ namespace BTCPayServer.Controllers
private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly PayjoinClient _payjoinClient;
private readonly LabelFactory _labelFactory;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly PullPaymentHostedService _pullPaymentService;
@ -69,6 +70,7 @@ namespace BTCPayServer.Controllers
private readonly WalletHistogramService _walletHistogramService;
readonly CurrencyNameTable _currencyTable;
public UIWalletsController(StoreRepository repo,
WalletRepository walletRepository,
CurrencyNameTable currencyTable,
@ -93,7 +95,8 @@ namespace BTCPayServer.Controllers
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
PullPaymentHostedService pullPaymentService,
IEnumerable<IPayoutHandler> payoutHandlers,
IServiceProvider serviceProvider)
IServiceProvider serviceProvider,
PullPaymentHostedService pullPaymentHostedService)
{
_currencyTable = currencyTable;
Repository = repo;
@ -102,7 +105,6 @@ namespace BTCPayServer.Controllers
_authorizationService = authorizationService;
NetworkProvider = networkProvider;
_userManager = userManager;
_serializerSettings = mvcJsonOptions.SerializerSettings;
_dashboard = dashboard;
ExplorerClientProvider = explorerProvider;
_feeRateProvider = feeRateProvider;
@ -113,6 +115,7 @@ namespace BTCPayServer.Controllers
_broadcaster = broadcaster;
_payjoinClient = payjoinClient;
_labelFactory = labelFactory;
_pullPaymentHostedService = pullPaymentHostedService;
_dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings;
_pullPaymentService = pullPaymentService;
@ -121,7 +124,7 @@ namespace BTCPayServer.Controllers
_connectionFactory = connectionFactory;
_walletHistogramService = walletHistogramService;
}
[HttpPost]
[Route("{walletId}")]
public async Task<IActionResult> ModifyTransaction(
@ -130,10 +133,10 @@ namespace BTCPayServer.Controllers
// does not work
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string transactionId,
string addlabel = null,
string addlabelclick = null,
string addcomment = null,
string removelabel = null)
string addlabel = null,
string addlabelclick = null,
string addcomment = null,
string removelabel = null)
{
addlabel = addlabel ?? addlabelclick;
// Hack necessary when the user enter a empty comment and submit.
@ -184,7 +187,8 @@ namespace BTCPayServer.Controllers
{
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)
{
walletBlobInfo.LabelColors.Remove(removelabel);
@ -219,18 +223,18 @@ namespace BTCPayServer.Controllers
var stores = await Repository.GetStoresByUserId(GetUserId());
var onChainWallets = stores
.SelectMany(s => s.GetSupportedPaymentMethods(NetworkProvider)
.OfType<DerivationSchemeSettings>()
.Select(d => ((Wallet: _walletProvider.GetWallet(d.Network),
DerivationStrategy: d.AccountDerivation,
Network: d.Network)))
.Where(_ => _.Wallet != null && _.Network.WalletSupported)
.Select(_ => (Wallet: _.Wallet,
Store: s,
Balance: GetBalanceString(_.Wallet, _.DerivationStrategy),
DerivationStrategy: _.DerivationStrategy,
Network: _.Network)))
.ToList();
.SelectMany(s => s.GetSupportedPaymentMethods(NetworkProvider)
.OfType<DerivationSchemeSettings>()
.Select(d => ((Wallet: _walletProvider.GetWallet(d.Network),
DerivationStrategy: d.AccountDerivation,
Network: d.Network)))
.Where(_ => _.Wallet != null && _.Network.WalletSupported)
.Select(_ => (Wallet: _.Wallet,
Store: s,
Balance: GetBalanceString(_.Wallet, _.DerivationStrategy),
DerivationStrategy: _.DerivationStrategy,
Network: _.Network)))
.ToList();
foreach (var wallet in onChainWallets)
{
@ -242,6 +246,7 @@ namespace BTCPayServer.Controllers
{
walletVm.Balance = "";
}
walletVm.CryptoCode = wallet.Network.CryptoCode;
walletVm.StoreId = wallet.Store.Id;
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 walletBlob = await walletBlobAsync;
var walletTransactionsInfo = await walletTransactionsInfoAsync;
var model = new ListTransactionsViewModel
{
Skip = skip,
Count = count,
Total = 0
};
var model = new ListTransactionsViewModel { Skip = skip, Count = count, Total = 0 };
if (labelFilter != null)
{
model.PaginationQuery = new Dictionary<string, object>
{
{"labelFilter", labelFilter}
};
model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
}
if (transactions == null)
{
@ -302,7 +299,7 @@ namespace BTCPayServer.Controllers
else
{
foreach (var tx in transactions.UnconfirmedTransactions.Transactions
.Concat(transactions.ConfirmedTransactions.Transactions).ToArray())
.Concat(transactions.ConfirmedTransactions.Transactions).ToArray())
{
var vm = new ListTransactionsViewModel.TransactionViewModel();
vm.Id = tx.TransactionId.ToString();
@ -327,7 +324,8 @@ namespace BTCPayServer.Controllers
}
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;
@ -354,8 +352,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{walletId}/receive")]
public IActionResult WalletReceive([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId)
public IActionResult WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId)
{
if (walletId?.StoreId == null)
return NotFound();
@ -371,7 +368,9 @@ namespace BTCPayServer.Controllers
var bip21 = network.GenerateBIP21(address?.ToString(), null);
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()
{
@ -384,8 +383,8 @@ namespace BTCPayServer.Controllers
[HttpPost]
[Route("{walletId}/receive")]
public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletReceiveViewModel viewModel, string command)
public async Task<IActionResult> WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
WalletReceiveViewModel viewModel, string command)
{
if (walletId?.StoreId == null)
return NotFound();
@ -479,17 +478,13 @@ namespace BTCPayServer.Controllers
rateRules.Spread = 0.0m;
var currencyPair = new Rating.CurrencyPair(paymentMethod.PaymentId.CryptoCode, storeData.DefaultCurrency);
double.TryParse(defaultAmount, out var amount);
var model = new WalletSendModel()
{
CryptoCode = walletId.CryptoCode
};
var model = new WalletSendModel() { CryptoCode = walletId.CryptoCode };
if (bip21?.Any() is true)
{
foreach (var link in bip21)
{
if (!string.IsNullOrEmpty(link))
{
LoadFromBIP21(model, link, network);
}
}
@ -517,7 +512,10 @@ namespace BTCPayServer.Controllers
{
var result = await feeProvider.GetFeeRateAsync(
(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)
{
@ -547,37 +545,46 @@ namespace BTCPayServer.Controllers
try
{
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)
{
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;
}
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; }
}
return View(model);
}
private async Task<string> GetSeed(WalletId walletId, BTCPayNetwork network)
{
return await CanUseHotWallet() &&
GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s &&
s.IsHotWallet &&
ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client &&
await client.GetMetadataAsync<string>(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is string seed &&
!string.IsNullOrEmpty(seed) ? seed : null;
GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s &&
s.IsHotWallet &&
ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client &&
await client.GetMetadataAsync<string>(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is
string seed &&
!string.IsNullOrEmpty(seed)
? seed
: null;
}
[HttpPost("{walletId}/send")]
public async Task<IActionResult> WalletSend(
[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)
return NotFound();
@ -606,7 +613,8 @@ namespace BTCPayServer.Controllers
var walletBlobAsync = await WalletRepository.GetWalletInfo(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 =>
{
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info);
@ -615,8 +623,12 @@ namespace BTCPayServer.Controllers
Outpoint = coin.OutPoint.ToString(),
Amount = coin.Value.GetValue(network),
Comment = info?.Comment,
Labels = info == null ? null : _labelFactory.ColorizeTransactionLabels(walletBlobAsync, info, Request),
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, coin.OutPoint.Hash.ToString()),
Labels =
info == null
? null
: _labelFactory.ColorizeTransactionLabels(walletBlobAsync, info, Request),
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink,
coin.OutPoint.Hash.ToString()),
Confirmations = coin.Confirmations
};
}).ToArray();
@ -645,7 +657,9 @@ namespace BTCPayServer.Controllers
if (command.StartsWith("remove-output", StringComparison.InvariantCultureIgnoreCase))
{
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);
return View(vm);
}
@ -657,6 +671,8 @@ namespace BTCPayServer.Controllers
return View(vm);
}
var bypassBalanceChecks = command == "schedule";
var subtractFeesOutputsCount = new List<int>();
var substractFees = vm.Outputs.Any(o => o.SubtractFeesFromOutput);
for (var i = 0; i < vm.Outputs.Count; i++)
@ -669,17 +685,20 @@ namespace BTCPayServer.Controllers
transactionOutput.DestinationAddress = transactionOutput.DestinationAddress?.Trim() ?? string.Empty;
var inputName =
string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].", i.ToString(CultureInfo.InvariantCulture)) +
nameof(transactionOutput.DestinationAddress);
string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].",
i.ToString(CultureInfo.InvariantCulture)) +
nameof(transactionOutput.DestinationAddress);
try
{
var address = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork);
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))
{
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");
}
if (transactionOutput.Amount.HasValue)
if (!bypassBalanceChecks && transactionOutput.Amount.HasValue)
{
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,
"You can only subtract fees from one output", this);
foreach (var subtractFeesOutput in subtractFeesOutputsCount)
{
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)
return View(vm);
DerivationSchemeSettings derivationScheme = GetDerivationSchemeSettings(walletId);
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
{
psbtResponse = await CreatePSBT(network, derivationScheme, vm, cancellation);
@ -763,23 +861,17 @@ namespace BTCPayServer.Controllers
switch (command)
{
case "sign":
return await WalletSign(walletId, new WalletPSBTViewModel()
{
SigningContext = signingContext
});
return await WalletSign(walletId, new WalletPSBTViewModel() { SigningContext = signingContext });
case "analyze-psbt":
var name =
$"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt";
return RedirectToWalletPSBT(new WalletPSBTViewModel
{
PSBT = psbt.ToBase64(),
FileName = name
});
return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), FileName = name });
default:
return View(vm);
}
}
private void LoadFromBIP21(WalletSendModel vm, string bip21, BTCPayNetwork network)
{
vm.Outputs ??= new List<WalletSendModel.TransactionOutput>();
@ -791,7 +883,10 @@ namespace BTCPayServer.Controllers
{
Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC),
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))
{
@ -811,9 +906,9 @@ namespace BTCPayServer.Controllers
try
{
vm.Outputs.Add(new WalletSendModel.TransactionOutput()
{
DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString()
}
{
DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString()
}
);
}
catch
@ -831,23 +926,22 @@ namespace BTCPayServer.Controllers
private IActionResult ViewVault(WalletId walletId, SigningContextModel signingContext)
{
return View(nameof(WalletSendVault), new WalletSendVaultModel()
{
SigningContext = signingContext,
WalletId = walletId.ToString(),
WebsocketPath = this.Url.Action(nameof(UIVaultController.VaultBridgeConnection), "UIVault", new { walletId = walletId.ToString() })
});
return View(nameof(WalletSendVault),
new WalletSendVaultModel()
{
SigningContext = signingContext,
WalletId = walletId.ToString(),
WebsocketPath = this.Url.Action(nameof(UIVaultController.VaultBridgeConnection), "UIVault",
new { walletId = walletId.ToString() })
});
}
[HttpPost]
[Route("{walletId}/vault")]
public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, WalletSendVaultModel model)
public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
WalletSendVaultModel model)
{
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel()
{
SigningContext = model.SigningContext
});
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel() { SigningContext = model.SigningContext });
}
private IActionResult RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm)
@ -886,7 +980,8 @@ namespace BTCPayServer.Controllers
redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT);
redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT);
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);
}
@ -897,29 +992,21 @@ namespace BTCPayServer.Controllers
AspController = "UIWallets",
AspAction = nameof(WalletPSBT),
RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } },
FormParameters =
{
{ "psbt", vm.PSBT },
{ "fileName", vm.FileName },
{ "command", "decode" },
}
FormParameters = { { "psbt", vm.PSBT }, { "fileName", vm.FileName }, { "command", "decode" }, }
};
return View("PostRedirect", redirectVm);
}
[HttpGet("{walletId}/psbt/seed")]
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, SigningContextModel signingContext)
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
SigningContextModel signingContext)
{
return View(nameof(SignWithSeed), new SignWithSeedViewModel
{
SigningContext = signingContext
});
return View(nameof(SignWithSeed), new SignWithSeedViewModel { SigningContext = signingContext });
}
[HttpPost("{walletId}/psbt/seed")]
public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, SignWithSeedViewModel viewModel)
public async Task<IActionResult> SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
SignWithSeedViewModel viewModel)
{
if (!ModelState.IsValid)
{
@ -957,7 +1044,8 @@ namespace BTCPayServer.Controllers
RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath();
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);
}
// 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
{
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);
}
@ -979,17 +1068,15 @@ namespace BTCPayServer.Controllers
var changed = psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath));
if (!changed)
{
var update = new UpdatePSBTRequest()
{
PSBT = psbt,
DerivationScheme = settings.AccountDerivation
};
var update = new UpdatePSBTRequest() { PSBT = psbt, DerivationScheme = settings.AccountDerivation };
update.RebaseKeyPaths = settings.GetPSBTRebaseKeyRules().ToList();
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)
{
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);
}
}
@ -1021,8 +1108,10 @@ namespace BTCPayServer.Controllers
var vm = new RescanWalletModel();
vm.IsFullySync = _dashboard.IsFullySynched(walletId.CryptoCode, out var unused);
vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded;
vm.IsSupportedByCurrency = _dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true;
vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings))
.Succeeded;
vm.IsSupportedByCurrency =
_dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true;
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation);
if (scanProgress != null)
@ -1040,12 +1129,15 @@ namespace BTCPayServer.Controllers
vm.RemainingTime = TimeSpan.FromSeconds(scanProgress.Progress.RemainingSeconds).PrettyPrint();
}
}
if (scanProgress.Status == ScanUTXOStatus.Complete)
{
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);
}
@ -1063,12 +1155,13 @@ namespace BTCPayServer.Controllers
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
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")
{
}
return RedirectToAction();
}
@ -1077,7 +1170,8 @@ namespace BTCPayServer.Controllers
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));
try
@ -1117,60 +1211,68 @@ namespace BTCPayServer.Controllers
switch (command)
{
case "cpfp":
{
selectedTransactions ??= Array.Empty<string>();
if (selectedTransactions.Length == 0)
{
selectedTransactions ??= Array.Empty<string>();
if (selectedTransactions.Length == 0)
{
TempData[WellKnownTempData.ErrorMessage] = $"No transaction selected";
return RedirectToAction(nameof(WalletTransactions), new { walletId });
}
var parameters = new MultiValueDictionary<string, string>();
parameters.Add("walletId", walletId.ToString());
int i = 0;
foreach (var tx in selectedTransactions)
{
parameters.Add($"transactionHashes[{i}]", tx);
i++;
}
parameters.Add("returnUrl", Url.Action(nameof(WalletTransactions), new { walletId }));
return View("PostRedirect", new PostRedirectViewModel
TempData[WellKnownTempData.ErrorMessage] = $"No transaction selected";
return RedirectToAction(nameof(WalletTransactions), new { walletId });
}
var parameters = new MultiValueDictionary<string, string>();
parameters.Add("walletId", walletId.ToString());
int i = 0;
foreach (var tx in selectedTransactions)
{
parameters.Add($"transactionHashes[{i}]", tx);
i++;
}
parameters.Add("returnUrl", Url.Action(nameof(WalletTransactions), new { walletId }));
return View("PostRedirect",
new PostRedirectViewModel
{
AspController = "UIWallets",
AspAction = nameof(UIWalletsController.WalletCPFP),
RouteParameters = { { "walletId", walletId.ToString() } },
FormParameters = parameters
});
}
}
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);
if (result.TotalPruned == 0)
{
TempData[WellKnownTempData.SuccessMessage] = "The wallet is already pruned";
}
else
{
TempData[WellKnownTempData.SuccessMessage] =
$"The wallet has been successfully pruned ({result.TotalPruned} transactions have been removed from the history)";
}
TempData[WellKnownTempData.SuccessMessage] = "The wallet is already pruned";
}
else
{
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):
{
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) &&
v < new Version(2, 2, 4))
{
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 });
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 });
}
default:
return NotFound();
}
@ -1199,7 +1301,6 @@ namespace BTCPayServer.Controllers
public class SendToAddressResult
{
[JsonProperty("psbt")]
public string PSBT { get; set; }
[JsonProperty("psbt")] public string PSBT { get; set; }
}
}

View file

@ -4,9 +4,10 @@ namespace BTCPayServer.Data
{
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;
if (entity.Metadata is null)
{

View file

@ -6,7 +6,7 @@ namespace BTCPayServer.Data
{
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 cryptoCode = "BTC";

View file

@ -5,6 +5,7 @@ using System.Text;
using System.Threading.Tasks;
using BTCPayServer;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
@ -243,8 +244,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
&& data.State == PayoutState.AwaitingPayment)
.ToListAsync();
var pullPaymentIds = payouts.Select(data => data.PullPaymentDataId).Distinct().ToArray();
var storeId = payouts.First().PullPaymentData.StoreId;
var pullPaymentIds = payouts.Select(data => data.PullPaymentDataId).Distinct().Where(s => s!= null).ToArray();
var storeId = payouts.First().StoreDataId;
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
List<string> bip21 = new List<string>();
foreach (var payout in payouts)
@ -261,10 +262,14 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
{
case UriClaimDestination uriClaimDestination:
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;
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;
}
}
@ -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;
}
@ -366,8 +371,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
await using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts
.Include(o => o.StoreData)
.Include(o => o.PullPaymentData)
.ThenInclude(o => o.StoreData)
.Where(p => p.State == PayoutState.AwaitingPayment)
.Where(p => p.PaymentMethodId == paymentMethodId.ToString())
#pragma warning disable CA1307 // Specify StringComparison
@ -386,7 +391,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
BTCPayServer.Extensions.RoundUp(payoutBlob.CryptoAmount.Value, network.Divisibility))
return;
var derivationSchemeSettings = payout.PullPaymentData.StoreData
var derivationSchemeSettings = payout.StoreData
.GetDerivationSchemeSettings(_btcPayNetworkProvider, newTransaction.CryptoCode).AccountDerivation;
var storeWalletMatched = (await _explorerClientProvider.GetExplorerClient(newTransaction.CryptoCode)
@ -403,19 +408,19 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
if (isInternal)
{
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,
newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
UpdateTransactionLabel.PayoutTemplate(payout.Id, payout.PullPaymentDataId, walletId.ToString())));
}
else
{
await _notificationSender.SendNotification(new StoreScope(payout.PullPaymentData.StoreId),
await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId),
new ExternalPayoutTransactionNotification()
{
PaymentMethod = payout.PaymentMethodId,
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)));
// 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.Collections.Generic;
using System.Threading.Tasks;
@ -15,8 +16,8 @@ public interface IPayoutHandler
public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination);
//Allows payout handler to parse payout destinations on its own
public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination);
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 (bool valid, string? error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob? pullPaymentBlob);
public async Task<(IClaimDestination? destination, string? error)> ParseAndValidateClaimDestination(PaymentMethodId paymentMethodId, string destination, PullPaymentBlob? pullPaymentBlob)
{
var res = await ParseClaimDestination(paymentMethodId, destination);
if (res.destination is null)

View file

@ -108,7 +108,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
if (claimDestination is not BoltInvoiceClaimDestination bolt)
return (true, null);
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,
$"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)
query = query.Include(p => p.PullPaymentData);
if (includeStore)
query = query.Include(p => p.PullPaymentData.StoreData);
query = query.Include(p => p.StoreData);
var payout = await query.Where(p => p.Id == payoutId &&
p.PullPaymentData.StoreId == storeId).FirstOrDefaultAsync();
p.StoreDataId == storeId).FirstOrDefaultAsync();
if (payout is null)
return null;
return payout;

View file

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

View file

@ -1,5 +1,6 @@
using BTCPayServer;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Http;
@ -63,13 +64,13 @@ namespace Microsoft.AspNetCore.Mvc
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);
return urlHelper.GetUriByAction(
action: nameof(UIStorePullPaymentsController.Payouts),
controller: "UIStorePullPayments",
values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId },
values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId, payoutState },
scheme, host, pathbase);
}
}

View file

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

View file

@ -25,6 +25,12 @@ namespace BTCPayServer.HostedServices
_EventAggregator = eventAggregator;
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>();
public async Task ProcessEvents(CancellationToken cancellationToken)

View file

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

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -23,9 +24,14 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer;
using Newtonsoft.Json.Linq;
using PeterO.Cbor;
using PayoutData = BTCPayServer.Data.PayoutData;
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Hosting
{
@ -41,6 +47,7 @@ namespace BTCPayServer.Hosting
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly LightningAddressService _lightningAddressService;
private readonly ILogger<MigrationStartupTask> _logger;
private readonly UserManager<ApplicationUser> _userManager;
public IOptions<LightningNetworkOptions> LightningOptions { get; }
@ -56,9 +63,8 @@ namespace BTCPayServer.Hosting
IEnumerable<IPayoutHandler> payoutHandlers,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
LightningAddressService lightningAddressService,
Logs logs)
ILogger<MigrationStartupTask> logger)
{
Logs = logs;
_DBContextFactory = dbContextFactory;
_StoreRepository = storeRepository;
_NetworkProvider = networkProvider;
@ -67,6 +73,7 @@ namespace BTCPayServer.Hosting
_payoutHandlers = payoutHandlers;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_lightningAddressService = lightningAddressService;
_logger = logger;
_userManager = userManager;
LightningOptions = lightningOptions;
}
@ -182,12 +189,17 @@ namespace BTCPayServer.Hosting
{
await MigrateLighingAddressDatabaseMigration();
settings.LighingAddressDatabaseMigration = true;
}
if (!settings.AddStoreToPayout)
{
await MigrateAddStoreToPayout();
settings.AddStoreToPayout = true;
await _Settings.UpdateSetting(settings);
}
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex, "Error on the MigrationStartupTask");
_logger.LogError(ex, "Error on the MigrationStartupTask");
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()
{
await using var ctx = _DBContextFactory.CreateContext();

View file

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

View file

@ -18,8 +18,7 @@ namespace BTCPayServer.Models.WalletViewModels
public TimeSpan Target { 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
{
[Display(Name = "Destination Address")]
@ -33,6 +32,8 @@ namespace BTCPayServer.Models.WalletViewModels
[Display(Name = "Subtract fees from amount")]
public bool SubtractFeesFromOutput { get; set; }
public string PayoutId { get; set; }
}
public decimal CurrentBalance { 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 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>>();
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();
#pragma warning restore CA1416 // Validate platform compatibility
process.WaitForExit();
}

View file

@ -770,18 +770,21 @@ namespace BTCPayServer.Services.Invoices
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));
}
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)
{
if (network == null)
{
return JsonConvert.SerializeObject(data, DefaultSerializerSettings);
}
return network.ToString(data);
return network == null ? JsonConvert.SerializeObject(data, DefaultSerializerSettings) : network.ToString(data);
}
}

View file

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

View file

@ -30,5 +30,6 @@ namespace BTCPayServer.Services
public bool AddInitialUserBlob { get; set; }
public bool LighingAddressSettingRename { 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.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
using BTCPayServer.Models.NotificationViewModels;
@ -32,7 +34,12 @@ namespace BTCPayServer.Services.Notifications.Blobs
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),
"UIStorePullPayments",
new { storeId = notification.StoreId, paymentMethodId = notification.PaymentMethod }, _options.RootPath);
@ -45,5 +52,6 @@ namespace BTCPayServer.Services.Notifications.Blobs
public string Currency { get; set; }
public override string Identifier => 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);
_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.Abstractions.Extensions
@using BTCPayServer.Client
@using BTCPayServer.PayoutProcessors
@model BTCPayServer.Models.WalletViewModels.PayoutsModel
@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);
Model.PaginationQuery ??= new Dictionary<string, object>();
Model.PaginationQuery.Add("pullPaymentId", Model.PullPaymentId);
@ -19,7 +23,6 @@
if (payoutHandler is null)
return;
stateActions.AddRange(payoutHandler.GetPayoutSpecificActions().Where(pair => pair.Key == Model.PayoutState).SelectMany(pair => pair.Value));
}
switch (Model.PayoutState)
{
@ -47,13 +50,28 @@
</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>
<form method="post">
<input type="hidden" asp-for="PaymentMethodId" />
<input type="hidden" asp-for="PayoutState" />
<input type="hidden" asp-for="PaymentMethodId"/>
<input type="hidden" asp-for="PayoutState"/>
<div class="d-flex justify-content-between mb-4">
<ul class="nav mb-1">
@foreach (var state in Model.PaymentMethods)
@ -65,7 +83,13 @@
asp-route-pullPaymentId="@Model.PullPaymentId"
class="btcpay-pill @(state.ToString() == Model.PaymentMethodId ? "active" : "")"
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>
}
</ul>
@ -93,70 +117,72 @@
asp-route-payoutState="@state.Key"
asp-route-pullPaymentId="@Model.PullPaymentId"
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>
</nav>
@if (Model.Payouts.Any())
{
<div class="table-responsive">
<table class="table table-hover">
<thead class="thead-inverse">
<tr>
<th permission="@Policies.CanModifyStoreSettings">
<input id="@Model.PayoutState-selectAllCheckbox" type="checkbox" class="form-check-input selectAll" data-payout-state="@Model.PayoutState.ToString()" />
</th>
<th style="min-width: 90px;" class="col-md-auto">
Date
</th>
<th class="text-start">Source</th>
<th class="text-start">Destination</th>
<th class="text-end">Amount</th>
@if (Model.PayoutState != PayoutState.AwaitingApproval)
{
<th class="text-end">Transaction</th>
}
</tr>
<tr>
<th permission="@Policies.CanModifyStoreSettings">
<input id="@Model.PayoutState-selectAllCheckbox" type="checkbox" class="form-check-input selectAll" data-payout-state="@Model.PayoutState.ToString()"/>
</th>
<th style="min-width: 90px;" class="col-md-auto">
Date
</th>
<th class="text-start">Source</th>
<th class="text-start">Destination</th>
<th class="text-end">Amount</th>
@if (Model.PayoutState != PayoutState.AwaitingApproval)
{
<th class="text-end">Transaction</th>
}
</tr>
</thead>
<tbody>
@for (int i = 0; i < Model.Payouts.Count; i++)
{
var pp = Model.Payouts[i];
<tr class="payout">
<td permission="@Policies.CanModifyStoreSettings">
<span>
<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" />
</span>
@for (int i = 0; i < Model.Payouts.Count; i++)
{
var pp = Model.Payouts[i];
<tr class="payout">
<td permission="@Policies.CanModifyStoreSettings">
<span>
<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"/>
</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>
<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>
}
</tr>
}
}
</tr>
}
</tbody>
</table>
</div>
<vc:pager view-model="Model" />
<vc:pager view-model="Model"/>
}
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.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-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"/>
</div>
</nav>

View file

@ -94,6 +94,7 @@
<div class="list-group list-group-flush">
@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="form-group">
<div class="d-flex align-items-center justify-content-between">
@ -231,6 +232,7 @@
</div>
<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="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>
<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>

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",
"required": true,
"description": "The store ID",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
}
],
"get": {
@ -38,7 +40,9 @@
}
}
},
"tags": [ "Pull payments (Management)" ],
"tags": [
"Pull payments (Management)"
],
"security": [
{
"API_Key": [
@ -142,7 +146,9 @@
}
}
},
"tags": [ "Pull payments (Management)" ],
"tags": [
"Pull payments (Management)"
],
"security": [
{
"API_Key": [
@ -160,7 +166,9 @@
"in": "path",
"required": true,
"description": "The ID of the pull payment",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
}
],
"get": {
@ -182,7 +190,9 @@
"description": "Pull payment not found"
}
},
"tags": [ "Pull payments (Public)" ],
"tags": [
"Pull payments (Public)"
],
"security": []
}
},
@ -193,14 +203,18 @@
"in": "path",
"required": true,
"description": "The ID of the store",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
},
{
"name": "pullPaymentId",
"in": "path",
"required": true,
"description": "The ID of the pull payment",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
}
],
"delete": {
@ -215,7 +229,9 @@
"description": "The pull payment has not been found, or does not belong to this store"
}
},
"tags": [ "Pull payments (Management)" ],
"tags": [
"Pull payments (Management)"
],
"security": [
{
"API_Key": [
@ -233,7 +249,9 @@
"in": "path",
"required": true,
"description": "The ID of the pull payment",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
}
],
"get": {
@ -267,7 +285,9 @@
"description": "Pull payment not found"
}
},
"tags": [ "Pull payments (Public)" ],
"tags": [
"Pull payments (Public)"
],
"security": []
},
"post": {
@ -321,7 +341,9 @@
}
}
},
"tags": [ "Pull payments (Public)" ],
"tags": [
"Pull payments (Public)"
],
"security": []
}
},
@ -332,14 +354,18 @@
"in": "path",
"required": true,
"description": "The ID of the pull payment",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
},
{
"name": "payoutId",
"in": "path",
"required": true,
"description": "The ID of the pull payment payout",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
}
],
"get": {
@ -361,7 +387,122 @@
"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": []
}
},
@ -372,18 +513,21 @@
"in": "path",
"required": true,
"description": "The ID of the store",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
},
{
"name": "payoutId",
"in": "path",
"required": true,
"description": "The ID of the payout",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
}
],
"post": {
"summary": "Approve Payout",
"operationId": "PullPayments_ApprovePayout",
"description": "Approve a payout",
@ -443,7 +587,9 @@
"description": "The payout is not found"
}
},
"tags": [ "Pull payments (Management)" ],
"tags": [
"Stores (Payouts)"
],
"security": [
{
"API_Key": [
@ -465,7 +611,9 @@
"description": "The payout is not found"
}
},
"tags": [ "Pull payments (Management)" ],
"tags": [
"Stores (Payouts)"
],
"security": [
{
"API_Key": [
@ -483,24 +631,27 @@
"in": "path",
"required": true,
"description": "The ID of the store",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
},
{
"name": "payoutId",
"in": "path",
"required": true,
"description": "The ID of the payout",
"schema": { "type": "string" }
"schema": {
"type": "string"
}
}
],
"post": {
"summary": "Mark Payout as Paid",
"operationId": "PullPayments_MarkPayoutPaid",
"description": "Mark a payout as paid",
"responses": {
"200": {
"description": "The payout has been marked paid, transitioning to `Completed` state."
"description": "The payout has been marked paid, transitioning to `Completed` state."
},
"422": {
"description": "Unable to validate the request",
@ -526,7 +677,9 @@
"description": "The payout is not found"
}
},
"tags": [ "Pull payments (Management)" ],
"tags": [
"Stores (Payouts)"
],
"security": [
{
"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": {
"type": "object",
"properties": {