Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/Proto/nodeguard.proto
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,18 @@ message GetNewWalletAddressResponse {
string address = 1;
}

message Destination {
// BTC address to send the funds to
string address = 1;
// Amount in satoshis
int64 amount_sats = 2;
}

message RequestWithdrawalRequest {
int32 wallet_id = 1;
string address = 2;
string address = 2 [deprecated = true];
// Amount in satoshis
int64 amount = 3;
int64 amount = 3 [deprecated = true];
string description = 4;
// in JSON format
string request_metadata = 5;
Expand All @@ -141,6 +148,8 @@ message RequestWithdrawalRequest {
optional int32 custom_fee_rate = 9;
// External reference id for the withdrawal request
optional string reference_id = 10;
// Destinations for the withdrawal
repeated Destination destinations = 11;
}

message RequestWithdrawalResponse {
Expand Down
70 changes: 43 additions & 27 deletions src/Rpc/NodeGuardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,34 @@ public override async Task<GetNewWalletAddressResponse> GetNewWalletAddress(GetN
return getNewWalletAddressResponse;
}

private void ValidateWithdrawalDestinations(IList<Destination> destinations, bool isChangeless = false)
{
if (destinations == null || destinations.Count == 0)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "At least one destination must be provided"));
}

// Changeless transactions can only have one destination
if (isChangeless && destinations.Count > 1)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Changeless transactions can only have one destination"));
}

foreach (var destination in destinations)
{
if (destination.AmountSats <= 0)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Amount must be greater than 0"));
}

if (string.IsNullOrEmpty(destination.Address))
{
throw new RpcException(new Status(StatusCode.InvalidArgument,
"A destination address must be provided"));
}
}
}

public override async Task<RequestWithdrawalResponse> RequestWithdrawal(RequestWithdrawalRequest request,
ServerCallContext context)
{
Expand All @@ -184,19 +212,8 @@ public override async Task<RequestWithdrawalResponse> RequestWithdrawal(RequestW
throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found"));
}

if (request.Amount <= 0)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, "Amount must be greater than 0"));
}

if (request.Address == "")
{
throw new RpcException(new Status(StatusCode.InvalidArgument,
"A destination address must be provided"));
}

//Create withdrawal request
var amount = new Money(request.Amount, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC);
// Validate destinations
ValidateWithdrawalDestinations(request.Destinations, request.Changeless);

var outpoints = new List<OutPoint>();
var utxos = new List<UTXO>();
Expand All @@ -215,20 +232,20 @@ public override async Task<RequestWithdrawalResponse> RequestWithdrawal(RequestW
throw new RpcException(new Status(StatusCode.Internal, "Derivation strategy not found"));

utxos = await _coinSelectionService.GetUTXOsByOutpointAsync(derivationStrategyBase, outpoints);
amount = utxos.Sum(u => ((Money)u.Value).ToUnit(MoneyUnit.BTC));

}

// Create destination objects for the withdrawal request
var withdrawalDestinations = request.Destinations.Select(d => new WalletWithdrawalRequestDestination()
{
Address = d.Address,
Amount = new Money(d.AmountSats, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC),
}).ToList();

withdrawalRequest = new WalletWithdrawalRequest()
{
WalletId = request.WalletId,
WalletWithdrawalRequestDestinations =
[
new WalletWithdrawalRequestDestination()
{
Address = request.Address,
Amount = amount,
}
],
WalletWithdrawalRequestDestinations = withdrawalDestinations,
Description = request.Description,
Status = wallet.IsHotWallet
? WalletWithdrawalRequestStatus.PSBTSignaturesPending
Expand Down Expand Up @@ -261,7 +278,7 @@ await _coinSelectionService.LockUTXOs(utxos, withdrawalRequest,
BitcoinRequestType.WalletWithdrawal);
}

//Update to refresh from db
// Update to refresh from db
withdrawalRequest = await _walletWithdrawalRequestRepository.GetById(withdrawalRequest.Id);

if (!withdrawalSaved.Item1)
Expand All @@ -270,12 +287,11 @@ await _coinSelectionService.LockUTXOs(utxos, withdrawalRequest,
throw new RpcException(new Status(StatusCode.Internal, "Error saving withdrawal request for wallet"));
}

//Template PSBT generation with SIGHASH_ALL
// Template PSBT generation with SIGHASH_ALL
var psbt = await _bitcoinService.GenerateTemplatePSBT(withdrawalRequest ??
throw new ArgumentException(
nameof(withdrawalRequest)));
throw new ArgumentException(nameof(withdrawalRequest)));

