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"