Allow Payjoin for wallet receive addresses (#2425)

* Allow Payjoin for wallet receive addresses

* wip

* show bip21 and additional work

* style better

* add to docs

* pr changes

* remove from state when unreserved
This commit is contained in:
Andrew Camilleri 2021-04-13 05:26:36 +02:00 committed by GitHub
parent b12c4c5fa0
commit 8fd4a816a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 164 additions and 63 deletions

View file

@ -9,5 +9,7 @@ namespace BTCPayServer.Client.Models
public string Address { get; set; }
[JsonConverter(typeof(KeyPathJsonConverter))]
public KeyPath KeyPath { get; set; }
public string PaymentLink { get; set; }
}
}

View file

@ -38,8 +38,8 @@ namespace BTCPayServer
//precision 0: 10 = 0.00000010
//precision 2: 10 = 0.00001000
//precision 8: 10 = 10
var money = new Money(cryptoInfoDue.ToDecimal(MoneyUnit.BTC) / decimal.Parse("1".PadRight(1 + 8 - Divisibility, '0')), MoneyUnit.BTC);
return $"{base.GenerateBIP21(cryptoInfoAddress, money)}&assetid={AssetId}";
var money = cryptoInfoDue is null? null: new Money(cryptoInfoDue.ToDecimal(MoneyUnit.BTC) / decimal.Parse("1".PadRight(1 + 8 - Divisibility, '0')), MoneyUnit.BTC);
return $"{base.GenerateBIP21(cryptoInfoAddress, money)}{(money is null? "?": "&")}assetid={AssetId}";
}
}
}

View file

@ -123,7 +123,7 @@ namespace BTCPayServer
public virtual string GenerateBIP21(string cryptoInfoAddress, Money cryptoInfoDue)
{
return $"{UriScheme}:{cryptoInfoAddress}?amount={cryptoInfoDue.ToString(false, true)}";
return $"{UriScheme}:{cryptoInfoAddress}{(cryptoInfoDue is null? string.Empty: $"?amount={cryptoInfoDue.ToString(false, true)}")}";
}
public virtual List<TransactionInformation> FilterValidTransactions(List<TransactionInformation> transactionInformationSet)

View file

@ -11,6 +11,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments.PayJoin;
using BTCPayServer.Payments.PayJoin.Sender;
using BTCPayServer.Services;
using BTCPayServer.Services.Wallets;
@ -122,9 +123,18 @@ namespace BTCPayServer.Controllers.GreenField
{
return BadRequest();
}
var bip21 = network.GenerateBIP21(kpi.Address.ToString(), null);
var allowedPayjoin = derivationScheme.IsHotWallet && Store.GetStoreBlob().PayJoinEnabled;
if (allowedPayjoin)
{
bip21 +=
$"?{PayjoinClient.BIP21EndpointKey}={Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint", new {cryptoCode}))}";
}
return Ok(new OnChainWalletAddressData()
{
Address = kpi.Address.ToString(),
PaymentLink = bip21,
KeyPath = kpi.KeyPath
});
}
@ -323,7 +333,7 @@ namespace BTCPayServer.Controllers.GreenField
request.AddModelError(transactionRequest => transactionRequest.Destinations[index],
"Amount must be specified or destination must be a BIP21 payment link, and greater than 0", this);
}
if (request.ProceedWithPayjoin && bip21?.UnknowParameters?.ContainsKey("pj") is true)
if (request.ProceedWithPayjoin && bip21?.UnknowParameters?.ContainsKey(PayjoinClient.BIP21EndpointKey) is true)
{
payjoinOutputIndex = index;
}

View file

