mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +01:00
Add ability to RBF a transaction with a single output
This commit is contained in:
@@ -474,6 +474,7 @@ namespace BTCPayServer.Tests
|
|||||||
goto retry;
|
goto retry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await Server.ExplorerNode.GenerateAsync(1);
|
||||||
await Page.ReloadAsync();
|
await Page.ReloadAsync();
|
||||||
await Page.Locator("#CancelWizard").ClickAsync();
|
await Page.Locator("#CancelWizard").ClickAsync();
|
||||||
return addressStr;
|
return addressStr;
|
||||||
@@ -541,5 +542,23 @@ namespace BTCPayServer.Tests
|
|||||||
await page.BringToFrontAsync();
|
await page.BringToFrontAsync();
|
||||||
return new SwitchDisposable(page, old, this, closeAfter);
|
return new SwitchDisposable(page, old, this, closeAfter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SendWalletPMO> GoToWalletSend(WalletId walletId = null)
|
||||||
|
{
|
||||||
|
await GoToWallet(walletId, navPages: WalletsNavPages.Send);
|
||||||
|
return new(Page);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SendWalletPMO(IPage page)
|
||||||
|
{
|
||||||
|
public Task FillAddress(BitcoinAddress address) => page.FillAsync("[name='Outputs[0].DestinationAddress']",
|
||||||
|
address.ToString());
|
||||||
|
|
||||||
|
public Task SweepBalance() => page.ClickAsync("#SweepBalance");
|
||||||
|
|
||||||
|
public Task Sign() => page.ClickAsync("#SignTransaction");
|
||||||
|
|
||||||
|
public Task SetFeeRate(decimal val) => page.FillAsync("[name=\"FeeSatoshiPerByte\"]", val.ToString(CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Models;
|
using BTCPayServer.Abstractions.Models;
|
||||||
using BTCPayServer.Client;
|
|
||||||
using BTCPayServer.Client.Models;
|
|
||||||
using BTCPayServer.Views.Wallets;
|
using BTCPayServer.Views.Wallets;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -87,6 +85,21 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
|
|||||||
// However, the new transaction should have copied the CPFP tag from the transaction it replaced, and have a RBF label as well.
|
// However, the new transaction should have copied the CPFP tag from the transaction it replaced, and have a RBF label as well.
|
||||||
await AssertHasLabels(s, rbfTx, "CPFP");
|
await AssertHasLabels(s, rbfTx, "CPFP");
|
||||||
await AssertHasLabels(s, rbfTx, "RBF");
|
await AssertHasLabels(s, rbfTx, "RBF");
|
||||||
|
|
||||||
|
// Now, we sweep all the UTXOs to a single destination. This should be RBF-able. (Fee deducted on the lone UTXO)
|
||||||
|
await s.GoToWallet(navPages: WalletsNavPages.Send);
|
||||||
|
var send = await s.GoToWalletSend();
|
||||||
|
await send.FillAddress(new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest));
|
||||||
|
await send.SweepBalance();
|
||||||
|
await send.SetFeeRate(20m);
|
||||||
|
await send.Sign();
|
||||||
|
await s.Page.ClickAsync("button[value=broadcast]");
|
||||||
|
// Now we RBF the sweep
|
||||||
|
await ClickBumpFee(s);
|
||||||
|
Assert.Equal("RBF", await s.Page.Locator("#BumpMethod").InnerTextAsync());
|
||||||
|
await s.ClickPagePrimary();
|
||||||
|
await s.Page.ClickAsync("#BroadcastTransaction");
|
||||||
|
await AssertHasLabels(s, "RBF");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CreateInvoices(PlaywrightTester tester)
|
private async Task CreateInvoices(PlaywrightTester tester)
|
||||||
@@ -107,13 +120,10 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
|
|||||||
await s.Page.ReloadAsync();
|
await s.Page.ReloadAsync();
|
||||||
await s.Page.Locator($"{TxRowSelector(txId)} .transaction-label[data-value=\"{label}\"]").WaitForAsync();
|
await s.Page.Locator($"{TxRowSelector(txId)} .transaction-label[data-value=\"{label}\"]").WaitForAsync();
|
||||||
}
|
}
|
||||||
private async Task AssertHasLabels(PlaywrightTester s, string label)
|
|
||||||
{
|
|
||||||
await s.Page.ReloadAsync();
|
|
||||||
await s.Page.Locator($".transaction-label[data-value=\"{label}\"]").First.WaitForAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
static string TxRowSelector(uint256 txId) => $".transaction-row[data-value=\"{txId}\"]";
|
private Task AssertHasLabels(PlaywrightTester s, string label) => AssertHasLabels(s, null, label);
|
||||||
|
|
||||||
|
static string TxRowSelector(uint256 txId) => txId is null ? ".transaction-row:first-of-type" : $".transaction-row[data-value=\"{txId}\"]";
|
||||||
|
|
||||||
private async Task SelectTransactions(PlaywrightTester s, params uint256[] txs)
|
private async Task SelectTransactions(PlaywrightTester s, params uint256[] txs)
|
||||||
{
|
{
|
||||||
@@ -123,7 +133,7 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ClickBumpFee(PlaywrightTester s, uint256 txId)
|
private async Task ClickBumpFee(PlaywrightTester s, uint256 txId = null)
|
||||||
{
|
{
|
||||||
await s.Page.ClickAsync($"{TxRowSelector(txId)} .bumpFee-btn");
|
await s.Page.ClickAsync($"{TxRowSelector(txId)} .bumpFee-btn");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,7 +284,6 @@ namespace BTCPayServer.Controllers
|
|||||||
model.RecommendedSatoshiPerByte =
|
model.RecommendedSatoshiPerByte =
|
||||||
recommendedFees.Where(option => option != null).ToList();
|
recommendedFees.Where(option => option != null).ToList();
|
||||||
model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate;
|
model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate;
|
||||||
|
|
||||||
if (HttpContext.Request.Method != HttpMethods.Post)
|
if (HttpContext.Request.Method != HttpMethods.Post)
|
||||||
{
|
{
|
||||||
model.Command = null;
|
model.Command = null;
|
||||||
@@ -303,7 +302,7 @@ namespace BTCPayServer.Controllers
|
|||||||
var feeBumpUrl = Url.Action(nameof(WalletBumpFee), new { walletId, transactionId = bumpTarget.GetSingleTransactionId(), model.FeeSatoshiPerByte, model.BumpMethod, model.TransactionHashes, model.Outpoints })!;
|
var feeBumpUrl = Url.Action(nameof(WalletBumpFee), new { walletId, transactionId = bumpTarget.GetSingleTransactionId(), model.FeeSatoshiPerByte, model.BumpMethod, model.TransactionHashes, model.Outpoints })!;
|
||||||
if (model.BumpMethod == "CPFP")
|
if (model.BumpMethod == "CPFP")
|
||||||
{
|
{
|
||||||
var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation);
|
var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation, cancellationToken);
|
||||||
|
|
||||||
List<OutPoint> bumpableUTXOs = bumpTarget.GetMatchedOutpoints(utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0).Select(u => u.Outpoint));
|
List<OutPoint> bumpableUTXOs = bumpTarget.GetMatchedOutpoints(utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0).Select(u => u.Outpoint));
|
||||||
if (bumpableUTXOs.Count == 0)
|
if (bumpableUTXOs.Count == 0)
|
||||||
@@ -350,12 +349,16 @@ namespace BTCPayServer.Controllers
|
|||||||
// RBF is only supported for a single tx
|
// RBF is only supported for a single tx
|
||||||
var tx = txs[bumpTarget.GetSingleTransactionId()!];
|
var tx = txs[bumpTarget.GetSingleTransactionId()!];
|
||||||
var changeOutput = tx.Outputs.FirstOrDefault(o => o.Feature == DerivationFeature.Change);
|
var changeOutput = tx.Outputs.FirstOrDefault(o => o.Feature == DerivationFeature.Change);
|
||||||
|
if (changeOutput is null &&
|
||||||
|
tx is { Transaction: { Outputs: [{ ScriptPubKey: {} singleAddress }] }})
|
||||||
|
changeOutput = new() { ScriptPubKey = singleAddress, Index = 0 };
|
||||||
if (tx.Inputs.Count != tx.Transaction?.Inputs.Count ||
|
if (tx.Inputs.Count != tx.Transaction?.Inputs.Count ||
|
||||||
changeOutput is null)
|
changeOutput is null)
|
||||||
{
|
{
|
||||||
this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]);
|
this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]);
|
||||||
return View(nameof(WalletBumpFee), model);
|
return View(nameof(WalletBumpFee), model);
|
||||||
}
|
}
|
||||||
|
|
||||||
IActionResult ChangeTooSmall(WalletBumpFeeViewModel vm, Money? missing)
|
IActionResult ChangeTooSmall(WalletBumpFeeViewModel vm, Money? missing)
|
||||||
{
|
{
|
||||||
if (missing is not null)
|
if (missing is not null)
|
||||||
@@ -365,7 +368,14 @@ namespace BTCPayServer.Controllers
|
|||||||
return View(nameof(WalletBumpFee), vm);
|
return View(nameof(WalletBumpFee), vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
var bumpResult = bumpable[tx.TransactionId].ReplacementInfo!.CalculateBumpResult(targetFeeRate);
|
var bumpableTx = bumpable[tx.TransactionId].ReplacementInfo!;
|
||||||
|
if (targetFeeRate < bumpableTx.CalculateNewMinFeeRate())
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(model.FeeSatoshiPerByte), StringLocalizer["The selected fee rate is too small. The minimum is {0} sat/byte", bumpableTx.CalculateNewMinFeeRate().SatoshiPerByte]);
|
||||||
|
return View(nameof(WalletBumpFee), model);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bumpResult = bumpableTx.CalculateBumpResult(targetFeeRate);
|
||||||
var createPSBT = new CreatePSBTRequest()
|
var createPSBT = new CreatePSBTRequest()
|
||||||
{
|
{
|
||||||
RBF = true,
|
RBF = true,
|
||||||
@@ -378,7 +388,12 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
ExplicitFee = bumpResult.NewTxFee
|
ExplicitFee = bumpResult.NewTxFee
|
||||||
},
|
},
|
||||||
ExplicitChangeAddress = changeOutput.Address,
|
ExplicitChangeAddress = changeOutput switch
|
||||||
|
{
|
||||||
|
{ Address: {} addr } => PSBTDestination.Create(addr),
|
||||||
|
{ ScriptPubKey: {} scriptPubKey } => PSBTDestination.Create(scriptPubKey),
|
||||||
|
_ => throw new InvalidOperationException("Invalid change output")
|
||||||
|
},
|
||||||
Destinations = tx.Transaction.Outputs.AsIndexedOutputs()
|
Destinations = tx.Transaction.Outputs.AsIndexedOutputs()
|
||||||
.Select(o => new CreatePSBTDestination()
|
.Select(o => new CreatePSBTDestination()
|
||||||
{
|
{
|
||||||
@@ -904,8 +919,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await feeProvider.GetFeeRateAsync(
|
var result = await feeProvider.GetFeeRateAsync((int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
|
||||||
(int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
|
|
||||||
options.Add(new WalletSendModel.FeeRateOption()
|
options.Add(new WalletSendModel.FeeRateOption()
|
||||||
{
|
{
|
||||||
Target = time,
|
Target = time,
|
||||||
|
|||||||
@@ -92,24 +92,20 @@ namespace BTCPayServer.HostedServices
|
|||||||
{
|
{
|
||||||
if (transactionEvent.NewTransactionEvent.Replacing is not null)
|
if (transactionEvent.NewTransactionEvent.Replacing is not null)
|
||||||
{
|
{
|
||||||
|
|
||||||
foreach (var replaced in transactionEvent.NewTransactionEvent.Replacing)
|
foreach (var replaced in transactionEvent.NewTransactionEvent.Replacing)
|
||||||
{
|
{
|
||||||
var replacedwoId = new WalletObjectId(wid,
|
var replacedwoId = new WalletObjectId(wid,
|
||||||
WalletObjectData.Types.Tx, replaced.ToString());
|
WalletObjectData.Types.Tx, replaced.ToString());
|
||||||
var replacedo = await _walletRepository.GetWalletObject(replacedwoId);
|
var replacedo = await _walletRepository.GetWalletObject(replacedwoId);
|
||||||
var replacedLinks = replacedo?.GetLinks().Where(t => t.type != WalletObjectData.Types.Tx) ?? [];
|
var replacedLinks = replacedo?.GetLinks().Where(t => t.type != WalletObjectData.Types.Tx) ?? [];
|
||||||
if (replacedLinks.Count() != 0)
|
var rbf = new WalletObjectId(wid, WalletObjectData.Types.RBF, "");
|
||||||
|
var label = WalletRepository.CreateLabel(rbf);
|
||||||
|
newObjs.Add(label.ObjectData);
|
||||||
|
links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, label.Id));
|
||||||
|
links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, rbf, new JObject()
|
||||||
{
|
{
|
||||||
var rbf = new WalletObjectId(wid, WalletObjectData.Types.RBF, "");
|
["txs"] = JArray.FromObject(new[] { replaced.ToString() })
|
||||||
var label = WalletRepository.CreateLabel(rbf);
|
}));
|
||||||
newObjs.Add(label.ObjectData);
|
|
||||||
links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, label.Id));
|
|
||||||
links.Add(WalletRepository.NewWalletObjectLinkData(txWalletObject, rbf, new JObject()
|
|
||||||
{
|
|
||||||
["txs"] = JArray.FromObject(new[] { replaced.ToString() })
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
foreach (var link in replacedLinks)
|
foreach (var link in replacedLinks)
|
||||||
{
|
{
|
||||||
links.Add(WalletRepository.NewWalletObjectLinkData(new WalletObjectId(walletObjectDatas.Key, link.type, link.id), txWalletObject, link.linkdata));
|
links.Add(WalletRepository.NewWalletObjectLinkData(new WalletObjectId(walletObjectDatas.Key, link.type, link.id), txWalletObject, link.linkdata));
|
||||||
|
|||||||
@@ -155,10 +155,7 @@ namespace BTCPayServer.Payments.Bitcoin
|
|||||||
if (evt.DerivationStrategy != null)
|
if (evt.DerivationStrategy != null)
|
||||||
{
|
{
|
||||||
wallet.InvalidateCache(evt.DerivationStrategy);
|
wallet.InvalidateCache(evt.DerivationStrategy);
|
||||||
var validOutputs = network.GetValidOutputs(evt).ToList();
|
foreach (var output in network.GetValidOutputs(evt))
|
||||||
if (!validOutputs.Any())
|
|
||||||
break;
|
|
||||||
foreach (var output in validOutputs)
|
|
||||||
{
|
{
|
||||||
if (!output.matchedOutput.Value.IsCompatible(network))
|
if (!output.matchedOutput.Value.IsCompatible(network))
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace BTCPayServer.Services.Fees
|
|||||||
{
|
{
|
||||||
public async Task<FeeRate> GetFeeRateAsync(int blockTarget = 20)
|
public async Task<FeeRate> GetFeeRateAsync(int blockTarget = 20)
|
||||||
{
|
{
|
||||||
return (await ExplorerClient.GetFeeRateAsync(blockTarget).ConfigureAwait(false)).FeeRate;
|
return (await ExplorerClient.GetFeeRateAsync(blockTarget).ConfigureAwait(false)).FeeRate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,9 +338,9 @@ namespace BTCPayServer.Services.Wallets
|
|||||||
WHERE code = @code AND wallet_id=@walletId AND mempool IS TRUE AND replaced_by IS NULL AND blk_id IS NULL
|
WHERE code = @code AND wallet_id=@walletId AND mempool IS TRUE AND replaced_by IS NULL AND blk_id IS NULL
|
||||||
GROUP BY code, tx_id
|
GROUP BY code, tx_id
|
||||||
)
|
)
|
||||||
SELECT tt.tx_id, u.raw, tt.input_count, tt.change_count, uu.unspent_count FROM unconfs u
|
SELECT tt.tx_id, u.raw, tt.input_count, tt.change_count, COALESCE(uu.unspent_count, 0) FROM unconfs u
|
||||||
JOIN tracked_txs tt USING (code, tx_id)
|
JOIN tracked_txs tt USING (code, tx_id)
|
||||||
JOIN unspent_utxos uu USING (code, tx_id);
|
LEFT JOIN unspent_utxos uu USING (code, tx_id);
|
||||||
""",
|
""",
|
||||||
parameters: new
|
parameters: new
|
||||||
{
|
{
|
||||||
@@ -367,8 +367,11 @@ namespace BTCPayServer.Services.Wallets
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ((state.MempoolInfo?.FullRBF is true || tx.RBF) && tx.Inputs.Count == r.input_count &&
|
|
||||||
r.change_count > 0)
|
var rbf = state.MempoolInfo?.FullRBF is true || Network.IsBTC || tx.RBF;
|
||||||
|
rbf &= tx.Inputs.Count == r.input_count;
|
||||||
|
rbf &= r.change_count > 0 || tx.Outputs.Count == 1;
|
||||||
|
if (rbf)
|
||||||
{
|
{
|
||||||
canRBF.Add(uint256.Parse(r.tx_id));
|
canRBF.Add(uint256.Parse(r.tx_id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,18 +67,10 @@
|
|||||||
<input type="hidden" asp-for="Outpoints[i]" />
|
<input type="hidden" asp-for="Outpoints[i]" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (!ViewContext.ModelState.IsValid)
|
@if (!ViewContext.ModelState.IsValid)
|
||||||
{
|
{
|
||||||
<ul class="text-danger">
|
<div asp-validation-summary="All"></div>
|
||||||
@foreach (var errors in ViewData.ModelState.Where(pair => pair.Key == string.Empty && pair.Value.ValidationState == ModelValidationState.Invalid))
|
}
|
||||||
{
|
|
||||||
foreach (var error in errors.Value.Errors)
|
|
||||||
{
|
|
||||||
<li>@error.ErrorMessage</li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
@if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId)
|
@if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId)
|
||||||
{
|
{
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-text crypto-info">
|
<div class="form-text crypto-info">
|
||||||
<span text-translate="true">Your available balance is</span>
|
<span text-translate="true">Your available balance is</span>
|
||||||
<button type="button" class="crypto-balance-link btn btn-link p-0 align-baseline">@Model.CurrentBalance</button> <span>@Model.CryptoCode</span>.
|
<button id="SweepBalance" type="button" class="crypto-balance-link btn btn-link p-0 align-baseline">@Model.CurrentBalance</button> <span>@Model.CryptoCode</span>.
|
||||||
@if (Model.ImmatureBalance > 0)
|
@if (Model.ImmatureBalance > 0)
|
||||||
{
|
{
|
||||||
<span><br>@StringLocalizer["Note: {0} are still immature and require additional confirmations.", $"{Model.ImmatureBalance} {Model.CryptoCode}"]</span>
|
<span><br>@StringLocalizer["Note: {0} are still immature and require additional confirmations.", $"{Model.ImmatureBalance} {Model.CryptoCode}"]</span>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
<input asp-for="AlwaysIncludeNonWitnessUTXO" class="btcpay-toggle me-3"/>
|
<input asp-for="AlwaysIncludeNonWitnessUTXO" class="btcpay-toggle me-3"/>
|
||||||
<label asp-for="AlwaysIncludeNonWitnessUTXO" class="form-check-label"></label>
|
<label asp-for="AlwaysIncludeNonWitnessUTXO" class="form-check-label"></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(Model.PayJoinBIP21))
|
@if (!string.IsNullOrEmpty(Model.PayJoinBIP21))
|
||||||
{
|
{
|
||||||
<div class="d-flex mb-3">
|
<div class="d-flex mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user