⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/csharp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Build and Test CSharp
on:
pull_request:
paths:
- payjoin-ffi/**
env:
RUSTUP_TOOLCHAIN: 1.85

jobs:
build-csharp-and-test:
name: "Build and test csharp"
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: payjoin-ffi/csharp
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Rust 1.85.0
uses: dtolnay/[email protected]

- name: Use cache
uses: Swatinem/rust-cache@v2

- name: Install .NET 8 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"

- name: Install LLVM (macOS)
if: matrix.os == 'macos-latest'
run: brew install llvm

- name: Generate bindings and binaries
run: bash ./scripts/generate_bindings.sh

- name: Run tests
run: dotnet test --logger "console;verbosity=minimal"
17 changes: 17 additions & 0 deletions payjoin-ffi/csharp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated bindings
src/*.cs

# Build outputs
bin/
obj/

# IDE
.vs/
*.user
*.suo

# NuGet
*.nupkg
packages/
/lib/*.so
/lib/*.dylib
26 changes: 26 additions & 0 deletions payjoin-ffi/csharp/Payjoin.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is net8.0 the most common target? I'm very curious what the actual needs of a BTCpayServer plugin or Strike are, because I've dealt with incompatibility issue here, albeit years ago.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it can: https://blog.btcpayserver.org/btcpay-server-1-12-0/#upgrade-to-net-8. Strike is likely ahead because they don't need to support a bunch of open source stuff.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, any idea which specific runtime they're on?

I did a quick check beforehand to target the most common runtime right now...

Gonna look loser into the Strike repos specifically

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strike very likely on net8.0. probably private repos. i'll ask their team

<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<!-- Include native library - copied to output directory for testing -->
<Target Name="CopyNativeLibraries" AfterTargets="Build">
<Copy SourceFiles="lib/libpayjoin_ffi.so" DestinationFolder="$(TargetDir)" Condition="Exists('lib/libpayjoin_ffi.so')" />
<Copy SourceFiles="lib/libpayjoin_ffi.dylib" DestinationFolder="$(TargetDir)" Condition="Exists('lib/libpayjoin_ffi.dylib')" />
</Target>

</Project>
34 changes: 34 additions & 0 deletions payjoin-ffi/csharp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Payjoin C# Bindings

Welcome to the C# language bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/)!

## Running Tests

Follow these steps to clone the repository and run the tests.

```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
```

## Requirements

- .NET 8.0 or higher
- `uniffi-bindgen-cs` fork at `chavic/external-types-support` (the `scripts/generate_bindings.sh` script will install it automatically if it's not already on your PATH)

## Configuration

You can specify the path to `uniffi-bindgen-cs` via environment variable:
```shell
export UNIFFI_CS=/path/to/uniffi-bindgen-cs
dotnet build
```
182 changes: 182 additions & 0 deletions payjoin-ffi/csharp/UnitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
using Xunit;
using uniffi.payjoin;

namespace Payjoin.Tests;

public class UriTests
{
[Fact]
public void UrlEncodedPayjoinParameter()
{
var uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao";
var result = Url.Parse(uri);
Assert.NotNull(result);
}

[Fact]
public void ValidUrl()
{
var uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao";
var result = Url.Parse(uri);
Assert.NotNull(result);
}

[Fact]
public void MissingAmountShouldBeOk()
{
var uri = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj";
var result = Url.Parse(uri);
Assert.NotNull(result);
}

[Theory]
[InlineData("bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", "https://example.com")]
[InlineData("bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion")]
[InlineData("BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4", "https://example.com")]
[InlineData("bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", "https://example.com")]
public void ValidUrisWithDifferentAddressesAndEndpoints(string address, string pj)
{
var uri = $"{address}?amount=1&pj={pj}";
var result = Url.Parse(uri);
Assert.NotNull(result);
}
}

public class InMemoryReceiverPersister : JsonReceiverSessionPersister
{
private List<string> _events = new();

public void Save(string @event)
{
_events.Add(@event);
}

public string[] Load()
{
return _events.ToArray();
}

public void Close()
{
// no-op for tests
}
}

public class InMemorySenderPersister : JsonSenderSessionPersister
{
private List<string> _events = new();

public void Save(string @event)
{
_events.Add(@event);
}

public string[] Load()
{
return _events.ToArray();
}

public void Close()
{
// no-op for tests
}
}

public class PersistenceTests
{
private static readonly byte[] OhttpKeysData = new byte[]
{
0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a,
0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1,
0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7,
0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe,
0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc,
0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c,
0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04,
0x00, 0x01, 0x00, 0x03,
};

[Fact]
public void ReceiverPersistence()
{
var persister = new InMemoryReceiverPersister();
var address = "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4";
var ohttpKeys = OhttpKeys.Decode(OhttpKeysData);

var builder = new ReceiverBuilder(address, "https://example.com", ohttpKeys);
var transition = builder.Build();
var initialized = transition.Save(persister);

var result = PayjoinMethods.ReplayReceiverEventLog(persister);
var state = result.State();

Assert.IsType<ReceiveSession.Initialized>(state);
}

[Fact]
public void SenderPersistence()
{
var receiverPersister = new InMemoryReceiverPersister();
var address = "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK";
var ohttpKeys = OhttpKeys.Decode(OhttpKeysData);

var receiver = new ReceiverBuilder(address, "https://example.com", ohttpKeys)
.Build()
.Save(receiverPersister);
var uri = receiver.PjUri();

var senderPersister = new InMemorySenderPersister();
var psbt = "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA=";

var withReplyKey = new SenderBuilder(psbt, uri)
.BuildRecommended(1000)
.Save(senderPersister);

var replayed = PayjoinMethods.ReplaySenderEventLog(senderPersister);
var state = replayed.State();

Assert.IsType<SendSession.WithReplyKey>(state);
}
}

public class ValidationTests
{
private static readonly byte[] OhttpKeysData = new byte[]
{
0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a,
0x92, 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1,
0x0c, 0xed, 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7,
0xad, 0x2c, 0xc9, 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe,
0xe8, 0xd2, 0x8b, 0xfe, 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc,
0x4d, 0x31, 0xcd, 0x85, 0x1e, 0x8a, 0x65, 0x40, 0xe8, 0x6c,
0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, 0x09, 0x57, 0x00, 0x04,
0x00, 0x01, 0x00, 0x03,
};

[Fact]
public void ReceiverBuilderRejectsBadAddress()
{
var ohttpKeys = OhttpKeys.Decode(OhttpKeysData);

Assert.Throws<ReceiverBuilderException.InvalidAddress>(() =>
{
new ReceiverBuilder("not-an-address", "https://example.com", ohttpKeys);
});
}

[Fact]
public void InputPairRejectsInvalidOutpoint()
{
Assert.Throws<InputPairException.InvalidOutPoint>(() =>
{
var txin = new PlainTxIn(
new PlainOutPoint("deadbeef", 0),
new byte[] {},
0,
new byte[][] {}
);
var psbtIn = new PlainPsbtInput(null, null, null);
new InputPair(txin, psbtIn, null);
});
}
}
72 changes: 72 additions & 0 deletions payjoin-ffi/csharp/scripts/generate_bindings.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail

OS=$(uname -s)
echo "Running on $OS"

if [[ $OS == "Darwin" ]]; then
LIBNAME=libpayjoin_ffi.dylib
elif [[ $OS == "Linux" ]]; then
LIBNAME=libpayjoin_ffi.so
else
echo "Unsupported os: $OS"
exit 1
fi

# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Navigate to payjoin-ffi directory (parent of csharp, which is parent of scripts)
cd "$SCRIPT_DIR/../.."

echo "Generating payjoin C#..."
cargo build --features _test-utils --profile dev -j2

# Use uniffi-bindgen-cs from the fork/branch we track, installed via cargo (no manual clone).
UNIFFI_CS=${UNIFFI_CS:-$HOME/.cargo/bin/uniffi-bindgen-cs}

if [[ ! -x "$UNIFFI_CS" ]]; then
echo "Installing uniffi-bindgen-cs from https://github.com/chavic/uniffi-bindgen-cs.git#chavic/external-types-support..."
cargo install --git https://github.com/chavic/uniffi-bindgen-cs.git --branch chavic/external-types-support --locked uniffi-bindgen-cs
fi

# Clean output directory to prevent duplicate definitions
echo "Cleaning csharp/src/ directory..."
rm -f csharp/src/*.cs

# From payjoin-ffi/, library is at ../target/debug/ (workspace root target)
$UNIFFI_CS --library ../target/debug/$LIBNAME --out-dir csharp/src/

# UniFFI marks foreign handles via the low bit. Older generators needed a patch to start at 1 and
# increment by 2. If the expected patterns are missing, assume upstream fixed it and skip.
python3 - <<'PY'
from pathlib import Path

path = Path("csharp/src/payjoin.cs")
text = path.read_text()
replaced = 0

replacements = [
("ulong currentHandle = 0;", "ulong currentHandle = 1;"),
("currentHandle += 1;\n map[currentHandle] = obj;\n return currentHandle;",
"var handle = currentHandle;\n currentHandle += 2;\n map[handle] = obj;\n return handle;"),
]

for old, new in replacements:
updated = text.replace(old, new, 1)
if updated != text:
replaced += 1
text = updated

if replaced:
path.write_text(text)
print(f"Applied handle-map patch ({replaced} replacements).")
else:
print("Handle-map patch not needed (generator already emits odd handles).")
PY

# Copy native library to csharp/lib/ directory for testing
echo "Copying native library..."
mkdir -p csharp/lib
cp ../target/debug/$LIBNAME csharp/lib/$LIBNAME

echo "All done!"
Loading