@ -27,6 +27,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using BTCPayServer.BIP78.Sender;
using BTCPayServer.Payments.PayJoin;
using NBitcoin.DataEncoders;
using NBXplorer;
using NBXplorer.DerivationStrategy;
@ -364,13 +365,20 @@ namespace BTCPayServer.Controllers
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId?.CryptoCode);
if (network == null)
return NotFound();
var address = _walletReceiveService.Get(walletId)?.Address;
var allowedPayjoin = paymentMethod.IsHotWallet && CurrentStore.GetStoreBlob().PayJoinEnabled;
var bip21 = address is null ? null : network.GenerateBIP21(address.ToString(), null);
if (allowedPayjoin)
{
bip21 +=
$"?{PayjoinClient.BIP21EndpointKey}={Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint", new {walletId.CryptoCode}))}";
}
return View(new WalletReceiveViewModel()
{
CryptoCode = walletId.CryptoCode,
Address = address?.ToString(),
CryptoImage = GetImage(paymentMethod.PaymentId, network)
CryptoImage = GetImage(paymentMethod.PaymentId, network),
PaymentLink = bip21
});
}
@ -724,7 +732,7 @@ namespace BTCPayServer.Controllers
{
new WalletSendModel.TransactionOutput()
{
Amount = uriBuilder.Amount.ToDecimal(MoneyUnit.BTC),
Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC),
DestinationAddress = uriBuilder.Address.ToString(),
SubtractFeesFromOutput = false
}
@ -1179,6 +1187,7 @@ namespace BTCPayServer.Controllers
public string CryptoImage { get; set; }
public string CryptoCode { get; set; }
public string Address { get; set; }
public string PaymentLink { get; set; }
}

View file

