Make Checkout Cheat Mode extensible by plugins (#6543)

This commit is contained in:
Nicolas Dorier 2025-01-10 16:17:55 +09:00 committed by GitHub
parent 0754a809e7
commit 5536935ff8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 224 additions and 197 deletions

View file

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
 <Project Sdk="Microsoft.NET.Sdk.Web">
<Import Project="../Build/Version.csproj" Condition="Exists('../Build/Version.csproj')" />
<Import Project="../Build/Common.csproj" />

View file

@ -1,4 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
@ -9,6 +11,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
@ -25,116 +28,101 @@ namespace BTCPayServer.Controllers
public class MineBlocksRequest
{
public string PaymentMethodId { get; set; }
public int BlockCount { get; set; } = 1;
public string CryptoCode { get; set; } = "BTC";
}
[HttpPost("i/{invoiceId}/test-payment")]
[CheatModeRoute]
public async Task<IActionResult> TestPayment(string invoiceId, FakePaymentRequest request,
[FromServices] Cheater cheater,
[FromServices] LightningClientFactoryService lightningClientFactoryService)
public async Task<IActionResult> TestPayment(string invoiceId, FakePaymentRequest request,
[FromServices] IEnumerable<ICheckoutCheatModeExtension> extensions)
{
var isSats = request.CryptoCode.ToUpper(CultureInfo.InvariantCulture) == "SATS";
var amount = isSats ? new Money(request.Amount, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC) : request.Amount;
var invoice = await _InvoiceRepository.GetInvoice(invoiceId);
var store = await _StoreRepository.FindStore(invoice.StoreId);
var isSats = request.CryptoCode.ToUpper(CultureInfo.InvariantCulture) == "SATS";
var cryptoCode = isSats ? "BTC" : request.CryptoCode;
var amount = new Money(request.Amount, isSats ? MoneyUnit.Satoshi : MoneyUnit.BTC);
var btcpayNetwork = _NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var network = btcpayNetwork.NBitcoinNetwork;
var paymentMethodId = new[] { store.GetDefaultPaymentId() }
.Concat(store.GetEnabledPaymentIds())
.FirstOrDefault(p => p?.ToString() == request.PaymentMethodId);
try
PaymentMethodId paymentMethodId = GetPaymentMethodId(request.PaymentMethodId, store);
var paymentMethod = invoice.GetPaymentPrompt(paymentMethodId);
var extension = GetCheatModeExtension(extensions, paymentMethodId);
var details = _handlers.ParsePaymentPromptDetails(paymentMethod);
if (extension is not null)
{
var paymentMethod = invoice.GetPaymentPrompt(paymentMethodId);
var details = _handlers.ParsePaymentPromptDetails(paymentMethod);
var destination = paymentMethod?.Destination;
if (details is BitcoinPaymentPromptDetails)
try
{
var address = BitcoinAddress.Create(destination, network);
var txid = (await cheater.GetCashCow(cryptoCode).SendToAddressAsync(address, amount)).ToString();
var result = await extension.PayInvoice(new ICheckoutCheatModeExtension.PayInvoiceContext(
invoice,
amount,
store,
paymentMethod,
details));
return Ok(new
{
Txid = txid,
AmountRemaining = paymentMethod.Calculate().Due - amount.ToDecimal(MoneyUnit.BTC),
SuccessMessage = $"Created transaction {txid}"
Txid = result.TransactionId,
AmountRemaining = result.AmountRemaining ?? paymentMethod.Calculate().Due - amount,
SuccessMessage = result.SuccessMessage ?? $"Created transaction {result.TransactionId}"
});
}
else if (details is LigthningPaymentPromptDetails)
catch (Exception e)
{
// requires the channels to be set up using the BTCPayServer.Tests/docker-lightning-channel-setup.sh script
var lnClient = lightningClientFactoryService.Create(
Environment.GetEnvironmentVariable("BTCPAY_BTCEXTERNALLNDREST"),
btcpayNetwork);
var lnAmount = new LightMoney(amount.Satoshi, LightMoneyUnit.Satoshi);
var response = await lnClient.Pay(destination, new PayInvoiceParams { Amount = lnAmount });
if (response.Result == PayResult.Ok)
return BadRequest(new
{
var bolt11 = BOLT11PaymentRequest.Parse(destination, network);
var paymentHash = bolt11.PaymentHash?.ToString();
var paid = response.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC);
return Ok(new
{
Txid = paymentHash,
AmountRemaining = paymentMethod.Calculate().TotalDue - paid,
SuccessMessage = $"Sent payment {paymentHash}"
});
}
return UnprocessableEntity(new
{
ErrorMessage = response.ErrorDetail
});
}
else
{
return UnprocessableEntity(new
{
ErrorMessage = $"Payment method {paymentMethodId} is not supported"
ErrorMessage = e.Message
});
}
}
catch (Exception e)
else
{
return BadRequest(new
{
ErrorMessage = e.Message
});
return BadRequest(new { ErrorMessage = "No ICheatModeExtension registered for this payment method" });
}
}
private static ICheckoutCheatModeExtension GetCheatModeExtension(IEnumerable<ICheckoutCheatModeExtension> extensions, PaymentMethodId paymentMethodId)
{
return extensions.Where(e => e.Handle(paymentMethodId)).FirstOrDefault();
}
private static PaymentMethodId GetPaymentMethodId(string requestPmi, StoreData store)
{
return new[] { store.GetDefaultPaymentId() }
.Concat(store.GetEnabledPaymentIds())
.FirstOrDefault(p => p?.ToString() == requestPmi);
}
[HttpPost("i/{invoiceId}/mine-blocks")]
[CheatModeRoute]
public IActionResult MineBlock(string invoiceId, MineBlocksRequest request, [FromServices] Cheater cheater)
public async Task<IActionResult> MineBlock(string invoiceId, MineBlocksRequest request, [FromServices] IEnumerable<ICheckoutCheatModeExtension> extensions)
{
var blockRewardBitcoinAddress = cheater.GetCashCow(request.CryptoCode).GetNewAddress();
try
{
if (request.BlockCount > 0)
{
cheater.GetCashCow(request.CryptoCode).GenerateToAddress(request.BlockCount, blockRewardBitcoinAddress);
return Ok(new { SuccessMessage = $"Mined {request.BlockCount} block{(request.BlockCount == 1 ? "" : "s")} " });
}
if (request.BlockCount <= 0)
return BadRequest(new { ErrorMessage = "Number of blocks should be at least 1" });
}
catch (Exception e)
var invoice = await _InvoiceRepository.GetInvoice(invoiceId);
var store = await _StoreRepository.FindStore(invoice.StoreId);
var paymentMethodId = GetPaymentMethodId(request.PaymentMethodId, store);
var extension = GetCheatModeExtension(extensions, paymentMethodId);
if (extension != null)
{
return BadRequest(new { ErrorMessage = e.Message });
try
{
var result = await extension.MineBlock(new() { BlockCount = request.BlockCount });
var defaultMessage = $"Mined {request.BlockCount} block{(request.BlockCount == 1 ? "" : "s")} ";
return Ok(new { SuccessMessage = result.SuccessMessage ?? defaultMessage });
}
catch (Exception e)
{
return BadRequest(new { ErrorMessage = e.Message });
}
}
else
return BadRequest(new { ErrorMessage = "No ICheatModeExtension registered for this payment method" });
}
[HttpPost("i/{invoiceId}/expire")]
[CheatModeRoute]
public async Task<IActionResult> Expire(string invoiceId, int seconds, [FromServices] Cheater cheater)
public async Task<IActionResult> Expire(string invoiceId, int seconds)
{
try
{
await cheater.UpdateInvoiceExpiry(invoiceId, TimeSpan.FromSeconds(seconds));
await _InvoiceRepository.UpdateInvoiceExpiry(invoiceId, TimeSpan.FromSeconds(seconds));
return Ok(new { SuccessMessage = $"Invoice set to expire in {seconds} seconds." });
}
catch (Exception e)

View file

@ -642,6 +642,9 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddSingleton<IPaymentMethodBitpayAPIExtension>(provider =>
(IPaymentMethodBitpayAPIExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinPaymentMethodBitpayAPIExtension), new object[] { pmi }));
services.AddSingleton<ICheckoutCheatModeExtension>(provider =>
(ICheckoutCheatModeExtension)ActivatorUtilities.CreateInstance(provider, typeof(BitcoinCheckoutCheatModeExtension), new object[] { network }));
if (!network.ReadonlyWallet && network.WalletSupported)
{
var payoutMethodId = PayoutTypes.CHAIN.GetPayoutMethodId(network.CryptoCode);
@ -669,6 +672,8 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
var payoutMethodId = PayoutTypes.LN.GetPayoutMethodId(network.CryptoCode);
services.AddSingleton<IPayoutHandler>(provider =>
(IPayoutHandler)ActivatorUtilities.CreateInstance(provider, typeof(LightningLikePayoutHandler), new object[] { payoutMethodId, network }));
services.AddSingleton<ICheckoutCheatModeExtension>(provider =>
(ICheckoutCheatModeExtension)ActivatorUtilities.CreateInstance(provider, typeof(LightningCheckoutCheatModeExtension), new object[] { network }));
}
// LNURL
{

View file

@ -0,0 +1,41 @@
using System.Threading.Tasks;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services;
using NBitcoin;
using ExchangeSharp.BinanceGroup;
namespace BTCPayServer.Payments.Bitcoin
{
public class BitcoinCheckoutCheatModeExtension : ICheckoutCheatModeExtension
{
private readonly Cheater _cheater;
public BitcoinCheckoutCheatModeExtension(Cheater cheater, BTCPayNetwork network)
{
_cheater = cheater;
Network = network;
pmi = PaymentTypes.CHAIN.GetPaymentMethodId(Network.CryptoCode);
}
public BTCPayNetwork Network { get; }
private PaymentMethodId pmi;
public bool Handle(PaymentMethodId paymentMethodId)
=> paymentMethodId == pmi;
public async Task<ICheckoutCheatModeExtension.MineBlockResult> MineBlock(ICheckoutCheatModeExtension.MineBlockContext mineBlockContext)
{
var cow = _cheater.GetCashCow(Network.CryptoCode);
var blockRewardBitcoinAddress = await cow.GetNewAddressAsync();
await cow.GenerateToAddressAsync(mineBlockContext.BlockCount, blockRewardBitcoinAddress);
return new ICheckoutCheatModeExtension.MineBlockResult();
}
public async Task<ICheckoutCheatModeExtension.PayInvoiceResult> PayInvoice(ICheckoutCheatModeExtension.PayInvoiceContext payInvoiceContext)
{
var address = BitcoinAddress.Create(payInvoiceContext.PaymentPrompt.Destination, Network.NBitcoinNetwork);
var txid = (await _cheater.GetCashCow(Network.CryptoCode).SendToAddressAsync(address, new Money(payInvoiceContext.Amount, MoneyUnit.BTC))).ToString();
return new ICheckoutCheatModeExtension.PayInvoiceResult(txid);
}
}
}

View file

@ -0,0 +1,57 @@
#nullable enable
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using NBitcoin;
namespace BTCPayServer.Payments
{
public interface ICheckoutCheatModeExtension
{
public class PayInvoiceResult
{
public PayInvoiceResult(string transactionId)
{
TransactionId = transactionId;
}
public string TransactionId { get; set; }
public decimal? AmountRemaining { get; set; }
public string? SuccessMessage { get; set; }
}
public class MineBlockResult
{
public MineBlockResult()
{
}
public MineBlockResult(string? successMessage)
{
SuccessMessage = successMessage;
}
public string? SuccessMessage { get; set; }
}
public class MineBlockContext
{
public int BlockCount { get; set; }
}
public class PayInvoiceContext
{
public PayInvoiceContext(InvoiceEntity invoice, decimal amount, StoreData store, PaymentPrompt paymentMethod, object details)
{
this.Invoice = invoice;
this.Amount = amount;
this.Store = store;
PaymentPrompt = paymentMethod;
PaymentPromptDetails = details;
}
public InvoiceEntity Invoice { get; }
public decimal Amount { get; }
public StoreData Store { get; }
public PaymentPrompt PaymentPrompt { get; }
public object? PaymentPromptDetails { get; }
}
public bool Handle(PaymentMethodId paymentMethodId);
Task<PayInvoiceResult> PayInvoice(PayInvoiceContext payInvoiceContext);
Task<MineBlockResult> MineBlock(MineBlockContext mineBlockContext);
}
}

View file

@ -0,0 +1,57 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using static BTCPayServer.Payments.ICheckoutCheatModeExtension;
namespace BTCPayServer.Payments.Lightning
{
public class LightningCheckoutCheatModeExtension : ICheckoutCheatModeExtension
{
private readonly Cheater _cheater;
private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly PaymentMethodId[] pmis;
public LightningCheckoutCheatModeExtension(Cheater cheater, BTCPayNetwork network, LightningClientFactoryService lightningClientFactoryService)
{
_cheater = cheater;
Network = network;
_lightningClientFactoryService = lightningClientFactoryService;
pmis = [PaymentTypes.LNURL.GetPaymentMethodId(Network.CryptoCode), PaymentTypes.LN.GetPaymentMethodId(Network.CryptoCode)];
}
public BTCPayNetwork Network { get; }
public bool Handle(PaymentMethodId paymentMethodId)
=> pmis.Contains(paymentMethodId);
public Task<ICheckoutCheatModeExtension.MineBlockResult> MineBlock(ICheckoutCheatModeExtension.MineBlockContext mineBlockContext)
=> new Bitcoin.BitcoinCheckoutCheatModeExtension(_cheater, Network).MineBlock(mineBlockContext);
public async Task<ICheckoutCheatModeExtension.PayInvoiceResult> PayInvoice(ICheckoutCheatModeExtension.PayInvoiceContext payInvoiceContext)
{
// requires the channels to be set up using the BTCPayServer.Tests/docker-lightning-channel-setup.sh script
var lnClient = _lightningClientFactoryService.Create(
Environment.GetEnvironmentVariable("BTCPAY_BTCEXTERNALLNDREST"),
Network);
var destination = payInvoiceContext.PaymentPrompt.Destination;
var lnAmount = new LightMoney(payInvoiceContext.Amount, LightMoneyUnit.BTC);
var response = await lnClient.Pay(destination, new PayInvoiceParams { Amount = lnAmount });
if (response.Result == PayResult.Ok)
{
var bolt11 = BOLT11PaymentRequest.Parse(destination, Network.NBitcoinNetwork);
var paymentHash = bolt11.PaymentHash?.ToString();
var paid = response.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC);
return new PayInvoiceResult(paymentHash)
{
SuccessMessage = $"Sent payment {paymentHash}"
};
};
throw new Exception($"Error while paying through lightning: {(Status: response.Result, ErrorDetails: response.ErrorDetail)}");
}
}
}

View file

@ -27,11 +27,6 @@ namespace BTCPayServer.Services
return _prov.GetExplorerClient(cryptoCode)?.RPCClient;
}
public async Task UpdateInvoiceExpiry(string invoiceId, TimeSpan seconds)
{
await _invoiceRepository.UpdateInvoiceExpiry(invoiceId, seconds);
}
async Task IHostedService.StartAsync(CancellationToken cancellationToken)
{
var liquid = _prov.GetNetwork("LBTC");

View file

@ -13,7 +13,7 @@
<label for="test-payment-amount" class="control-label form-label">Fake a {{cryptoCode}} payment for testing</label>
<div class="d-flex gap-2 mb-2">
<div class="input-group">
<input id="test-payment-amount" name="Amount" type="number" :step="isSats ? '1' : '0.00000001'" min="0" class="form-control" placeholder="@StringLocalizer["Amount"]" v-model="amount" :readonly="paying || paymentMethodId === 'BTC-LN'" />
<input id="test-payment-amount" name="Amount" class="form-control" placeholder="@StringLocalizer["Amount"]" v-model="amount" :readonly="paying || paymentMethodId === 'BTC-LN'" />
<div id="test-payment-crypto-code" class="input-group-addon input-group-text" v-text="cryptoCode"></div>
</div>
<button class="btn btn-secondary flex-shrink-0 px-3 w-100px" type="submit" :disabled="paying" id="FakePayment">Pay</button>
@ -22,6 +22,7 @@
<form id="mine-block" :action="`/i/${invoiceId}/mine-blocks`" method="post" v-on:submit.prevent="handleFormSubmit($event, 'mining')" v-if="displayMine">
<label for="BlockCount" class="control-label form-label" text-translate="true">Mine to test processing and settlement</label>
<div class="d-flex gap-2">
<input name="PaymentMethodId" type="hidden" :value="paymentMethodId">
<div class="input-group">
<input id="BlockCount" name="BlockCount" type="number" step="1" min="1" class="form-control" value="1"/>
<div class="input-group-addon input-group-text" text-translate="true">blocks</div>

View file

@ -1,117 +0,0 @@
@model CheckoutModel
<div id="testing">
<hr class="my-3" />
<p class="alert alert-danger" style="display: none; word-break: break-all;"></p>
<p class="alert alert-success" style="display: none; word-break: break-all;"></p>
<form id="test-payment" action="/i/@Model.InvoiceId/test-payment" method="post" class="cheat-mode form-inline my-2">
<input name="CryptoCode" type="hidden" value="@Model.PaymentMethodCurrency">
<div class="form-group mb-1">
<label for="test-payment-amount" class="control-label">{{$t("Fake a @Model.PaymentMethodCurrency payment for testing")}}</label>
<div class="input-group">
<input id="test-payment-amount" name="Amount" type="number" step="0.00000001" min="0" class="form-control" placeholder="@StringLocalizer["Amount"]" value="@Model.Due" />
<div id="test-payment-crypto-code" class="input-group-addon">@Model.PaymentMethodCurrency</div>
</div>
</div>
<button id="FakePayment" class="btn btn-primary" type="submit">{{$t("Fake Payment")}}</button>
<p class="text-muted mt-1">{{$t("This is the same as running bitcoin-cli.sh sendtoaddress xxx")}}</p>
</form>
<form id="expire-invoice" action="/i/@Model.InvoiceId/expire" method="post" class="mb-1">
<button class="btn btn-secondary" type="submit">{{$t("Expire Invoice Now")}}</button>
</form>
@* TODO
<form id="expire-monitoring" action="/i/@Model.InvoiceId/expire-monitoring" method="post" class="mb-1">
<!-- TODO only show when expired -->
<button class="btn btn-secondary" type="submit">{{$t("Expire Monitoring Now")}} (TODO)</button>
</form>
*@
<form id="mine-block" action="/i/@Model.InvoiceId/mine-blocks" method="post" class="cheat-mode form-inline my-2">
<!-- TODO only show when BTC On-chain -->
<div class="form-group mb-1">
<label for="block-count" class="control-label">{{$t("Mine a few blocks to test processing and settlement.")}}</label>
<div class="input-group">
<input id="block-count" name="BlockCount" type="number" step="1" min="1" class="form-control" value="1" />
<div class="input-group-addon">{{$t("Blocks")}}</div>
</div>
</div>
<button class="btn btn-primary" type="submit">{{$t("Mine")}}</button>
</form>
</div>
<script type="text/javascript">
$(document).ready(function() {
const cheatForms = $('form.cheat-mode');
const expireForm = $('form#expire-invoice');
const successAlert = $('#testing p.alert-success');
const errorAlert = $('#testing p.alert-danger');
cheatForms.submit(e => {
e.preventDefault();
const form = $(e.target);
const url = form.attr('action');
const data = form.serialize();
const inputField = form.find('input[type=number]');
const submitButton = form.find('button[type=submit]');
successAlert.hide();
errorAlert.hide();
inputField.prop('disabled', true);
submitButton.prop('disabled', true);
$.post({
url,
data,
success(data) {
const { successMessage, amountRemaining } = data;
successAlert.html(successMessage);
successAlert.show();
if (amountRemaining){
if(amountRemaining > 0) {
inputField.val(amountRemaining);
} else {
form.hide();
expireForm.hide();
}
}
},
error(xhr) {
const { errorMessage } = JSON.parse(xhr.responseText);
errorAlert.html(errorMessage).show();
},
complete() {
inputField.prop('disabled', false);
submitButton.prop('disabled', false);
}
});
});
// Expire invoice form
expireForm.submit(e => {
e.preventDefault();
const form = $(e.target);
const submitButton = form.find('button[type=submit]');
const expireButton = form.find('[type=submit]');
successAlert.hide();
errorAlert.hide();
$.post({
url: expireForm.attr('action'),
success(data) {
const { successMessage } = data;
successAlert.html(successMessage).show();
expireButton.hide();
},
complete() {
submitButton.prop('disabled', false);
},
error(xhr) {
const { errorMessage } = JSON.parse(xhr.responseText);
errorAlert.html(errorMessage).show();
}
});
});
});
</script>