//If the wallet is hot, we send the withdrawal request to the node
// If the wallet is hot, we send the withdrawal request to the embedded or remote signer
if (wallet.IsHotWallet)
{
var map = new JobDataMap();
Expand Down
144 changes: 85 additions & 59 deletions src/Services/BitcoinService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ public async Task<PSBT> GenerateTemplatePSBT(WalletWithdrawalRequest walletWithd
{
if (walletWithdrawalRequest == null) throw new ArgumentNullException(nameof(walletWithdrawalRequest));

walletWithdrawalRequest = await _walletWithdrawalRequestRepository.GetById(walletWithdrawalRequest.Id);
var fetchedWithdrawalRequest = await _walletWithdrawalRequestRepository.GetById(walletWithdrawalRequest.Id);
if (fetchedWithdrawalRequest == null)
{
throw new InvalidOperationException($"Withdrawal request with Id {walletWithdrawalRequest.Id} not found.");
}
walletWithdrawalRequest = fetchedWithdrawalRequest;

if (walletWithdrawalRequest.Status != WalletWithdrawalRequestStatus.Pending
&& walletWithdrawalRequest.Status != WalletWithdrawalRequestStatus.PSBTSignaturesPending)
Expand Down Expand Up @@ -172,13 +177,6 @@ public async Task<PSBT> GenerateTemplatePSBT(WalletWithdrawalRequest walletWithd
await _coinSelectionService.GetLockedUTXOsForRequest(walletWithdrawalRequest,
BitcoinRequestType.WalletWithdrawal);

if (walletWithdrawalRequest.Changeless && previouslyLockedUTXOs.Count == 0)
{
_logger.LogError(
"Cannot generate base template PSBT for withdrawal request: {RequestId}, no UTXOs were provided for the changeless operation",
walletWithdrawalRequest.Id);
throw new ArgumentException();
}

var availableUTXOs = previouslyLockedUTXOs.Count > 0
? previouslyLockedUTXOs
Expand All @@ -199,77 +197,105 @@ await _coinSelectionService.GetTxInputCoins(availableUTXOs, walletWithdrawalRequ
var nbXplorerNetwork = CurrentNetworkHelper.GetCurrentNetwork();

PSBT? originalPSBT = null;
try

//We got enough inputs to fund the TX so time to build the PSBT

var network = CurrentNetworkHelper.GetCurrentNetwork();
var txBuilder = nbXplorerNetwork.CreateTransactionBuilder();

//We get the mempool.space recommended fees, the custom or use the bitcoin core (nbxplorer) one if not available
var feerate = await _nbXplorerService.GetFeesByType(walletWithdrawalRequest.MempoolRecommendedFeesType)
?? walletWithdrawalRequest.CustomFeeRate
?? (await LightningHelper.GetFeeRateResult(network, _nbXplorerService)).FeeRate
.SatoshiPerByte;
var feeRateResult = new FeeRate(feerate);

var changeAddress = await _nbXplorerService.GetUnusedAsync(derivationStrategy, DerivationFeature.Change,
0, false, default);

if (changeAddress == null)
{
//We got enough inputs to fund the TX so time to build the PSBT
_logger.LogError("Change address was not found for wallet: {WalletId}",
walletWithdrawalRequest.Wallet.Id);
throw new ArgumentNullException(
$"Change address was not found for wallet: {walletWithdrawalRequest.Wallet.Id}");
}

var network = CurrentNetworkHelper.GetCurrentNetwork();
var txBuilder = nbXplorerNetwork.CreateTransactionBuilder();
var builder = txBuilder;
builder.AddCoins(scriptCoins);

//We get the mempool.space recommended fees, the custom or use the bitcoin core (nbxplorer) one if not available
var feerate = await _nbXplorerService.GetFeesByType(walletWithdrawalRequest.MempoolRecommendedFeesType)
?? walletWithdrawalRequest.CustomFeeRate
?? (await LightningHelper.GetFeeRateResult(network, _nbXplorerService)).FeeRate
.SatoshiPerByte;
var feeRateResult = new FeeRate(feerate);
var changelessAmount = selectedUTXOs.Sum(u => (Money)u.Value);
var destinations = walletWithdrawalRequest.WalletWithdrawalRequestDestinations?.ToList();
if (destinations == null || !destinations.Any())
{
throw new ArgumentException("No destination addresses provided.");
}

var changeAddress = await _nbXplorerService.GetUnusedAsync(derivationStrategy, DerivationFeature.Change,
0, false, default);
builder.SetSigningOptions(SigHash.All)
.SetChange(changeAddress.Address)
.SendEstimatedFees(feeRateResult);

if (changeAddress == null)
// We preserve the output order when testing so the psbt doesn't change
var command = Assembly.GetEntryAssembly()?.GetName().Name?.ToLowerInvariant();
builder.ShuffleOutputs = command != "ef" && (command != null && !command.Contains("test"));

if (walletWithdrawalRequest.WithdrawAllFunds)
{
// For withdraw all funds, send to the first destination only and check there's only one destination
if (destinations.Count > 1)
{
_logger.LogError("Change address was not found for wallet: {WalletId}",
walletWithdrawalRequest.Wallet.Id);
throw new ArgumentNullException(
$"Change address was not found for wallet: {walletWithdrawalRequest.Wallet.Id}");
throw new ArgumentException("Withdraw all funds can only have one destination address.");
}

var builder = txBuilder;
builder.AddCoins(scriptCoins);

var changelessAmount = selectedUTXOs.Sum(u => (Money)u.Value);
var amount = new Money(walletWithdrawalRequest.SatsAmount, MoneyUnit.Satoshi);
var destinationAddress = walletWithdrawalRequest.WalletWithdrawalRequestDestinations?.FirstOrDefault()?.Address;
if (string.IsNullOrEmpty(destinationAddress))
var firstDestination = destinations.First();
if (string.IsNullOrEmpty(firstDestination.Address))
{
throw new ArgumentException("Destination address is null or empty.");
throw new ArgumentException("First destination address is null or empty.");
}
var firstDestinationAddress = BitcoinAddress.Create(firstDestination.Address, nbXplorerNetwork);
builder.SendAll(firstDestinationAddress);
}
else if (walletWithdrawalRequest.Changeless)
{
// Changeless transactions can only have one destination
if (destinations.Count > 1)
{
throw new ArgumentException("Changeless transactions can only have one destination address.");
}
var destination = BitcoinAddress.Create(destinationAddress, nbXplorerNetwork);

builder.SetSigningOptions(SigHash.All)
.SetChange(changeAddress.Address)
.SendEstimatedFees(feeRateResult);

// We preserve the output order when testing so the psbt doesn't change
var command = Assembly.GetEntryAssembly()?.GetName().Name?.ToLowerInvariant();
builder.ShuffleOutputs = command != "ef" && (command != null && !command.Contains("test"));

if (walletWithdrawalRequest.WithdrawAllFunds)
var destination = destinations.First();
if (string.IsNullOrEmpty(destination.Address))
{
builder.SendAll(destination);
throw new ArgumentException("Destination address is null or empty.");
}
else
var destinationAddress = BitcoinAddress.Create(destination.Address, nbXplorerNetwork);
builder.Send(destinationAddress, changelessAmount);
builder.SubtractFees();
}
else
{
// Handle multiple destinations for regular transactions
foreach (var dest in destinations)
{
builder.Send(destination, walletWithdrawalRequest.Changeless ? changelessAmount : amount);
if (walletWithdrawalRequest.Changeless)
if (string.IsNullOrEmpty(dest.Address))
{
builder.SubtractFees();
throw new ArgumentException("Destination address is null or empty.");
}

builder.SendAllRemainingToChange();
var destinationAddress = BitcoinAddress.Create(dest.Address, nbXplorerNetwork);
var amount = new Money(dest.Amount, MoneyUnit.BTC);
builder.Send(destinationAddress, amount);
}

originalPSBT = builder.BuildPSBT(false);

//Additional fields to support PSBT signing with a HW or the Remote Signer
originalPSBT = LightningHelper.AddDerivationData(walletWithdrawalRequest.Wallet, originalPSBT,
selectedUTXOs, scriptCoins, _logger);
}
catch (Exception e)
{
_logger.LogError(e, "Error while generating base PSBT");
builder.SendAllRemainingToChange();
}

originalPSBT = builder.BuildPSBT(false);

//Additional fields to support PSBT signing with a HW or the Remote Signer
originalPSBT = LightningHelper.AddDerivationData(walletWithdrawalRequest.Wallet, originalPSBT,
selectedUTXOs, scriptCoins, _logger);


// We "lock" the PSBT to the channel operation request by adding to its UTXOs collection for later checking
var utxos = selectedUTXOs.Select(x => _mapper.Map<UTXO, FMUTXO>(x)).ToList();

Expand Down
8 changes: 6 additions & 2 deletions test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -632,9 +632,13 @@ public async Task RequestWithdrawal_Success()
var requestWithdrawalRequest = new RequestWithdrawalRequest
{
WalletId = wallet.Id,
Amount = 100,
Description = $"Request Withdrawal Test {DateTime.Now}",
Address = "bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal",
Destinations = { new Destination
{
Address = "bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal",
AmountSats = new Money(1, MoneyUnit.BTC).Satoshi
}
}
};

//Act
Expand Down
Loading
Loading