@ -35,7 +35,7 @@ namespace BTCPayServer
{
var negative = sats < 0;
var amt = sats.ToString(CultureInfo.InvariantCulture)
.Replace("-", "")
.Replace("-", "", StringComparison.InvariantCulture)
.PadLeft(divisibility, '0');
amt = amt.Length == divisibility ? $"0.{amt}" : amt.Insert(amt.Length - divisibility, ".");
return decimal.Parse($"{(negative? "-": string.Empty)}{amt}", CultureInfo.InvariantCulture);

View file

@ -1,36 +1,23 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.NetworkInformation;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Amazon.Runtime.Internal;
using Amazon.S3.Model;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitcoin.Secp256k1;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using Org.BouncyCastle.Ocsp;
using TwentyTwenty.Storage;
namespace BTCPayServer.HostedServices
{

View file

@ -89,6 +89,8 @@ namespace BTCPayServer.Payments.PayJoin
private readonly NBXplorerDashboard _dashboard;
private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly BTCPayServerEnvironment _env;
private readonly WalletReceiveService _walletReceiveService;
private readonly StoreRepository _storeRepository;
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
@ -97,7 +99,9 @@ namespace BTCPayServer.Payments.PayJoin
EventAggregator eventAggregator,
NBXplorerDashboard dashboard,
DelayedTransactionBroadcaster broadcaster,
BTCPayServerEnvironment env)
BTCPayServerEnvironment env,
WalletReceiveService walletReceiveService,
StoreRepository storeRepository)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_invoiceRepository = invoiceRepository;
@ -108,6 +112,8 @@ namespace BTCPayServer.Payments.PayJoin
_dashboard = dashboard;
_broadcaster = broadcaster;
_env = env;
_walletReceiveService = walletReceiveService;
_storeRepository = storeRepository;
}
[HttpPost("")]
@ -238,17 +244,34 @@ namespace BTCPayServer.Payments.PayJoin
KeyPath paymentAddressIndex = null;
InvoiceEntity invoice = null;
DerivationSchemeSettings derivationSchemeSettings = null;
WalletId walletId = null;
foreach (var output in psbt.Outputs)
{
var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] { key })).FirstOrDefault();
if (invoice is null)
continue;
derivationSchemeSettings = invoice.GetSupportedPaymentMethod<DerivationSchemeSettings>(paymentMethodId)
.SingleOrDefault();
var walletReceiveMatch =
_walletReceiveService.GetByScriptPubKey(network.CryptoCode, output.ScriptPubKey);
if (walletReceiveMatch is null)
{
var key = output.ScriptPubKey.Hash + "#" + network.CryptoCode.ToUpperInvariant();
invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] {key})).FirstOrDefault();
if (invoice is null)
continue;
derivationSchemeSettings = invoice
.GetSupportedPaymentMethod<DerivationSchemeSettings>(paymentMethodId)
.SingleOrDefault();
walletId = new WalletId(invoice.StoreId, network.CryptoCode.ToUpperInvariant());
}
else
{
var store = await _storeRepository.FindStore(walletReceiveMatch.Item1.StoreId);
derivationSchemeSettings = store.GetDerivationSchemeSettings(_btcPayNetworkProvider,
walletReceiveMatch.Item1.CryptoCode);
walletId = walletReceiveMatch.Item1;
}
if (derivationSchemeSettings is null)
continue;
var receiverInputsType = derivationSchemeSettings.AccountDerivation.ScriptPubKeyType();
if (receiverInputsType == ScriptPubKeyType.Legacy)
{
@ -259,24 +282,39 @@ namespace BTCPayServer.Payments.PayJoin
{
return CreatePayjoinErrorAndLog(503, PayjoinReceiverWellknownErrors.Unavailable, "We do not have any UTXO available for making a payjoin with the sender's inputs type");
}
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
var paymentDetails =
paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
if (paymentDetails is null || !paymentDetails.PayjoinEnabled || !paymentDetails.Activated)
continue;
if (invoice.GetAllBitcoinPaymentData().Any())
if (walletReceiveMatch is null)
{
ctx.DoNotBroadcast();
return UnprocessableEntity(CreatePayjoinError("already-paid",
$"The invoice this PSBT is paying has already been partially or completely paid"));
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
var paymentDetails =
paymentMethod.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
if (paymentDetails is null || !paymentDetails.PayjoinEnabled)
continue;
paidSomething = true;
due = paymentMethod.Calculate().TotalDue - output.Value;
if (due > Money.Zero)
{
break;
}
paymentAddress = paymentDetails.GetDepositAddress(network.NBitcoinNetwork);
paymentAddressIndex = paymentDetails.KeyPath;
if (invoice.GetAllBitcoinPaymentData().Any())
{
ctx.DoNotBroadcast();
return UnprocessableEntity(CreatePayjoinError("already-paid",
$"The invoice this PSBT is paying has already been partially or completely paid"));
}
}
else
{
paidSomething = true;
due = Money.Zero;
paymentAddress = walletReceiveMatch.Item2.Address;
paymentAddressIndex = walletReceiveMatch.Item2.KeyPath;
}
paidSomething = true;
due = paymentMethod.Calculate().TotalDue - output.Value;
if (due > Money.Zero)
{
break;
}
if (!await _payJoinRepository.TryLockInputs(ctx.OriginalTransaction.Inputs.Select(i => i.PrevOut).ToArray()))
{
@ -299,8 +337,6 @@ namespace BTCPayServer.Payments.PayJoin
}
ctx.LockedUTXOs = selectedUTXOs.Select(u => u.Key).ToArray();
originalPaymentOutput = output;
paymentAddress = paymentDetails.GetDepositAddress(network.NBitcoinNetwork);
paymentAddressIndex = paymentDetails.KeyPath;
break;
}
@ -448,22 +484,27 @@ namespace BTCPayServer.Payments.PayJoin
CoinjoinValue = originalPaymentValue - ourFeeContribution,
ContributedOutPoints = selectedUTXOs.Select(o => o.Key).ToArray()
};
var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true);
if (payment is null)
if (invoice != null)
{
return UnprocessableEntity(CreatePayjoinError("already-paid",
$"The original transaction has already been accounted"));
var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, originalPaymentData, network, true);
if (payment is null)
{
return UnprocessableEntity(CreatePayjoinError("already-paid",
$"The original transaction has already been accounted"));
}
_eventAggregator.Publish(new InvoiceEvent(invoice,InvoiceEvent.ReceivedPayment) { Payment = payment });
}
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(ctx.OriginalTransaction);
_eventAggregator.Publish(new InvoiceEvent(invoice,InvoiceEvent.ReceivedPayment) { Payment = payment });
_eventAggregator.Publish(new UpdateTransactionLabel()
{
WalletId = new WalletId(invoice.StoreId, network.CryptoCode),
WalletId = walletId,
TransactionLabels = selectedUTXOs.GroupBy(pair => pair.Key.Hash).Select(utxo =>
new KeyValuePair<uint256, List<(string color, Label label)>>(utxo.Key,
new List<(string color, Label label)>()
{
UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice.Id)
UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice?.Id)
}))
.ToDictionary(pair => pair.Key, pair => pair.Value)
});

View file

