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;
|
||||
}
|
||||
}
|
||||
await Server.ExplorerNode.GenerateAsync(1);
|
||||
await Page.ReloadAsync();
|
||||
await Page.Locator("#CancelWizard").ClickAsync();
|
||||
return addressStr;
|
||||
@@ -541,5 +542,23 @@ namespace BTCPayServer.Tests
|
||||
await page.BringToFrontAsync();
|
||||
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.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Views.Wallets;
|
||||
using NBitcoin;
|
||||
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.
|
||||
await AssertHasLabels(s, rbfTx, "CPFP");
|
||||
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)
|
||||
@@ -107,13 +120,10 @@ public class WalletTests(ITestOutputHelper helper) : UnitTestBase(helper)
|
||||
await s.Page.ReloadAsync();
|
||||
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)
|
||||
{
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -284,7 +284,6 @@ namespace BTCPayServer.Controllers
|
||||
model.RecommendedSatoshiPerByte =
|
||||
recommendedFees.Where(option => option != null).ToList();
|
||||
model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate;
|
||||
|
||||
if (HttpContext.Request.Method != HttpMethods.Post)
|
||||
{
|
||||
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 })!;
|
||||
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));
|
||||
if (bumpableUTXOs.Count == 0)
|
||||
@@ -350,12 +349,16 @@ namespace BTCPayServer.Controllers
|
||||
// RBF is only supported for a single tx
|
||||
var tx = txs[bumpTarget.GetSingleTransactionId()!];
|
||||
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 ||
|
||||
changeOutput is null)
|
||||
{
|
||||
this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]);
|
||||
return View(nameof(WalletBumpFee), model);
|
||||
}
|
||||
|
||||
IActionResult ChangeTooSmall(WalletBumpFeeViewModel vm, Money? missing)
|
||||
{
|
||||
if (missing is not null)
|
||||
@@ -365,7 +368,14 @@ namespace BTCPayServer.Controllers
|
||||
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()
|
||||
{
|
||||
RBF = true,
|
||||
@@ -378,7 +388,12 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
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()
|
||||
.Select(o => new CreatePSBTDestination()
|
||||
{
|
||||
@@ -904,8 +919,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await feeProvider.GetFeeRateAsync(
|
||||
(int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
|
||||
var result = await feeProvider.GetFeeRateAsync((int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time));
|
||||
options.Add(new WalletSendModel.FeeRateOption()
|
||||
{
|
||||
Target = time,
|
||||
|
||||
@@ -92,15 +92,12 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
if (transactionEvent.NewTransactionEvent.Replacing is not null)
|
||||
{
|
||||
|
||||
foreach (var replaced in transactionEvent.NewTransactionEvent.Replacing)
|
||||
{
|
||||
var replacedwoId = new WalletObjectId(wid,
|
||||
WalletObjectData.Types.Tx, replaced.ToString());
|
||||
var replacedo = await _walletRepository.GetWalletObject(replacedwoId);
|
||||
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);
|
||||
@@ -109,7 +106,6 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
["txs"] = JArray.FromObject(new[] { replaced.ToString() })
|
||||
}));
|
||||
}
|
||||
foreach (var link in replacedLinks)
|
||||
{
|
||||
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)
|
||||
{
|
||||
wallet.InvalidateCache(evt.DerivationStrategy);
|
||||
var validOutputs = network.GetValidOutputs(evt).ToList();
|
||||
if (!validOutputs.Any())
|
||||
break;
|
||||
foreach (var output in validOutputs)
|
||||
foreach (var output in network.GetValidOutputs(evt))
|
||||
{
|
||||
if (!output.matchedOutput.Value.IsCompatible(network))
|
||||
continue;
|
||||
|
||||
@@ -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
|
||||
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 unspent_utxos uu USING (code, tx_id);
|
||||
LEFT JOIN unspent_utxos uu USING (code, tx_id);
|
||||
""",
|
||||
parameters: new
|
||||
{
|
||||
@@ -367,8 +367,11 @@ namespace BTCPayServer.Services.Wallets
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -69,15 +69,7 @@
|
||||
}
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<ul class="text-danger">
|
||||
@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>
|
||||
<div asp-validation-summary="All"></div>
|
||||
}
|
||||
@if (Model.GetBumpTarget().GetSingleTransactionId() is { } txId)
|
||||
{
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
<div class="form-text crypto-info">
|
||||
<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)
|
||||
{
|
||||
<span><br>@StringLocalizer["Note: {0} are still immature and require additional confirmations.", $"{Model.ImmatureBalance} {Model.CryptoCode}"]</span>
|
||||
|
||||
Reference in New Issue
Block a user