diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml
index b4c1817a2..9d75299f3 100644
--- a/.github/workflows/csharp.yml
+++ b/.github/workflows/csharp.yml
@@ -60,4 +60,184 @@ jobs:
run: ./scripts/generate_bindings.ps1
- name: Run tests
- run: dotnet test --logger "console;verbosity=minimal"
+ run: dotnet test Payjoin.Tests.csproj --logger "console;verbosity=minimal"
+
+ build-nuget-native:
+ # Every RID cross-compiles from Linux: cargo-zigbuild links the Linux targets
+ # against a pinned glibc floor and the macOS targets against zig's bundled
+ # libSystem stubs; cargo-xwin links MSVC-ABI Windows targets against the
+ # Microsoft CRT/SDK. The per-RID smoke jobs verify each produced asset on
+ # real hardware.
+ name: "Build NuGet native asset"
+ runs-on: ubuntu-latest
+ env:
+ # Must match the dtolnay/rust-toolchain version exactly: the cross-target
+ # std components are installed for that toolchain, and a partial version
+ # here selects a different rustup toolchain entry without them (E0463).
+ RUSTUP_TOOLCHAIN: 1.85.0
+ defaults:
+ run:
+ working-directory: payjoin-ffi/csharp
+ strategy:
+ # Let every RID report its own result; a failure in one target should not
+ # cancel the signal from the others.
+ fail-fast: false
+ matrix:
+ include:
+ - rid: linux-arm64
+ target: aarch64-unknown-linux-gnu
+ - rid: linux-x64
+ target: x86_64-unknown-linux-gnu
+ - rid: osx-arm64
+ target: aarch64-apple-darwin
+ - rid: osx-x64
+ target: x86_64-apple-darwin
+ - rid: win-arm64
+ target: aarch64-pc-windows-msvc
+ - rid: win-x64
+ target: x86_64-pc-windows-msvc
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Install Rust 1.85.0
+ uses: dtolnay/rust-toolchain@1.85.0
+ with:
+ targets: ${{ matrix.target }}
+
+ - name: Use cache
+ uses: Swatinem/rust-cache@v2
+
+ - name: Install cross toolchain
+ # Version- and hash-pinned: these tools produce the shipped binaries.
+ run: pip install --require-hashes -r scripts/cross-requirements.txt
+
+ - name: Install MSVC toolchain frontends (clang-cl, llvm-lib)
+ if: startsWith(matrix.rid, 'win-')
+ # cargo-xwin compiles C dependencies with clang-cl and archives them with
+ # llvm-lib. The runner image ships LLVM with versioned binary names only,
+ # so expose the unversioned names cc-rs looks for.
+ run: |
+ for tool in clang-cl llvm-lib; do
+ if command -v "$tool" >/dev/null; then
+ continue
+ fi
+ latest=$(ls -1 /usr/bin/"$tool"-* 2>/dev/null | sort -V | tail -n 1)
+ if [ -z "$latest" ]; then
+ sudo apt-get update -q
+ sudo apt-get install -y -q llvm clang
+ latest=$(ls -1 /usr/bin/"$tool"-* 2>/dev/null | sort -V | tail -n 1)
+ fi
+ if [ -z "$latest" ]; then
+ echo "$tool not found on the runner" >&2
+ exit 1
+ fi
+ sudo ln -s "$latest" /usr/local/bin/"$tool"
+ done
+ command -v clang-cl llvm-lib
+
+ - name: Build release native asset
+ run: |
+ PAYJOIN_FFI_CROSS=1 PAYJOIN_FFI_RID=${{ matrix.rid }} bash ./scripts/build_nuget_native.sh
+
+ - name: Upload NuGet native asset
+ uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.rid }}
+ path: payjoin-ffi/csharp/artifacts/runtimes/${{ matrix.rid }}/native/*
+ if-no-files-found: error
+
+ pack-nuget:
+ name: "Pack C# NuGet package"
+ runs-on: ubuntu-latest
+ needs: build-nuget-native
+ env:
+ # Exact version so the packaged bindings build with the toolchain the
+ # action installs, not an implicitly-installed latest 1.85.x.
+ RUSTUP_TOOLCHAIN: 1.85.0
+ defaults:
+ run:
+ working-directory: payjoin-ffi/csharp
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Install Rust 1.85.0
+ uses: dtolnay/rust-toolchain@1.85.0
+
+ - name: Use cache
+ uses: Swatinem/rust-cache@v2
+
+ - name: Install .NET 10 SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Download native assets
+ uses: actions/download-artifact@v4
+ with:
+ path: payjoin-ffi/csharp/artifacts/downloaded
+
+ - name: Assemble RID assets
+ run: |
+ for rid in linux-arm64 linux-x64 osx-arm64 osx-x64 win-arm64 win-x64; do
+ mkdir -p "artifacts/runtimes/$rid/native"
+ cp "artifacts/downloaded/$rid"/* "artifacts/runtimes/$rid/native/"
+ done
+
+ - name: Generate production bindings
+ run: |
+ PAYJOIN_FFI_FEATURES= PAYJOIN_FFI_PROFILE=release bash ./scripts/generate_bindings.sh
+
+ - name: Pack NuGet package
+ run: dotnet pack Payjoin.csproj --configuration Release --output artifacts/packages
+
+ - name: Upload NuGet package
+ uses: actions/upload-artifact@v4
+ with:
+ name: payjoin-csharp-nuget-package
+ path: payjoin-ffi/csharp/artifacts/packages/*.nupkg
+ if-no-files-found: error
+
+ smoke-nuget:
+ name: "Smoke test C# NuGet package"
+ runs-on: ${{ matrix.os }}
+ needs: pack-nuget
+ defaults:
+ run:
+ working-directory: payjoin-ffi/csharp
+ strategy:
+ matrix:
+ include:
+ - os: ubuntu-24.04-arm
+ rid: linux-arm64
+ - os: ubuntu-latest
+ rid: linux-x64
+ - os: macos-latest
+ rid: osx-arm64
+ - os: macos-15-intel
+ rid: osx-x64
+ - os: windows-11-arm
+ rid: win-arm64
+ - os: windows-latest
+ rid: win-x64
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Install .NET 10 SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "10.0.x"
+
+ - name: Download NuGet package
+ uses: actions/download-artifact@v4
+ with:
+ name: payjoin-csharp-nuget-package
+ path: payjoin-ffi/csharp/artifacts/packages
+
+ - name: Run package smoke test
+ shell: bash
+ # 'auto' derives the version from the packed artifact, so Payjoin.csproj
+ # stays the only place the package version is maintained.
+ run: bash ./scripts/smoke_nuget_package.sh artifacts/packages auto ${{ matrix.rid }}
diff --git a/payjoin-ffi/csharp/.gitignore b/payjoin-ffi/csharp/.gitignore
index deed121f5..95fe7098f 100644
--- a/payjoin-ffi/csharp/.gitignore
+++ b/payjoin-ffi/csharp/.gitignore
@@ -4,6 +4,7 @@ src/*.cs
# Build outputs
bin/
obj/
+artifacts/
# IDE
.vs/
diff --git a/payjoin-ffi/csharp/Directory.Build.props b/payjoin-ffi/csharp/Directory.Build.props
new file mode 100644
index 000000000..6bfb052b4
--- /dev/null
+++ b/payjoin-ffi/csharp/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+ obj/$(MSBuildProjectName)/
+ bin/$(MSBuildProjectName)/
+ $(DefaultItemExcludes);obj/**;bin/**;artifacts/**
+
+
diff --git a/payjoin-ffi/csharp/Payjoin.Http.cs b/payjoin-ffi/csharp/Payjoin.Http.cs
index 0ce5136ca..33af61489 100644
--- a/payjoin-ffi/csharp/Payjoin.Http.cs
+++ b/payjoin-ffi/csharp/Payjoin.Http.cs
@@ -6,7 +6,7 @@
namespace Payjoin.Http
{
- internal sealed class OhttpKeysClient : IDisposable
+ public sealed class OhttpKeysClient : IDisposable
{
private readonly HttpClient _client;
private readonly HttpClientHandler _handler;
diff --git a/payjoin-ffi/csharp/Payjoin.Tests.csproj b/payjoin-ffi/csharp/Payjoin.Tests.csproj
index 41481ab97..93025616d 100644
--- a/payjoin-ffi/csharp/Payjoin.Tests.csproj
+++ b/payjoin-ffi/csharp/Payjoin.Tests.csproj
@@ -16,6 +16,12 @@
all
+
+
+
+
+
+
diff --git a/payjoin-ffi/csharp/Payjoin.csproj b/payjoin-ffi/csharp/Payjoin.csproj
new file mode 100644
index 000000000..5dd9357ad
--- /dev/null
+++ b/payjoin-ffi/csharp/Payjoin.csproj
@@ -0,0 +1,63 @@
+
+
+
+ net10.0
+ enable
+ enable
+ true
+ true
+ Payjoin
+ 0.24.0-preview.1
+ Payjoin
+ Payjoin Dev Kit Contributors
+ C# bindings for payjoin-ffi, generated from rust-payjoin via UniFFI.
+ bitcoin;payjoin;bip78;ffi;csharp;dotnet
+ https://github.com/payjoin/rust-payjoin
+ https://github.com/payjoin/rust-payjoin
+ git
+ MIT OR Apache-2.0
+ README.md
+ false
+ false
+ false
+
+ $(NoWarn);CS0108;CS0659;NU5131
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/payjoin-ffi/csharp/Payjoin.nuspec b/payjoin-ffi/csharp/Payjoin.nuspec
deleted file mode 100644
index 7ffd37c32..000000000
--- a/payjoin-ffi/csharp/Payjoin.nuspec
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
- Payjoin
- 0.24.0
- Payjoin
- Payjoin Dev Kit Contributors
- payjoin
- false
- MIT OR Apache-2.0
- README.md
- https://github.com/payjoin/rust-payjoin
-
- C# bindings for payjoin-ffi, generated from rust-payjoin via UniFFI.
- bitcoin payjoin bip78 ffi csharp dotnet
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/payjoin-ffi/csharp/README.md b/payjoin-ffi/csharp/README.md
index 9c1c19f38..6c6d0f98d 100644
--- a/payjoin-ffi/csharp/README.md
+++ b/payjoin-ffi/csharp/README.md
@@ -1,10 +1,50 @@
# Payjoin C# Bindings
-Welcome to the C# language bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/)!
+C# bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/), generated from
+`payjoin-ffi` with UniFFI.
-## Running Tests
+The NuGet package is still prepared as a preview while the C# API stabilizes. The
+first release-ready package layout targets .NET 10 and ships a managed
+`Payjoin.dll` plus RID-specific native `payjoin_ffi` libraries.
-Follow these steps to clone the repository and run the tests.
+## Install
+
+```shell
+dotnet add package Payjoin --prerelease
+```
+
+## Requirements
+
+- .NET 10.0 or higher
+- A supported RID native asset in the package
+
+The first preview release matrix is:
+
+| OS | RID | Native library |
+| ------------------- | ------------- | ---------------------- |
+| Linux arm64 | `linux-arm64` | `libpayjoin_ffi.so` |
+| Linux x64 | `linux-x64` | `libpayjoin_ffi.so` |
+| macOS Apple Silicon | `osx-arm64` | `libpayjoin_ffi.dylib` |
+| macOS x64 | `osx-x64` | `libpayjoin_ffi.dylib` |
+| Windows arm64 | `win-arm64` | `payjoin_ffi.dll` |
+| Windows x64 | `win-x64` | `payjoin_ffi.dll` |
+
+The package follows the .NET native asset layout:
+
+- `ref/net10.0/Payjoin.dll`
+- `runtimes/any/lib/net10.0/Payjoin.dll`
+- `runtimes/{rid}/native/{native-library}`
+
+## Minimal Usage
+
+```csharp
+var uri = Payjoin.Url.Parse(
+ "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao");
+
+Console.WriteLine(uri.AsString());
+```
+
+## Development
With nix, the C# development shell provides the Rust toolchain and .NET 10 SDK:
@@ -12,91 +52,84 @@ With nix, the C# development shell provides the Rust toolchain and .NET 10 SDK:
nix develop .#csharp -c bash payjoin-ffi/csharp/contrib/test.sh
```
+Without nix, a Rust toolchain (MSRV: 1.85.0 for this repository) and the .NET 10
+SDK are required:
+
```shell
git clone https://github.com/payjoin/rust-payjoin.git
cd rust-payjoin/payjoin-ffi/csharp
-# Generate the bindings
bash ./scripts/generate_bindings.sh
-
-# Build the project
-dotnet build
-
-# Run all tests
-dotnet test
+dotnet build Payjoin.Tests.csproj
+dotnet test Payjoin.Tests.csproj
```
-### Windows (PowerShell)
+### Windows
```powershell
git clone https://github.com/payjoin/rust-payjoin.git
cd rust-payjoin/payjoin-ffi/csharp
-# Generate the bindings
powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1
+dotnet build Payjoin.Tests.csproj
+dotnet test Payjoin.Tests.csproj
+```
-# Build the project
-dotnet build
+Generation uses the Cargo-managed C# generator pinned in `payjoin-ffi/Cargo.toml`.
+By default, development generation enables `_test-utils` to keep parity with the
+test suite. For production bindings, set `PAYJOIN_FFI_FEATURES` to an empty value
+(bash) or pass `-ProductionBindings` (PowerShell — Windows cannot represent an
+empty environment variable, so the switch is the only reliable signal there).
-# Run all tests
-dotnet test
-```
+## Packaging
-### Windows (Git Bash / MSYS)
+Build the release native asset for the current host RID (production features are
+the default when `PAYJOIN_FFI_FEATURES` is not set; this step does not regenerate
+the C# bindings):
```shell
-git clone https://github.com/payjoin/rust-payjoin.git
-cd rust-payjoin/payjoin-ffi/csharp
-bash ./scripts/generate_bindings.sh
-dotnet build
-dotnet test
+bash ./scripts/build_nuget_native.sh
```
-## Requirements
-
-- .NET 10.0 or higher
-- Rust toolchain (MSRV: 1.85.0 for this repository)
-- Cargo will fetch the C# generator from `chavic/uniffi-bindgen-cs` at commit `878a3d269eacce64beadcd336ade0b7c8da09824` (pinned in `payjoin-ffi/Cargo.toml`)
-
-## Configuration
-
-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`.
-
-### Unix shells
+Any supported RID can also be cross-compiled from a Linux host, which is how CI
+builds every native asset (`pip install -r scripts/cross-requirements.txt` for
+the version- and hash-pinned toolchain CI uses, and `rustup target add` the
+matching triple first):
```shell
-export PAYJOIN_FFI_FEATURES=_test-utils # default behavior
-# export PAYJOIN_FFI_FEATURES="" # build without extra features
-bash ./scripts/generate_bindings.sh
+PAYJOIN_FFI_CROSS=1 PAYJOIN_FFI_RID=osx-arm64 bash ./scripts/build_nuget_native.sh
```
-### PowerShell
+On Windows:
```powershell
-$env:PAYJOIN_FFI_FEATURES = "_test-utils" # default behavior
-# $env:PAYJOIN_FFI_FEATURES = "" # build without extra features
-powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1
-dotnet build
+powershell -ExecutionPolicy Bypass -File .\scripts\build_nuget_native.ps1
```
-## NuGet Packaging (Draft)
-
-`Payjoin.nuspec` is included for packaging the generated C# source plus native library artifacts.
+To pack, gather every supported RID under `artifacts/runtimes/{rid}/native/`,
+generate production bindings, and run:
-Before packing, make sure generation has produced:
+```shell
+PAYJOIN_FFI_FEATURES= PAYJOIN_FFI_PROFILE=release bash ./scripts/generate_bindings.sh
+dotnet pack Payjoin.csproj --configuration Release --output artifacts/packages
+```
-- `src/payjoin.cs`
-- `lib/*` (native library for the current platform)
+On Windows:
-Note: this packs the native library currently present in `lib/`. For a cross-platform package, build and include native artifacts from each target platform in CI before publishing.
+```powershell
+$env:PAYJOIN_FFI_PROFILE = "release"
+powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1 -ProductionBindings
+dotnet pack Payjoin.csproj --configuration Release --output artifacts/packages
+```
-Example:
+Validate the package in a clean sample app (`auto` derives the version from the
+packed artifact; an explicit version is also accepted):
-```powershell
-powershell -ExecutionPolicy Bypass -File .\scripts\generate_bindings.ps1
-nuget pack .\Payjoin.nuspec -Version 0.24.0
+```shell
+bash ./scripts/smoke_nuget_package.sh artifacts/packages auto linux-x64
```
-If `nuget` is not installed, install NuGet.CommandLine first (for example via `dotnet tool` or your package manager).
+CI performs the package build from release native assets and runs the smoke test
+on each supported RID before publishing should be considered. The maintainer
+release and publish workflow is documented in
+[`RELEASING.md`](https://github.com/payjoin/rust-payjoin/blob/master/payjoin-ffi/csharp/RELEASING.md).
diff --git a/payjoin-ffi/csharp/RELEASING.md b/payjoin-ffi/csharp/RELEASING.md
new file mode 100644
index 000000000..fc86cad12
--- /dev/null
+++ b/payjoin-ffi/csharp/RELEASING.md
@@ -0,0 +1,121 @@
+# Releasing the Payjoin NuGet Package
+
+Maintainer documentation for publishing the `Payjoin` package to nuget.org.
+Consumer documentation lives in [`README.md`](README.md), which ships as the
+package readme.
+
+## Versioning
+
+- The package version is set in `Payjoin.csproj` (``).
+- It tracks the `payjoin-ffi` crate version from `payjoin-ffi/Cargo.toml`,
+ with a `-preview.N` suffix while the C# API stabilizes. For example,
+ `0.24.0-preview.1` packages `payjoin-ffi 0.24.0`. Pre-release suffixes
+ follow [SemVer], per NuGet's [package versioning] guidance.
+- Bump only the `-preview.N` suffix for packaging-only fixes. Bump
+ `MAJOR.MINOR.PATCH` together with a `payjoin-ffi` version bump.
+- `Payjoin.csproj` is the only place the version is maintained: the CI smoke
+ test derives the version from the packed artifact.
+
+## Producing a release candidate
+
+CI is the release path. On every pull request touching `payjoin-ffi/**`, the
+`Build and Test CSharp` workflow:
+
+1. builds release-profile native assets for each supported RID
+ (`linux-arm64`, `linux-x64`, `osx-arm64`, `osx-x64`, `win-arm64`,
+ `win-x64`),
+2. generates production bindings (no `_test-utils`) and packs the `.nupkg`
+ with all RID assets,
+3. installs the package into a clean console app and runs a smoke test on
+ each supported RID.
+
+To cut a candidate, download the `payjoin-csharp-nuget-package` artifact from
+the workflow run on the release commit in `master`. Do not pack from a
+development machine for publication; a local pack only contains the native
+assets present on that host.
+
+## Release readiness checklist
+
+Review before every publish to nuget.org. Grounded in the NuGet
+[publish guide], [package authoring best practices], and
+[native library packaging] documentation.
+
+### Package correctness
+
+- [ ] Every job of the `Build and Test CSharp` workflow is green on the
+ release commit, including the per-RID smoke tests.
+- [ ] `unzip -l Payjoin..nupkg` shows the expected layout:
+ - `README.md`
+ - `ref/net10.0/Payjoin.dll`
+ - `runtimes/any/lib/net10.0/Payjoin.dll`
+ - `runtimes/linux-arm64/native/libpayjoin_ffi.so`
+ - `runtimes/linux-x64/native/libpayjoin_ffi.so`
+ - `runtimes/osx-arm64/native/libpayjoin_ffi.dylib`
+ - `runtimes/osx-x64/native/libpayjoin_ffi.dylib`
+ - `runtimes/win-arm64/native/payjoin_ffi.dll`
+ - `runtimes/win-x64/native/payjoin_ffi.dll`
+- [ ] Native assets are release-profile builds without `_test-utils` (the
+ pack step's validation target enforces both; confirm it ran in CI).
+- [ ] The package is under nuget.org's 250 MB size limit.
+- [ ] Package version in `Payjoin.csproj` matches `payjoin-ffi`'s crate
+ version plus the intended pre-release suffix.
+
+### Metadata and trust
+
+- [ ] README renders correctly (verify with nuget.org upload preview or the
+ [readme preview] guidance) and its install command, support matrix, and
+ minimal usage are accurate for this version.
+- [ ] License expression, project URL, repository URL, and tags are present
+ and correct in `Payjoin.csproj`.
+- [ ] Release notes for this version exist (GitHub release or changelog
+ entry) and breaking changes are called out — required for any version
+ that changes the package model consumers depend on.
+- [ ] Ownership of the `Payjoin` package ID on nuget.org is confirmed for the
+ publishing account (nuget.org assigns ownership to the pushing account,
+ not the `Authors` field).
+
+### Publish security
+
+- [ ] The nuget.org publishing account has two-factor authentication enabled.
+- [ ] The push uses a scoped API key (push-only, `Payjoin` glob, short
+ expiry) per [scoped API keys], or the `NUGET_API_KEY` environment
+ variable (.NET SDK 10.0.300+) so the key never appears in shell
+ history.
+
+### Post-publish verification
+
+- [ ] Package passes nuget.org validation and indexing (usually under 15
+ minutes; the confirmation email arrives when it is listed).
+- [ ] `dotnet new console && dotnet add package Payjoin --prerelease`
+ restores, builds, and runs on at least one supported RID from the live
+ feed.
+- [ ] Listing on shows the readme,
+ license, and repository metadata as intended.
+- [ ] Decide whether older versions (for example the `0.0.1` placeholder)
+ should be unlisted or deprecated now that a real release exists.
+
+## Publishing
+
+1. Work through the release readiness checklist above.
+2. Push the CI-built package:
+
+ ```shell
+ dotnet nuget push Payjoin..nupkg \
+ --source https://api.nuget.org/v3/index.json \
+ --api-key
+ ```
+
+3. Complete the post-publish verification section of the checklist.
+
+Publishing is intentionally manual while the package is in preview. Only
+maintainers with nuget.org ownership of the `Payjoin` package ID can push.
+If publishing later moves into CI, prefer nuget.org trusted publishing over
+long-lived API keys.
+
+[SemVer]: https://semver.org/
+[package versioning]: https://learn.microsoft.com/en-us/nuget/concepts/package-versioning
+[publish guide]: https://learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package
+[package authoring best practices]: https://learn.microsoft.com/en-us/nuget/create-packages/package-authoring-best-practices
+[native library packaging]: https://learn.microsoft.com/en-us/nuget/create-packages/native-files-in-net-packages
+[readme preview]: https://learn.microsoft.com/en-us/nuget/nuget-org/package-readme-on-nuget-org
+[scoped API keys]: https://learn.microsoft.com/en-us/nuget/nuget-org/scoped-api-keys
diff --git a/payjoin-ffi/csharp/contrib/test.sh b/payjoin-ffi/csharp/contrib/test.sh
index 73cfcf3df..3c25810fa 100755
--- a/payjoin-ffi/csharp/contrib/test.sh
+++ b/payjoin-ffi/csharp/contrib/test.sh
@@ -7,4 +7,4 @@ echo "==> Generating FFI bindings..."
bash ./scripts/generate_bindings.sh
echo "==> Running C# tests..."
-dotnet test --logger "console;verbosity=minimal"
+dotnet test Payjoin.Tests.csproj --logger "console;verbosity=minimal"
diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.ps1 b/payjoin-ffi/csharp/scripts/build_nuget_native.ps1
new file mode 100644
index 000000000..cf73972f0
--- /dev/null
+++ b/payjoin-ffi/csharp/scripts/build_nuget_native.ps1
@@ -0,0 +1,79 @@
+$ErrorActionPreference = "Stop"
+$PSNativeCommandUseErrorActionPreference = $true
+
+function Get-PayjoinArchitecture {
+ $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
+ switch ($architecture) {
+ "X64" { "x64"; break }
+ "Arm64" { "arm64"; break }
+ default { throw "Unsupported architecture: $architecture" }
+ }
+}
+
+function Get-PayjoinRid {
+ $arch = Get-PayjoinArchitecture
+ if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) {
+ return "win-$arch"
+ }
+ if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::OSX)) {
+ return "osx-$arch"
+ }
+ if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Linux)) {
+ return "linux-$arch"
+ }
+
+ throw "Unsupported OS: $([System.Runtime.InteropServices.RuntimeInformation]::OSDescription)"
+}
+
+function Get-NativeLibraryName {
+ param([Parameter(Mandatory = $true)][string] $Rid)
+
+ if ($Rid.StartsWith("win-")) {
+ return "payjoin_ffi.dll"
+ }
+ if ($Rid.StartsWith("osx-")) {
+ return "libpayjoin_ffi.dylib"
+ }
+ if ($Rid.StartsWith("linux-")) {
+ return "libpayjoin_ffi.so"
+ }
+
+ throw "Unsupported RID: $Rid"
+}
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$csharpDir = Resolve-Path (Join-Path $scriptDir "..")
+Set-Location $csharpDir
+
+if ($env:PAYJOIN_FFI_RID) {
+ $rid = $env:PAYJOIN_FFI_RID
+} else {
+ $rid = Get-PayjoinRid
+}
+
+$libName = Get-NativeLibraryName -Rid $rid
+
+# On Windows, assigning "" to an environment variable removes it, so we cannot mark production
+# bindings by clearing PAYJOIN_FFI_FEATURES the way the bash script does. When the caller has not
+# requested specific features, ask generate_bindings.ps1 for production bindings explicitly; an
+# explicitly-set PAYJOIN_FFI_FEATURES is still forwarded through the environment.
+$useProductionBindings = $null -eq $env:PAYJOIN_FFI_FEATURES
+if (-not $env:PAYJOIN_FFI_PROFILE) {
+ $env:PAYJOIN_FFI_PROFILE = "release"
+}
+
+if ($useProductionBindings) {
+ & (Join-Path $csharpDir "scripts/generate_bindings.ps1") -ProductionBindings -NativeOnly
+} else {
+ & (Join-Path $csharpDir "scripts/generate_bindings.ps1") -NativeOnly
+}
+if ($LASTEXITCODE -ne 0) {
+ throw "generate_bindings.ps1 failed with exit code $LASTEXITCODE"
+}
+
+$artifactDir = Join-Path $csharpDir "artifacts/runtimes/$rid/native"
+Remove-Item $artifactDir -Recurse -Force -ErrorAction SilentlyContinue
+New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null
+Copy-Item (Join-Path $csharpDir "lib/$libName") (Join-Path $artifactDir $libName) -Force
+
+Write-Host "Wrote $artifactDir/$libName"
diff --git a/payjoin-ffi/csharp/scripts/build_nuget_native.sh b/payjoin-ffi/csharp/scripts/build_nuget_native.sh
new file mode 100755
index 000000000..979c9aef9
--- /dev/null
+++ b/payjoin-ffi/csharp/scripts/build_nuget_native.sh
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+detect_arch() {
+ case "$(uname -m)" in
+ x86_64 | amd64)
+ echo "x64"
+ ;;
+ arm64 | aarch64)
+ echo "arm64"
+ ;;
+ *)
+ echo "Unsupported architecture: $(uname -m)" >&2
+ exit 1
+ ;;
+ esac
+}
+
+detect_rid() {
+ local arch
+ arch=$(detect_arch)
+
+ case "$(uname -s)" in
+ Linux)
+ echo "linux-$arch"
+ ;;
+ Darwin)
+ echo "osx-$arch"
+ ;;
+ MINGW* | MSYS* | CYGWIN*)
+ echo "win-$arch"
+ ;;
+ *)
+ echo "Unsupported os: $(uname -s)" >&2
+ exit 1
+ ;;
+ esac
+}
+
+native_library_name() {
+ case "$1" in
+ linux-*)
+ echo "libpayjoin_ffi.so"
+ ;;
+ osx-*)
+ echo "libpayjoin_ffi.dylib"
+ ;;
+ win-*)
+ echo "payjoin_ffi.dll"
+ ;;
+ *)
+ echo "Unsupported RID: $1" >&2
+ exit 1
+ ;;
+ esac
+}
+
+# Rust target triple for cross-compiling a RID from a Linux host. Linux targets pin a
+# glibc floor through zig so the produced .so loads on older distributions instead of
+# inheriting the build host's glibc version.
+rid_to_cross_target() {
+ case "$1" in
+ linux-x64)
+ echo "x86_64-unknown-linux-gnu.$PAYJOIN_FFI_GLIBC_FLOOR"
+ ;;
+ linux-arm64)
+ echo "aarch64-unknown-linux-gnu.$PAYJOIN_FFI_GLIBC_FLOOR"
+ ;;
+ osx-x64)
+ echo "x86_64-apple-darwin"
+ ;;
+ osx-arm64)
+ echo "aarch64-apple-darwin"
+ ;;
+ win-x64)
+ echo "x86_64-pc-windows-msvc"
+ ;;
+ win-arm64)
+ echo "aarch64-pc-windows-msvc"
+ ;;
+ *)
+ echo "Unsupported RID for cross build: $1" >&2
+ exit 1
+ ;;
+ esac
+}
+
+# cargo-zigbuild links Linux (with a chosen glibc floor) and macOS targets (zig ships
+# redistributable libSystem stubs); cargo-xwin links MSVC-ABI Windows targets against
+# the Microsoft CRT/SDK it fetches.
+rid_to_cross_tool() {
+ case "$1" in
+ linux-* | osx-*)
+ echo "zigbuild"
+ ;;
+ win-*)
+ echo "xwin"
+ ;;
+ esac
+}
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CSHARP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+cd "$CSHARP_DIR"
+
+RID=${PAYJOIN_FFI_RID:-$(detect_rid)}
+LIBNAME=$(native_library_name "$RID")
+
+export PAYJOIN_FFI_FEATURES="${PAYJOIN_FFI_FEATURES-}"
+export PAYJOIN_FFI_PROFILE="${PAYJOIN_FFI_PROFILE:-release}"
+PAYJOIN_FFI_GLIBC_FLOOR=${PAYJOIN_FFI_GLIBC_FLOOR:-2.17}
+
+if [[ ${PAYJOIN_FFI_CROSS:-0} == 1 ]]; then
+ # Cross-compile the native library for $RID from a Linux host. Bindings are never
+ # generated here; the pack step generates the production bindings it packages.
+ TARGET=$(rid_to_cross_target "$RID")
+ TOOL=$(rid_to_cross_tool "$RID")
+ if ! command -v "cargo-$TOOL" >/dev/null; then
+ echo "cargo-$TOOL is required for cross builds: pip install cargo-zigbuild cargo-xwin" >&2
+ exit 1
+ fi
+
+ CROSS_CARGO=(cargo "$TOOL")
+ if [[ $TOOL == xwin ]]; then
+ CROSS_CARGO+=(build)
+ fi
+
+ GENERATOR_FEATURES="csharp${PAYJOIN_FFI_FEATURES:+,$PAYJOIN_FFI_FEATURES}"
+ (cd .. && "${CROSS_CARGO[@]}" --target "$TARGET" --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -p payjoin-ffi)
+
+ # Cargo writes to the triple without any zig glibc suffix.
+ TARGET_DIR=${TARGET%."$PAYJOIN_FFI_GLIBC_FLOOR"}
+ if [[ $PAYJOIN_FFI_PROFILE == "dev" ]]; then
+ PROFILE_DIR=debug
+ else
+ PROFILE_DIR=$PAYJOIN_FFI_PROFILE
+ fi
+ BUILT_LIB="../../target/$TARGET_DIR/$PROFILE_DIR/$LIBNAME"
+else
+ bash ./scripts/generate_bindings.sh --native-only
+ BUILT_LIB="lib/$LIBNAME"
+fi
+
+ARTIFACT_DIR="artifacts/runtimes/$RID/native"
+rm -rf "$ARTIFACT_DIR"
+mkdir -p "$ARTIFACT_DIR"
+cp "$BUILT_LIB" "$ARTIFACT_DIR/$LIBNAME"
+
+echo "Wrote $ARTIFACT_DIR/$LIBNAME"
diff --git a/payjoin-ffi/csharp/scripts/cross-requirements.txt b/payjoin-ffi/csharp/scripts/cross-requirements.txt
new file mode 100644
index 000000000..9f3db226f
--- /dev/null
+++ b/payjoin-ffi/csharp/scripts/cross-requirements.txt
@@ -0,0 +1,19 @@
+# Pinned cross-compilation toolchain for the CI native-asset job (Linux runners).
+# These tools produce the exact binaries shipped in the NuGet package, so they
+# are pinned by version and hash: a floating install would let an upstream
+# release or a compromised PyPI package silently change the shipped artifacts.
+# Update deliberately, never implicitly. Hashes cover the x86_64 and aarch64
+# manylinux wheels.
+ziglang==0.16.0 \
+ --hash=sha256:9fcda73f62b851dd72a54b710ad40a209896db14cfb13649e62191243556342b \
+ --hash=sha256:e27d409812b11e0fb89ed0200cf2e55b6464d43f9461553104e4a4f9a94a1fd5
+cargo-zigbuild==0.23.0 \
+ --hash=sha256:3a2e021435a5e3c8b3a70bfc490580a3af6952185aa5ffb722b005d1fbe000c9 \
+ --hash=sha256:d984af93da7a230606452ef5f6df47e6a081b12dcead8a3e8e5ff024728c94f0
+cargo-xwin==0.22.0 \
+ --hash=sha256:eebe464a84371c98c9a3e2d24a87b6c0fdc575146f6670f49823a366158027bf \
+ --hash=sha256:8a6d3f235590a1e583286884cbdf0b2af67164713f8a81d292f3312e0fa2355e
+# Transitive dependency of cargo-xwin.
+ninja==1.13.0 \
+ --hash=sha256:fb46acf6b93b8dd0322adc3a4945452a4e774b75b91293bafcc7b7f8e6517dfa \
+ --hash=sha256:3d00c692fb717fd511abeb44b8c5d00340c36938c12d6538ba989fe764e79630
diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.ps1 b/payjoin-ffi/csharp/scripts/generate_bindings.ps1
index 52e782508..e022c3a3f 100644
--- a/payjoin-ffi/csharp/scripts/generate_bindings.ps1
+++ b/payjoin-ffi/csharp/scripts/generate_bindings.ps1
@@ -1,3 +1,14 @@
+param(
+ # Force production bindings (no extra cargo features). Windows cannot represent an empty
+ # environment variable distinctly from an unset one, so callers signal production through
+ # this switch rather than by setting PAYJOIN_FFI_FEATURES to "".
+ [switch] $ProductionBindings,
+
+ # Build the native library without regenerating the C# bindings, for callers (the per-RID
+ # packaging jobs) that consume only the native asset.
+ [switch] $NativeOnly
+)
+
$ErrorActionPreference = "Stop"
$PSNativeCommandUseErrorActionPreference = $true
@@ -37,7 +48,9 @@ $payjoinFfiDir = Resolve-Path (Join-Path $scriptDir "..\..")
Set-Location $payjoinFfiDir
Write-Host "Generating payjoin C#..."
-if ($null -ne $env:PAYJOIN_FFI_FEATURES) {
+if ($ProductionBindings) {
+ $payjoinFfiFeatures = ""
+} elseif ($null -ne $env:PAYJOIN_FFI_FEATURES) {
$payjoinFfiFeatures = $env:PAYJOIN_FFI_FEATURES
} else {
# Keep parity with other language test scripts: include _test-utils by default.
@@ -50,27 +63,41 @@ if ($payjoinFfiFeatures) {
$generatorFeatures = "csharp"
}
-Invoke-Native cargo build --features $generatorFeatures --profile dev -j2
-
-Write-Host "Cleaning csharp/src/ directory..."
-New-Item -ItemType Directory -Force -Path "csharp/src" | Out-Null
-Get-ChildItem "csharp/src" -Filter "*.cs" -ErrorAction SilentlyContinue | Remove-Item -Force
+if ($env:PAYJOIN_FFI_PROFILE) {
+ $payjoinFfiProfile = $env:PAYJOIN_FFI_PROFILE
+} else {
+ $payjoinFfiProfile = "dev"
+}
-$previousUniffiLanguage = $env:UNIFFI_BINDGEN_LANGUAGE
-$env:UNIFFI_BINDGEN_LANGUAGE = "csharp"
-try {
- Invoke-Native cargo run --features $generatorFeatures --profile dev --bin uniffi-bindgen '--' --library "../target/debug/$libName" --out-dir "csharp/src/"
+if ($payjoinFfiProfile -eq "dev") {
+ $targetProfileDir = "debug"
+} else {
+ $targetProfileDir = $payjoinFfiProfile
}
-finally {
- if ($null -eq $previousUniffiLanguage) {
- Remove-Item Env:UNIFFI_BINDGEN_LANGUAGE -ErrorAction SilentlyContinue
- } else {
- $env:UNIFFI_BINDGEN_LANGUAGE = $previousUniffiLanguage
+
+Invoke-Native cargo build --features $generatorFeatures --profile $payjoinFfiProfile -j2
+
+if (-not $NativeOnly) {
+ Write-Host "Cleaning csharp/src/ directory..."
+ New-Item -ItemType Directory -Force -Path "csharp/src" | Out-Null
+ Get-ChildItem "csharp/src" -Filter "*.cs" -ErrorAction SilentlyContinue | Remove-Item -Force
+
+ $previousUniffiLanguage = $env:UNIFFI_BINDGEN_LANGUAGE
+ $env:UNIFFI_BINDGEN_LANGUAGE = "csharp"
+ try {
+ Invoke-Native cargo run --features $generatorFeatures --profile dev --bin uniffi-bindgen '--' --library "../target/$targetProfileDir/$libName" --out-dir "csharp/src/"
+ }
+ finally {
+ if ($null -eq $previousUniffiLanguage) {
+ Remove-Item Env:UNIFFI_BINDGEN_LANGUAGE -ErrorAction SilentlyContinue
+ } else {
+ $env:UNIFFI_BINDGEN_LANGUAGE = $previousUniffiLanguage
+ }
}
}
Write-Host "Copying native library..."
New-Item -ItemType Directory -Force -Path "csharp/lib" | Out-Null
-Copy-Item "../target/debug/$libName" "csharp/lib/$libName" -Force
+Copy-Item "../target/$targetProfileDir/$libName" "csharp/lib/$libName" -Force
Write-Host "All done!"
diff --git a/payjoin-ffi/csharp/scripts/generate_bindings.sh b/payjoin-ffi/csharp/scripts/generate_bindings.sh
index 630ab2146..6fd7af2f0 100755
--- a/payjoin-ffi/csharp/scripts/generate_bindings.sh
+++ b/payjoin-ffi/csharp/scripts/generate_bindings.sh
@@ -1,6 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
+# --native-only builds the native library without regenerating the C# bindings, for callers
+# (the per-RID packaging jobs) that consume only the native asset.
+NATIVE_ONLY=0
+if [[ ${1:-} == "--native-only" ]]; then
+ NATIVE_ONLY=1
+fi
+
OS=$(uname -s)
echo "Running on $OS"
@@ -23,27 +30,35 @@ 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}
+PAYJOIN_FFI_FEATURES=${PAYJOIN_FFI_FEATURES-_test-utils}
+PAYJOIN_FFI_PROFILE=${PAYJOIN_FFI_PROFILE:-dev}
+if [[ $PAYJOIN_FFI_PROFILE == "dev" ]]; then
+ TARGET_PROFILE_DIR=debug
+else
+ TARGET_PROFILE_DIR=$PAYJOIN_FFI_PROFILE
+fi
GENERATOR_FEATURES="csharp"
if [[ -n $PAYJOIN_FFI_FEATURES ]]; then
GENERATOR_FEATURES="$GENERATOR_FEATURES,$PAYJOIN_FFI_FEATURES"
fi
-cargo build --features "$GENERATOR_FEATURES" --profile dev -j2
+cargo build --features "$GENERATOR_FEATURES" --profile "$PAYJOIN_FFI_PROFILE" -j2
-# Clean output directory to prevent duplicate definitions
-echo "Cleaning csharp/src/ directory..."
-mkdir -p csharp/src
-rm -f csharp/src/*.cs
+if [[ $NATIVE_ONLY -eq 0 ]]; then
+ # Clean output directory to prevent duplicate definitions
+ echo "Cleaning csharp/src/ directory..."
+ mkdir -p csharp/src
+ rm -f csharp/src/*.cs
-# Use the Cargo-managed C# generator pinned in payjoin-ffi/Cargo.toml.
-UNIFFI_BINDGEN_LANGUAGE=csharp cargo run --features "$GENERATOR_FEATURES" --profile dev --bin uniffi-bindgen -- \
- --library ../target/debug/$LIBNAME \
- --out-dir csharp/src/
+ # Use the Cargo-managed C# generator pinned in payjoin-ffi/Cargo.toml.
+ UNIFFI_BINDGEN_LANGUAGE=csharp cargo run --features "$GENERATOR_FEATURES" --profile dev --bin uniffi-bindgen -- \
+ --library "../target/$TARGET_PROFILE_DIR/$LIBNAME" \
+ --out-dir csharp/src/
+fi
# Copy native library to csharp/lib/ directory for testing
echo "Copying native library..."
mkdir -p csharp/lib
-cp ../target/debug/$LIBNAME csharp/lib/$LIBNAME
+cp "../target/$TARGET_PROFILE_DIR/$LIBNAME" "csharp/lib/$LIBNAME"
echo "All done!"
diff --git a/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh b/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh
new file mode 100755
index 000000000..15ba0b52f
--- /dev/null
+++ b/payjoin-ffi/csharp/scripts/smoke_nuget_package.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -lt 2 || $# -gt 3 ]]; then
+ echo "usage: $0 [runtime-id]" >&2
+ echo " 'auto' derives the version from the single Payjoin.*.nupkg in ." >&2
+ exit 1
+fi
+
+PACKAGE_SOURCE=$(cd "$1" && pwd)
+PACKAGE_VERSION=$2
+RID=${3:-}
+
+if [[ $PACKAGE_VERSION == auto ]]; then
+ shopt -s nullglob
+ packages=("$PACKAGE_SOURCE"/Payjoin.*.nupkg)
+ shopt -u nullglob
+ if [[ ${#packages[@]} -ne 1 ]]; then
+ echo "Expected exactly one Payjoin.*.nupkg in $PACKAGE_SOURCE to derive the version; found ${#packages[@]}." >&2
+ exit 1
+ fi
+ PACKAGE_VERSION=$(basename "${packages[0]}")
+ PACKAGE_VERSION=${PACKAGE_VERSION#Payjoin.}
+ PACKAGE_VERSION=${PACKAGE_VERSION%.nupkg}
+ echo "Derived package version $PACKAGE_VERSION from ${packages[0]}"
+fi
+WORKDIR=$(mktemp -d)
+trap 'rm -rf "$WORKDIR"' EXIT
+export DOTNET_CLI_HOME="$WORKDIR/dotnet-home"
+
+native_library_name() {
+ case "$1" in
+ linux-*)
+ echo "libpayjoin_ffi.so"
+ ;;
+ osx-*)
+ echo "libpayjoin_ffi.dylib"
+ ;;
+ win-*)
+ echo "payjoin_ffi.dll"
+ ;;
+ *)
+ echo "Unsupported RID: $1" >&2
+ exit 1
+ ;;
+ esac
+}
+
+DOTNET_10_SDK_VERSION=$(dotnet --list-sdks | awk '/^10\./ { print $1; exit }')
+if [[ -n $DOTNET_10_SDK_VERSION ]]; then
+ cat >"$WORKDIR/global.json" </dev/null
+
+dotnet new console --framework net10.0 --output "PayjoinSmoke" --no-restore
+dotnet add "PayjoinSmoke/PayjoinSmoke.csproj" package Payjoin \
+ --version "$PACKAGE_VERSION" \
+ --source "$PACKAGE_SOURCE" \
+ --no-restore
+
+cat >"PayjoinSmoke/Program.cs" <<'EOF'
+var uri = Payjoin.Url.Parse("bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao");
+Console.WriteLine(uri.AsString());
+EOF
+
+dotnet restore "PayjoinSmoke/PayjoinSmoke.csproj" \
+ --source "$PACKAGE_SOURCE" \
+ --ignore-failed-sources \
+ -p:UseAppHost=false \
+ -p:DisableTransitiveFrameworkReferenceDownloads=true
+dotnet build "PayjoinSmoke/PayjoinSmoke.csproj" \
+ --framework net10.0 \
+ --no-restore \
+ -p:UseAppHost=false \
+ -p:DisableTransitiveFrameworkReferenceDownloads=true
+
+if [[ -n $RID ]]; then
+ LIBNAME=$(native_library_name "$RID")
+ if [[ -z $(find "PayjoinSmoke/bin/Debug/net10.0" -name "$LIBNAME" -print -quit) ]]; then
+ echo "Expected native asset $LIBNAME was not copied for $RID" >&2
+ exit 1
+ fi
+fi
+
+dotnet "PayjoinSmoke/bin/Debug/net10.0/PayjoinSmoke.dll"
+
+popd >/dev/null
diff --git a/payjoin-ffi/uniffi.toml b/payjoin-ffi/uniffi.toml
index 590e8d5b4..9006539eb 100644
--- a/payjoin-ffi/uniffi.toml
+++ b/payjoin-ffi/uniffi.toml
@@ -15,3 +15,4 @@ package_name = "payjoin"
[bindings.csharp]
namespace = "Payjoin"
cdylib_name = "payjoin_ffi"
+access_modifier = "public"