@ -10,7 +10,7 @@ namespace BTCPayServer.Services.Labels
{
static void FixLegacy(JObject jObj, ReferenceLabel refLabel)
{
if (refLabel.Reference is null)
if (refLabel.Reference is null && jObj.ContainsKey("id"))
refLabel.Reference = jObj["id"].Value<string>();
FixLegacy(jObj, (Label)refLabel);
}

View file

@ -52,6 +52,7 @@ namespace BTCPayServer.Services.Wallets
}
await explorerClient.CancelReservationAsync(kpi.DerivationStrategy, new[] {kpi.KeyPath});
Remove(walletId);
return kpi.Address.ToString();
}
@ -128,5 +129,19 @@ namespace BTCPayServer.Services.Wallets
_leases.Dispose();
return Task.CompletedTask;
}
public Tuple<WalletId, KeyPathInformation>? GetByScriptPubKey(string cryptoCode,Script script)
{
var match = _walletReceiveState.Where(pair =>
pair.Key.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCulture) &&
pair.Value.ScriptPubKey == script);
if (match.Any())
{
var f =match.First();
return new Tuple<WalletId, KeyPathInformation>(f.Key, f.Value);
}
return null;
}
}
}

View file

@ -19,12 +19,23 @@
<h3 class="card-title mb-4">Next available @Model.CryptoCode&nbsp;address</h3>
<noscript>
<div class="card-body m-sm-0 p-sm-0">
<div class="input-group">
<input type="text" class="form-control " readonly="readonly" asp-for="Address" id="address"/>
<div class="input-group-append">
<span class="input-group-text fa fa-copy"> </span>
<div class="form-group">
<div class="input-group">
<input type="text" class="form-control " readonly="readonly" asp-for="Address" id="address"/>
<div class="input-group-append">
<span class="input-group-text fa fa-copy"> </span>
</div>
</div>
</div>
<div class="form-group">
<div class="input-group">
<input type="text" class="form-control" readonly="readonly" asp-for="PaymentLink" id="payment-link"/>
<div class="input-group-append">
<span class="input-group-text fa fa-copy"> </span>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12 col-sm-6">
<button type="submit" name="command" value="generate-new-address" class="btn btn-primary w-100">Generate another address</button>
@ -38,12 +49,33 @@
<div class="only-for-js card-body m-sm-0 p-sm-0" id="app">
<div class="qr-container mb-4">
<img src="@Model.CryptoImage" class="qr-icon"/>
<vc:qr-code data="@Model.Address"/>
<div class="tab-content" id="myTabContent">
<div class="tab-pane show active" id="link-tab" role="tabpanel">
<vc:qr-code data="@Model.PaymentLink"/>
</div>
<div class="tab-pane" id="address-tab" role="tabpanel">
<vc:qr-code data="@Model.Address"/>
</div>
</div>
<div class="nav justify-content-center">
<a class="nav-link active" data-toggle="tab" href="#link-tab">Link</a>
<a class="nav-link" data-toggle="tab" href="#address-tab">Address</a>
</div>
</div>
<div class="input-group" data-clipboard="@Model.Address">
<input type="text" class="form-control" style="cursor: copy" readonly="readonly" value="@Model.Address" id="address"/>
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" data-clipboard-confirm>Copy address</button>
<div class="form-group">
<div class="input-group" data-clipboard="@Model.Address">
<input type="text" class="form-control" style="cursor: copy" readonly="readonly" value="@Model.Address" id="address"/>
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" data-clipboard-confirm>Copy address</button>
</div>
</div>
</div>
<div class="form-group">
<div class="input-group" data-clipboard="@Model.PaymentLink">
<input type="text" class="form-control" style="cursor: copy" readonly="readonly" value="@Model.PaymentLink" id="payment-link"/>
<div class="input-group-append">
<button type="button" class="btn btn-outline-secondary" data-clipboard-confirm>Copy link</button>
</div>
</div>
</div>
<div class="row mt-4">

View file

@ -548,6 +548,11 @@
"type": "string",
"format": "keypath",
"description": "the derivation path in relation to the HD account"
},
"paymentLink": {
"type": "string",
"format": "BIP21",
"description": "a bip21 payment link"
}
}
},