From a0a1831d379f1439d4fe62b21082233591e8f928 Mon Sep 17 00:00:00 2001
From: Valera <50830352+ValeraFinebits@users.noreply.github.com>
Date: Sat, 21 Feb 2026 11:47:11 +0200
Subject: [PATCH 1/2] Upgrade packages to the latest versions
---
payjoin-ffi/csharp/Payjoin.Tests.csproj | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/payjoin-ffi/csharp/Payjoin.Tests.csproj b/payjoin-ffi/csharp/Payjoin.Tests.csproj
index b637c308e..b3074aff6 100644
--- a/payjoin-ffi/csharp/Payjoin.Tests.csproj
+++ b/payjoin-ffi/csharp/Payjoin.Tests.csproj
@@ -10,12 +10,12 @@
-
-
-
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
From 8fe975f121690db4e13ac3e3e2a6e04adc9497ee Mon Sep 17 00:00:00 2001
From: Valera <50830352+ValeraFinebits@users.noreply.github.com>
Date: Sat, 21 Feb 2026 14:36:55 +0200
Subject: [PATCH 2/2] Add C# integration tests for Payjoin FFI
---
payjoin-ffi/csharp/IntegrationTests.cs | 621 +++++++++++++++++++++++++
1 file changed, 621 insertions(+)
create mode 100644 payjoin-ffi/csharp/IntegrationTests.cs
diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs
new file mode 100644
index 000000000..d17742a79
--- /dev/null
+++ b/payjoin-ffi/csharp/IntegrationTests.cs
@@ -0,0 +1,621 @@
+using System.Text.Json;
+using uniffi.payjoin;
+using Xunit;
+
+namespace Payjoin.Tests
+{
+ public class IntegrationTests : IAsyncLifetime
+ {
+ private static string RpcCall(RpcClient rpc, string method, params string?[] args) => rpc.Call(method, args);
+ private BitcoindEnv? _env;
+ private TestServices? _services;
+ private HttpClient? _httpClient;
+
+ private sealed class InMemoryReceiverPersister : JsonReceiverSessionPersister
+ {
+ private readonly List _events = new();
+ public RpcClient? Connection { get; set; }
+
+ public void Save(string @event) => _events.Add(@event);
+ public string[] Load() => _events.ToArray();
+ public void Close() { }
+ }
+
+ private sealed class InMemorySenderPersister : JsonSenderSessionPersister
+ {
+ private readonly List _events = new();
+
+ public void Save(string @event) => _events.Add(@event);
+ public string[] Load() => _events.ToArray();
+ public void Close() { }
+ }
+
+ private sealed class MempoolAcceptanceCallback : CanBroadcast
+ {
+ private readonly RpcClient _connection;
+
+ public MempoolAcceptanceCallback(RpcClient connection)
+ {
+ _connection = connection;
+ }
+
+ public bool Callback(byte[] tx)
+ {
+ try
+ {
+ var hexTx = Convert.ToHexString(tx).ToLowerInvariant();
+ var resultJson = RpcCall(_connection, "testmempoolaccept", JsonSerializer.Serialize(new[] { hexTx }));
+ using var doc = JsonDocument.Parse(resultJson);
+
+ return doc.RootElement[0].GetProperty("allowed").GetBoolean();
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+
+ private sealed class IsScriptOwnedCallback : IsScriptOwned
+ {
+ private readonly RpcClient _connection;
+
+ public IsScriptOwnedCallback(RpcClient connection)
+ {
+ _connection = connection;
+ }
+
+ public bool Callback(byte[] script)
+ {
+ try
+ {
+ var scriptHex = Convert.ToHexString(script).ToLowerInvariant();
+ var decodedScriptJson = RpcCall(_connection, "decodescript", JsonSerializer.Serialize(scriptHex));
+ using var decodedScriptDoc = JsonDocument.Parse(decodedScriptJson);
+ var decoded = decodedScriptDoc.RootElement;
+
+ var candidates = new List();
+
+ if (decoded.TryGetProperty("address", out var addressProp) && addressProp.ValueKind == JsonValueKind.String)
+ {
+ candidates.Add(addressProp.GetString()!);
+ }
+
+ if (decoded.TryGetProperty("addresses", out var addressesProp) && addressesProp.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var addr in addressesProp.EnumerateArray())
+ {
+ if (addr.ValueKind == JsonValueKind.String)
+ {
+ candidates.Add(addr.GetString()!);
+ }
+ }
+ }
+
+ if (decoded.TryGetProperty("segwit", out var segwitProp) && segwitProp.ValueKind == JsonValueKind.Object)
+ {
+ if (segwitProp.TryGetProperty("address", out var segwitAddr) && segwitAddr.ValueKind == JsonValueKind.String)
+ {
+ candidates.Add(segwitAddr.GetString()!);
+ }
+
+ if (segwitProp.TryGetProperty("addresses", out var segwitAddrs) && segwitAddrs.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var addr in segwitAddrs.EnumerateArray())
+ {
+ if (addr.ValueKind == JsonValueKind.String)
+ {
+ candidates.Add(addr.GetString()!);
+ }
+ }
+ }
+ }
+
+ foreach (var addr in candidates)
+ {
+ var infoJson = RpcCall(_connection, "getaddressinfo", JsonSerializer.Serialize(addr));
+ using var infoDoc = JsonDocument.Parse(infoJson);
+ if (infoDoc.RootElement.TryGetProperty("ismine", out var isMineProp) && isMineProp.ValueKind == JsonValueKind.True)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+ }
+
+ private sealed class CheckInputsNotSeenCallback : IsOutputKnown
+ {
+ public bool Callback(PlainOutPoint _outpoint) => false;
+ }
+
+ private sealed class ProcessPsbtCallback : ProcessPsbt
+ {
+ private readonly RpcClient _connection;
+
+ public ProcessPsbtCallback(RpcClient connection)
+ {
+ _connection = connection;
+ }
+
+ public string Callback(string psbt)
+ {
+ var resJson = RpcCall(_connection, "walletprocesspsbt", JsonSerializer.Serialize(psbt));
+ using var doc = JsonDocument.Parse(resJson);
+
+ return doc.RootElement.GetProperty("psbt").GetString()!;
+ }
+ }
+
+ private static InputPair[] GetInputs(RpcClient rpc)
+ {
+ var utxosJson = RpcCall(rpc, "listunspent");
+ using var utxosDoc = JsonDocument.Parse(utxosJson);
+
+ var inputs = new List();
+ foreach (var utxo in utxosDoc.RootElement.EnumerateArray())
+ {
+ var txid = utxo.GetProperty("txid").GetString()!;
+ var vout = utxo.GetProperty("vout").GetUInt32();
+ var scriptPubKeyHex = utxo.GetProperty("scriptPubKey").GetString()!;
+ var amountBtc = utxo.GetProperty("amount").GetDouble();
+ var valueSat = (ulong)Math.Round(amountBtc * 100_000_000.0);
+
+ var txin = new PlainTxIn(
+ new PlainOutPoint(txid, vout),
+ Array.Empty(),
+ 0,
+ Array.Empty());
+
+ var txout = new PlainTxOut(valueSat, Convert.FromHexString(scriptPubKeyHex));
+ var psbtIn = new PlainPsbtInput(txout, null, null);
+
+ inputs.Add(new InputPair(txin, psbtIn, null));
+ }
+
+ return inputs.ToArray();
+ }
+
+ private async Task RetrieveReceiverProposal(
+ Initialized receiver,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister,
+ string ohttpRelay,
+ CancellationToken cancellationToken)
+ {
+ var request = receiver.CreatePollRequest(ohttpRelay);
+ var response = await _httpClient!.PostAsync(
+ request.request.url,
+ new ByteArrayContent(request.request.body)
+ {
+ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(request.request.contentType) }
+ },
+ cancellationToken);
+
+ var responseBuffer = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+
+ using var transition = receiver.ProcessResponse(responseBuffer, request.clientResponse);
+ using var outcome = transition.Save(recvPersister);
+
+ if (outcome is InitializedTransitionOutcome.Stasis)
+ {
+ return null;
+ }
+
+ if (outcome is InitializedTransitionOutcome.Progress progress)
+ {
+ using var proposal = progress.inner;
+ return await ProcessUncheckedProposal(proposal, receiverRpc, recvPersister);
+ }
+
+ throw new InvalidOperationException("Unknown initialized transition outcome");
+ }
+
+ private Task ProcessUncheckedProposal(
+ UncheckedOriginalPayload proposal,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister)
+ {
+ using var checkedTransition = proposal.CheckBroadcastSuitability(null, new MempoolAcceptanceCallback(receiverRpc));
+ using var maybeInputsOwned = checkedTransition.Save(recvPersister);
+
+ return ProcessMaybeInputsOwned(maybeInputsOwned, receiverRpc, recvPersister);
+ }
+
+ private Task ProcessMaybeInputsOwned(
+ MaybeInputsOwned proposal,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister)
+ {
+ using var transition = proposal.CheckInputsNotOwned(new IsScriptOwnedCallback(receiverRpc));
+ using var maybeInputsSeen = transition.Save(recvPersister);
+
+ return ProcessMaybeInputsSeen(maybeInputsSeen, receiverRpc, recvPersister);
+ }
+
+ private Task ProcessMaybeInputsSeen(
+ MaybeInputsSeen proposal,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister)
+ {
+ using var transition = proposal.CheckNoInputsSeenBefore(new CheckInputsNotSeenCallback());
+ using var outputsUnknown = transition.Save(recvPersister);
+
+ return ProcessOutputsUnknown(outputsUnknown, receiverRpc, recvPersister);
+ }
+
+ private Task ProcessOutputsUnknown(
+ OutputsUnknown proposal,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister)
+ {
+ using var transition = proposal.IdentifyReceiverOutputs(new IsScriptOwnedCallback(receiverRpc));
+ using var wantsOutputs = transition.Save(recvPersister);
+
+ return ProcessWantsOutputs(wantsOutputs, receiverRpc, recvPersister);
+ }
+
+ private Task ProcessWantsOutputs(
+ WantsOutputs proposal,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister)
+ {
+ using var transition = proposal.CommitOutputs();
+ using var wantsInputs = transition.Save(recvPersister);
+
+ return ProcessWantsInputs(wantsInputs, receiverRpc, recvPersister);
+ }
+
+ private Task ProcessWantsInputs(
+ WantsInputs proposal,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister)
+ {
+ using var contributed = proposal.ContributeInputs(GetInputs(receiverRpc));
+ using var transition = contributed.CommitInputs();
+ using var wantsFeeRange = transition.Save(recvPersister);
+
+ return ProcessWantsFeeRange(wantsFeeRange, receiverRpc, recvPersister);
+ }
+
+ private Task ProcessWantsFeeRange(
+ WantsFeeRange proposal,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister)
+ {
+ using var transition = proposal.ApplyFeeRange(1, 10);
+ using var provisional = transition.Save(recvPersister);
+
+ return ProcessProvisionalProposal(provisional, receiverRpc, recvPersister);
+ }
+
+ private Task ProcessProvisionalProposal(
+ ProvisionalProposal proposal,
+ RpcClient receiverRpc,
+ InMemoryReceiverPersister recvPersister)
+ {
+ using var transition = proposal.FinalizeProposal(new ProcessPsbtCallback(receiverRpc));
+ var payjoinProposal = transition.Save(recvPersister);
+
+ return Task.FromResult(payjoinProposal);
+ }
+
+ public ValueTask InitializeAsync()
+ {
+ _httpClient = new HttpClient();
+ _services = TestServices.Initialize();
+
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ _httpClient?.Dispose();
+ _services?.Dispose();
+ _env?.Dispose();
+
+ return ValueTask.CompletedTask;
+ }
+
+ [Fact]
+ public void TestFfiValidation()
+ {
+ var tooLargeAmount = 21_000_000UL * 100_000_000UL + 1;
+
+ var invalidTxid = new string('0', 128);
+ Assert.Throws(() =>
+ {
+ var txin = new PlainTxIn(
+ new PlainOutPoint(invalidTxid, 0),
+ Array.Empty(),
+ 0,
+ Array.Empty()
+ );
+ var psbtIn = new PlainPsbtInput(
+ new PlainTxOut(tooLargeAmount, new byte[] { 0x6a }),
+ null,
+ null
+ );
+ new InputPair(txin, psbtIn, null);
+ });
+
+ var validTxid = new string('0', 64);
+ Assert.Throws(() =>
+ {
+ var txin = new PlainTxIn(
+ new PlainOutPoint(validTxid, 0),
+ Array.Empty(),
+ 0,
+ Array.Empty()
+ );
+ var psbtIn = new PlainPsbtInput(
+ new PlainTxOut(tooLargeAmount, new byte[] { 0x6a }),
+ null,
+ null
+ );
+ new InputPair(txin, psbtIn, null);
+ });
+
+ var hugeScript = new byte[10_001];
+ Array.Fill(hugeScript, (byte)0x51);
+ Assert.Throws(() =>
+ {
+ var txin = new PlainTxIn(
+ new PlainOutPoint(validTxid, 0),
+ Array.Empty(),
+ 0,
+ Array.Empty()
+ );
+ var psbtIn = new PlainPsbtInput(
+ new PlainTxOut(1, hugeScript),
+ null,
+ null
+ );
+ new InputPair(txin, psbtIn, null);
+ });
+
+ Assert.Throws(() =>
+ {
+ var txin = new PlainTxIn(
+ new PlainOutPoint(validTxid, 0),
+ Array.Empty(),
+ 0,
+ Array.Empty()
+ );
+ var psbtIn = new PlainPsbtInput(
+ new PlainTxOut(1, new byte[] { 0x6a }),
+ null,
+ null
+ );
+ new InputPair(txin, psbtIn, new PlainWeight(0));
+ });
+
+ var directory = _services!.DirectoryUrl();
+ _services.WaitForServicesReady();
+ var ohttpKeys = _services.FetchOhttpKeys();
+
+ var recvPersister = new InMemoryReceiverPersister();
+ using var receiverBuilder = new ReceiverBuilder("2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", directory, ohttpKeys);
+ using var receiveTransition = receiverBuilder.Build();
+ using var receiver = receiveTransition.Save(recvPersister);
+ using var pjUri = receiver.PjUri();
+
+ var psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";
+
+ Assert.Throws(() =>
+ {
+ new SenderBuilder(psbt, pjUri).BuildRecommended(ulong.MaxValue);
+ });
+
+ Assert.Throws(() =>
+ {
+ pjUri.SetAmountSats(tooLargeAmount);
+ });
+ }
+
+ [Fact]
+ public async Task TestIntegrationV2ToV2()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ try
+ {
+ _env = PayjoinMethods.InitBitcoindSenderReceiver();
+ }
+ catch (Exception ex)
+ {
+ Assert.Skip($"test-utils are not available: {ex.GetType().Name}: {ex.Message}");
+ }
+
+ _ = _env.GetBitcoind();
+ var receiver = _env.GetReceiver();
+ var sender = _env.GetSender();
+
+ var receiverAddressJson = RpcCall(receiver, "getnewaddress");
+ var receiverAddress = JsonSerializer.Deserialize(receiverAddressJson)!;
+
+ var directory = _services!.DirectoryUrl();
+ var ohttpRelay = _services.OhttpRelayUrl();
+ _services.WaitForServicesReady();
+
+ var ohttpKeys = _services.FetchOhttpKeys();
+
+ var recvPersister = new InMemoryReceiverPersister { Connection = receiver };
+ var senderPersister = new InMemorySenderPersister();
+
+ using var receiverBuilder = new ReceiverBuilder(receiverAddress, directory, ohttpKeys);
+ using var receiveTransition = receiverBuilder.Build();
+ using var session = receiveTransition.Save(recvPersister);
+
+ var initial = await RetrieveReceiverProposal(session, receiver, recvPersister, ohttpRelay, cancellationToken);
+ Assert.Null(initial);
+
+ // *****************************
+ // SENDER SIDE
+ // Get PayJoin URI from receiver
+ using var pjUri = session.PjUri();
+
+ // Create a funded PSBT that sweeps all funds to receiver
+ var psbt = BuildSweepPsbt(sender, pjUri);
+
+ // Build sender request context
+ using var senderBuilder = new SenderBuilder(psbt, pjUri);
+ using var senderTransition = senderBuilder.BuildRecommended(1000);
+ using var reqCtx = senderTransition.Save(senderPersister);
+
+ // Create V2 POST request with OHTTP
+ using var request = reqCtx.CreateV2PostRequest(ohttpRelay);
+ var response = await _httpClient!.PostAsync(
+ request.request.url,
+ new ByteArrayContent(request.request.body)
+ {
+ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(request.request.contentType) }
+ },
+ cancellationToken);
+
+ var responseBuffer = await response.Content.ReadAsByteArrayAsync(cancellationToken);
+
+ // Process sender response
+ using var senderResponseTransition = reqCtx.ProcessResponse(responseBuffer, request.ohttpCtx);
+ using var sendCtx = senderResponseTransition.Save(senderPersister);
+
+ // *********************
+ // RECEIVER SIDE
+ // Poll for the proposal
+ using var payjoinProposal = await RetrieveReceiverProposal(session, receiver, recvPersister, ohttpRelay, cancellationToken);
+ Assert.NotNull(payjoinProposal);
+ Assert.IsType(payjoinProposal);
+
+ // Post the payjoin proposal back to the directory
+ using var proposalRequest = payjoinProposal!.CreatePostRequest(ohttpRelay);
+ using var proposalResponse = await _httpClient.PostAsync(
+ proposalRequest.request.url,
+ new ByteArrayContent(proposalRequest.request.body)
+ {
+ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(proposalRequest.request.contentType) }
+ },
+ cancellationToken);
+
+ var proposalResponseBuffer = await proposalResponse.Content.ReadAsByteArrayAsync(cancellationToken);
+ payjoinProposal.ProcessResponse(proposalResponseBuffer, proposalRequest.clientResponse);
+
+ // *******************************
+ // SENDER SIDE (FINALIZATION)
+ // Poll for the final payjoin PSBT
+ PollingForProposalTransitionOutcome? pollOutcome = null;
+ var attempts = 0;
+ while (true)
+ {
+ using var pollRequest = sendCtx.CreatePollRequest(ohttpRelay);
+ using var pollResponse = await _httpClient.PostAsync(
+ pollRequest.request.url,
+ new ByteArrayContent(pollRequest.request.body)
+ {
+ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(pollRequest.request.contentType) }
+ },
+ cancellationToken);
+
+ var pollResponseBuffer = await pollResponse.Content.ReadAsByteArrayAsync(cancellationToken);
+ using var pollTransition = sendCtx.ProcessResponse(pollResponseBuffer, pollRequest.ohttpCtx);
+ pollOutcome = pollTransition.Save(senderPersister);
+
+ if (pollOutcome is PollingForProposalTransitionOutcome.Progress)
+ {
+ break;
+ }
+
+ attempts += 1;
+ if (attempts >= 3)
+ {
+ Assert.Fail("Timed out waiting for receiver");
+ return;
+ }
+ }
+
+ var progressOutcome = (PollingForProposalTransitionOutcome.Progress)pollOutcome!;
+
+ // Sign the payjoin PSBT
+ var payjoinPsbt = progressOutcome.psbtBase64;
+ var processedPsbtJson = RpcCall(sender, "walletprocesspsbt", JsonSerializer.Serialize(payjoinPsbt));
+ using var processedDoc = JsonDocument.Parse(processedPsbtJson);
+ var processedPsbt = processedDoc.RootElement.GetProperty("psbt").GetString()!;
+
+ // Finalize PSBT with the sender client
+ var finalPsbtJson = RpcCall(sender, "finalizepsbt", JsonSerializer.Serialize(processedPsbt), JsonSerializer.Serialize(false));
+ using var finalDoc = JsonDocument.Parse(finalPsbtJson);
+ var finalPsbt = finalDoc.RootElement.GetProperty("psbt").GetString()!;
+
+ // Extract and broadcast transaction
+ var extractionJson = RpcCall(sender, "finalizepsbt", JsonSerializer.Serialize(processedPsbt), JsonSerializer.Serialize(true));
+ using var extractionDoc = JsonDocument.Parse(extractionJson);
+ var finalHex = extractionDoc.RootElement.GetProperty("hex").GetString()!;
+ RpcCall(sender, "sendrawtransaction", JsonSerializer.Serialize(finalHex));
+
+ // *******************************
+ // VERIFY RESULTS
+ // Decode PSBT to get network fees
+ var decodedPsbtJson = RpcCall(sender, "decodepsbt", JsonSerializer.Serialize(finalPsbt));
+ using var decodedPsbtDoc = JsonDocument.Parse(decodedPsbtJson);
+ var networkFees = decodedPsbtDoc.RootElement.GetProperty("fee").GetDouble();
+
+ // Decode transaction to verify structure
+ var decodedTxJson = RpcCall(sender, "decoderawtransaction", JsonSerializer.Serialize(finalHex));
+ using var decodedTxDoc = JsonDocument.Parse(decodedTxJson);
+ var decodedTx = decodedTxDoc.RootElement;
+
+ var inputCount = decodedTx.GetProperty("vin").GetArrayLength();
+ var outputCount = decodedTx.GetProperty("vout").GetArrayLength();
+
+ Assert.Equal(2, inputCount); // Should have 2 inputs (sender + receiver)
+ Assert.Equal(1, outputCount); // Should have 1 output (to receiver)
+
+ // Verify receiver balance
+ var receiverBalancesJson = RpcCall(receiver, "getbalances");
+ using var receiverBalancesDoc = JsonDocument.Parse(receiverBalancesJson);
+ var receiverBalance = receiverBalancesDoc.RootElement
+ .GetProperty("mine")
+ .GetProperty("untrusted_pending")
+ .GetDouble();
+
+ Assert.Equal(100 - networkFees, receiverBalance, 6); // 100 BTC minus network fees
+
+ // Verify sender balance (should be 0 after sweeping)
+ var senderBalanceJson = RpcCall(sender, "getbalance");
+ var senderBalance = JsonSerializer.Deserialize(senderBalanceJson);
+ Assert.Equal(0.0, senderBalance);
+ }
+
+ private static string BuildSweepPsbt(RpcClient sender, PjUri pjUri)
+ {
+ var outputs = new Dictionary
+ {
+ [pjUri.Address()] = 50
+ };
+
+ var psbtJson = RpcCall(
+ sender,
+ "walletcreatefundedpsbt",
+ JsonSerializer.Serialize(Array.Empty