From 08a80c480748e0ada498804ecc380c4be78780e3 Mon Sep 17 00:00:00 2001
From: Valera <50830352+ValeraFinebits@users.noreply.github.com>
Date: Sun, 22 Feb 2026 13:28:44 +0200
Subject: [PATCH 1/3] Add test: FetchOhttpKeys in non-Tokio context
---
payjoin-ffi/csharp/IntegrationTests.cs | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs
index d17742a79..ccc67d6a5 100644
--- a/payjoin-ffi/csharp/IntegrationTests.cs
+++ b/payjoin-ffi/csharp/IntegrationTests.cs
@@ -323,6 +323,31 @@ public ValueTask DisposeAsync()
return ValueTask.CompletedTask;
}
+ ///
+ /// Regression test: called from a plain .NET async
+ /// context should successfully return OHTTP keys.
+ ///
+ /// Without the fix this test fails with:
+ /// PanicException: "there is no reactor running, must be called from the context of a Tokio 1.x runtime"
+ ///
+ [Fact]
+ public async Task FetchOhttpKeys_ShouldWorkFromNonTokioContext()
+ {
+ // Arrange: use TestServices' URLs so connectivity is guaranteed and
+ // the failure (if any) is purely about missing Tokio runtime, not networking.
+ _services!.WaitForServicesReady();
+ var ohttpRelay = _services.OhttpRelayUrl();
+ var directory = _services.DirectoryUrl();
+
+ // Act: call the raw UniFFI async binding directly — NOT TestServices.FetchOhttpKeys(),
+ // which uses an internal block_on(RUNTIME) and therefore always has a Tokio context.
+ // PayjoinMethods.FetchOhttpKeys() has no such safety net.
+ var keys = await PayjoinMethods.FetchOhttpKeys(ohttpRelay, directory);
+
+ // Assert
+ Assert.NotNull(keys);
+ }
+
[Fact]
public void TestFfiValidation()
{
From d9cbd21a579ba6bb84f8cdd4a7eddbd96dea5b2f Mon Sep 17 00:00:00 2001
From: chavic
Date: Sun, 22 Feb 2026 14:42:34 +0200
Subject: [PATCH 2/3] Enable Tokio runtime for UniFFI IO futures
FetchOhttpKeys is exported as a UniFFI async function and is polled
from plain .NET async threads in the C# binding. Those threads do not
carry a Tokio runtime, so reqwest panics with "there is no reactor
running" when the future is polled.
Enable UniFFI's tokio feature and mark fetch_ohttp_keys with
#[uniffi::export(async_runtime = "tokio")] so UniFFI polls it inside
a Tokio-compatible runtime context.
This fixes the runtime panic path and allows errors to be surfaced as
regular IO failures instead of a PanicException.
---
payjoin-ffi/Cargo.toml | 2 +-
payjoin-ffi/src/io.rs | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml
index c55bdd187..f186ec753 100644
--- a/payjoin-ffi/Cargo.toml
+++ b/payjoin-ffi/Cargo.toml
@@ -33,7 +33,7 @@ serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
thiserror = "2.0.14"
tokio = { version = "1.47.1", features = ["full"], optional = true }
-uniffi = { version = "0.30.0", features = ["cli"] }
+uniffi = { version = "0.30.0", features = ["cli", "tokio"] }
uniffi-bindgen-cs = { git = "https://github.com/chavic/uniffi-bindgen-cs.git", rev = "878a3d269eacce64beadcd336ade0b7c8da09824", optional = true }
uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "f830323", optional = true }
url = "2.5.4"
diff --git a/payjoin-ffi/src/io.rs b/payjoin-ffi/src/io.rs
index 34a1f6d5c..6d2060056 100644
--- a/payjoin-ffi/src/io.rs
+++ b/payjoin-ffi/src/io.rs
@@ -21,7 +21,7 @@ pub mod error {
///
/// * `payjoin_directory`: The payjoin directory from which to fetch the ohttp keys. This
/// directory stores and forwards payjoin client payloads.
-#[uniffi::export]
+#[uniffi::export(async_runtime = "tokio")]
pub async fn fetch_ohttp_keys(
ohttp_relay: &str,
payjoin_directory: &str,
From 6c5441faa5cc22ddc5caeb640bcfc48d8e3f5408 Mon Sep 17 00:00:00 2001
From: chavic
Date: Sun, 22 Feb 2026 15:10:57 +0200
Subject: [PATCH 3/3] Use cert-aware OHTTP fetch in C# test
After enabling Tokio runtime polling for UniFFI async IO, the C#
non-Tokio regression moved past PanicException and began failing with an
IoException against local HTTPS test services.
That failure was a TLS trust issue: TestServices uses a generated
self-signed certificate, while FetchOhttpKeys uses default trust.
The regression should validate async runtime behavior, not certificate
store configuration.
Switch the regression to FetchOhttpKeysWithCert and pass TestServices
certificate bytes. Also export fetch_ohttp_keys_with_cert with Tokio
runtime polling and include _manual-tls in default C# binding
generation features so local and CI builds stay consistent.
---
payjoin-ffi/csharp/IntegrationTests.cs | 11 ++++++-----
payjoin-ffi/csharp/README.md | 6 +++---
payjoin-ffi/csharp/scripts/generate_bindings.ps1 | 5 +++--
payjoin-ffi/csharp/scripts/generate_bindings.sh | 5 +++--
payjoin-ffi/src/io.rs | 1 +
5 files changed, 16 insertions(+), 12 deletions(-)
diff --git a/payjoin-ffi/csharp/IntegrationTests.cs b/payjoin-ffi/csharp/IntegrationTests.cs
index ccc67d6a5..cc5150690 100644
--- a/payjoin-ffi/csharp/IntegrationTests.cs
+++ b/payjoin-ffi/csharp/IntegrationTests.cs
@@ -324,7 +324,7 @@ public ValueTask DisposeAsync()
}
///
- /// Regression test: called from a plain .NET async
+ /// Regression test: called from a plain .NET async
/// context should successfully return OHTTP keys.
///
/// Without the fix this test fails with:
@@ -333,16 +333,17 @@ public ValueTask DisposeAsync()
[Fact]
public async Task FetchOhttpKeys_ShouldWorkFromNonTokioContext()
{
- // Arrange: use TestServices' URLs so connectivity is guaranteed and
- // the failure (if any) is purely about missing Tokio runtime, not networking.
+ // Arrange: use TestServices' URLs and certificate so connectivity is guaranteed and
+ // the failure (if any) is purely about missing Tokio runtime, not TLS trust setup.
_services!.WaitForServicesReady();
var ohttpRelay = _services.OhttpRelayUrl();
var directory = _services.DirectoryUrl();
+ var cert = _services.Cert();
// Act: call the raw UniFFI async binding directly — NOT TestServices.FetchOhttpKeys(),
// which uses an internal block_on(RUNTIME) and therefore always has a Tokio context.
- // PayjoinMethods.FetchOhttpKeys() has no such safety net.
- var keys = await PayjoinMethods.FetchOhttpKeys(ohttpRelay, directory);
+ // PayjoinMethods.FetchOhttpKeysWithCert() has no such safety net.
+ var keys = await PayjoinMethods.FetchOhttpKeysWithCert(ohttpRelay, directory, cert);
// Assert
Assert.NotNull(keys);
diff --git a/payjoin-ffi/csharp/README.md b/payjoin-ffi/csharp/README.md
index 9afccc5c3..a24fbb074 100644
--- a/payjoin-ffi/csharp/README.md
+++ b/payjoin-ffi/csharp/README.md
@@ -56,12 +56,12 @@ dotnet test
Generation uses the Cargo-managed C# generator from `payjoin-ffi/Cargo.toml`.
-By default, generation builds `payjoin-ffi` with `_test-utils` enabled to keep parity with other language test scripts. Override via `PAYJOIN_FFI_FEATURES`.
+By default, generation builds `payjoin-ffi` with `_test-utils,_manual-tls` so C# integration tests can use local HTTPS services with generated self-signed certificates. Override via `PAYJOIN_FFI_FEATURES`.
### Unix shells
```shell
-export PAYJOIN_FFI_FEATURES=_test-utils # default behavior
+export PAYJOIN_FFI_FEATURES=_test-utils,_manual-tls # default behavior
# export PAYJOIN_FFI_FEATURES="" # build without extra features
bash ./scripts/generate_bindings.sh
```
@@ -69,7 +69,7 @@ bash ./scripts/generate_bindings.sh
### PowerShell
```powershell
-$env:PAYJOIN_FFI_FEATURES = "_test-utils" # default behavior
+$env:PAYJOIN_FFI_FEATURES = "_test-utils,_manual-tls" # default behavior
# $env:PAYJOIN_FFI_FEATURES = "" # build without extra features
powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1
dotnet build
diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.ps1 b/payjoin-ffi/csharp/scripts/generate_bindings.ps1
index 52e782508..ff195ef87 100644
--- a/payjoin-ffi/csharp/scripts/generate_bindings.ps1
+++ b/payjoin-ffi/csharp/scripts/generate_bindings.ps1
@@ -40,8 +40,9 @@ Write-Host "Generating payjoin C#..."
if ($null -ne $env:PAYJOIN_FFI_FEATURES) {
$payjoinFfiFeatures = $env:PAYJOIN_FFI_FEATURES
} else {
- # Keep parity with other language test scripts: include _test-utils by default.
- $payjoinFfiFeatures = "_test-utils"
+ # Include test utilities and manual TLS by default so local test services
+ # can fetch OHTTP keys over HTTPS with their generated self-signed cert.
+ $payjoinFfiFeatures = "_test-utils,_manual-tls"
}
if ($payjoinFfiFeatures) {
diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.sh b/payjoin-ffi/csharp/scripts/generate_bindings.sh
index 630ab2146..5de84d415 100755
--- a/payjoin-ffi/csharp/scripts/generate_bindings.sh
+++ b/payjoin-ffi/csharp/scripts/generate_bindings.sh
@@ -22,8 +22,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/../.."
echo "Generating payjoin C#..."
-# Keep parity with other language test scripts: include _test-utils by default.
-PAYJOIN_FFI_FEATURES=${PAYJOIN_FFI_FEATURES:-_test-utils}
+# Include test utilities and manual TLS by default so local test services
+# can fetch OHTTP keys over HTTPS with their generated self-signed cert.
+PAYJOIN_FFI_FEATURES=${PAYJOIN_FFI_FEATURES:-_test-utils,_manual-tls}
GENERATOR_FEATURES="csharp"
if [[ -n $PAYJOIN_FFI_FEATURES ]]; then
GENERATOR_FEATURES="$GENERATOR_FEATURES,$PAYJOIN_FFI_FEATURES"
diff --git a/payjoin-ffi/src/io.rs b/payjoin-ffi/src/io.rs
index 6d2060056..6069c4e75 100644
--- a/payjoin-ffi/src/io.rs
+++ b/payjoin-ffi/src/io.rs
@@ -43,6 +43,7 @@ pub async fn fetch_ohttp_keys(
///
/// * `cert_der`: The DER-encoded certificate to use for local HTTPS connections.
#[cfg(feature = "_manual-tls")]
+#[uniffi::export(async_runtime = "tokio")]
pub async fn fetch_ohttp_keys_with_cert(
ohttp_relay: &str,
payjoin_directory: &str,