diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml new file mode 100644 index 000000000..0e4032f52 --- /dev/null +++ b/.github/workflows/cpp.yml @@ -0,0 +1,157 @@ +name: C++ + +on: + push: + branches: + - main + tags: + - "**" + pull_request: + branches: + - "**" + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow_ref, github.event.pull_request.number) || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + generate: + name: Generate C++ bindings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: x86_64-unknown-linux-gnu-cargo-ubuntu-latest-${{ hashFiles('**/Cargo.lock') }} + + - name: Build native library (linux-x64, symbols intact for bindgen) + run: cargo build --profile release-cpp -p chia-wallet-sdk-cpp --target x86_64-unknown-linux-gnu + + - name: Install uniffi-bindgen-cpp + run: cargo install uniffi-bindgen-cpp --git https://github.com/NordSecurity/uniffi-bindgen-cpp --tag v0.8.1+v0.29.4 + + - name: Generate C++ bindings + run: | + mkdir -p cpp/chia_wallet_sdk + uniffi-bindgen-cpp \ + --library target/x86_64-unknown-linux-gnu/release-cpp/libchia_wallet_sdk.so \ + --out-dir cpp/chia_wallet_sdk + test -f cpp/chia_wallet_sdk/chia_wallet_sdk.cpp || { echo "ERROR: chia_wallet_sdk.cpp was not generated"; exit 1; } + + - name: Patch generated bindings + run: | + # 1. Clvm methods `bool`/`int` are emitted with reserved C++ keyword names. + # 2. VdfInfo/VdfProof forward declarations are emitted with all-caps acronyms. + sed -i \ + -e 's/std::shared_ptr bool(/std::shared_ptr bool_(/' \ + -e 's/std::shared_ptr int(/std::shared_ptr int_(/' \ + -e 's/struct VDFInfo;/struct VdfInfo;/' \ + -e 's/struct VDFProof;/struct VdfProof;/' \ + cpp/chia_wallet_sdk/chia_wallet_sdk.hpp + sed -i \ + -e 's/Clvm::bool(/Clvm::bool_(/' \ + -e 's/Clvm::int(/Clvm::int_(/' \ + cpp/chia_wallet_sdk/chia_wallet_sdk.cpp + + - name: Upload C++ bindings artifact + uses: actions/upload-artifact@v7 + with: + name: cpp-bindings + path: | + cpp/chia_wallet_sdk/chia_wallet_sdk.hpp + cpp/chia_wallet_sdk/chia_wallet_sdk.cpp + cpp/chia_wallet_sdk/chia_wallet_sdk_scaffolding.hpp + if-no-files-found: error + + build: + name: Build native - ${{ matrix.settings.target }} + strategy: + fail-fast: false + matrix: + settings: + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact-name: native-linux-x64 + lib-name: libchia_wallet_sdk.so + - host: macos-latest + target: aarch64-apple-darwin + artifact-name: native-osx-arm64 + lib-name: libchia_wallet_sdk.dylib + - host: macos-latest + target: x86_64-apple-darwin + artifact-name: native-osx-x64 + lib-name: libchia_wallet_sdk.dylib + - host: windows-latest + target: x86_64-pc-windows-msvc + artifact-name: native-win-x64 + lib-name: chia_wallet_sdk.dll + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.settings.target }} + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Build native library + run: cargo build --profile release-cpp -p chia-wallet-sdk-cpp --target ${{ matrix.settings.target }} + + - name: Upload native artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.settings.artifact-name }} + path: target/${{ matrix.settings.target }}/release-cpp/${{ matrix.settings.lib-name }} + if-no-files-found: error + + test: + name: Test C++ bindings + runs-on: ubuntu-latest + needs: [generate, build] + steps: + - uses: actions/checkout@v6 + + - name: Download C++ bindings + uses: actions/download-artifact@v8 + with: + name: cpp-bindings + path: cpp/chia_wallet_sdk + + - name: Download linux-x64 native library + uses: actions/download-artifact@v8 + with: + name: native-linux-x64 + path: cpp/chia_wallet_sdk + + - name: Configure and build test + run: | + cmake -S cpp/tests -B cpp/tests/build -DCMAKE_BUILD_TYPE=Release + cmake --build cpp/tests/build + + - name: Run C++ tests + run: ctest --test-dir cpp/tests/build --output-on-failure diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 000000000..094202cf0 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,256 @@ +name: dotnet + +on: + push: + branches: + - main + tags: + # Only semver-shaped tags (optionally v-prefixed) drive a publishable build. + - "[0-9]+.[0-9]+.[0-9]+*" + - "v[0-9]+.[0-9]+.[0-9]+*" + pull_request: + branches: + - "**" + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow_ref, github.event.pull_request.number) || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + generate: + name: Generate C# bindings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Build native library (linux-x64, symbols intact for bindgen) + run: cargo build --profile release-cs -p chia-wallet-sdk-cs --target x86_64-unknown-linux-gnu + + - name: Install uniffi-bindgen-cs + run: cargo install uniffi-bindgen-cs --git https://github.com/NordSecurity/uniffi-bindgen-cs --tag v0.10.0+v0.29.4 + + - name: Generate C# bindings + run: | + uniffi-bindgen-cs \ + --library \ + --out-dir dotnet/cs \ + --config dotnet/uniffi.toml \ + target/x86_64-unknown-linux-gnu/release-cs/libchia_wallet_sdk.so + test -f dotnet/cs/chia_wallet_sdk.cs || { echo "ERROR: chia_wallet_sdk.cs was not generated"; exit 1; } + + - name: Upload C# bindings artifact + uses: actions/upload-artifact@v7 + with: + name: cs-bindings + path: dotnet/cs/chia_wallet_sdk.cs + if-no-files-found: error + + build: + name: Build native - ${{ matrix.settings.target }} + strategy: + fail-fast: false + matrix: + settings: + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact-name: native-linux-x64 + lib-name: libchia_wallet_sdk.so + - host: macos-latest + target: aarch64-apple-darwin + artifact-name: native-osx-arm64 + lib-name: libchia_wallet_sdk.dylib + - host: macos-latest + target: x86_64-apple-darwin + artifact-name: native-osx-x64 + lib-name: libchia_wallet_sdk.dylib + - host: windows-latest + target: x86_64-pc-windows-msvc + artifact-name: native-win-x64 + lib-name: chia_wallet_sdk.dll + # - host: ubuntu-latest + # target: x86_64-unknown-linux-musl + # artifact-name: native-linux-musl-x64 + # lib-name: libchia_wallet_sdk.so + # use_cross: true + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.settings.target }} + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Install cross + if: matrix.settings.use_cross + uses: taiki-e/install-action@cross + + - name: Build native library + run: ${{ matrix.settings.use_cross && 'cross' || 'cargo' }} build --profile release-cs -p chia-wallet-sdk-cs --target ${{ matrix.settings.target }} + + - name: Upload native artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.settings.artifact-name }} + path: target/${{ matrix.settings.target }}/release-cs/${{ matrix.settings.lib-name }} + if-no-files-found: error + + test: + name: Test (linux-x64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: test-cargo-ubuntu-latest-${{ hashFiles('**/Cargo.lock') }} + + # Build without an explicit --target so the library lands in target/release-cs/, + # which is where tests/ChiaWalletSdkTests.csproj copies the native lib from. + - name: Build native library + run: cargo build --profile release-cs -p chia-wallet-sdk-cs + + - name: Install uniffi-bindgen-cs + run: cargo install uniffi-bindgen-cs --git https://github.com/NordSecurity/uniffi-bindgen-cs --tag v0.10.0+v0.29.4 + + - name: Generate C# bindings + run: | + uniffi-bindgen-cs \ + --library \ + --out-dir dotnet/cs \ + --config dotnet/uniffi.toml \ + target/release-cs/libchia_wallet_sdk.so + test -f dotnet/cs/chia_wallet_sdk.cs || { echo "ERROR: chia_wallet_sdk.cs was not generated"; exit 1; } + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.x' + + - name: Run tests + run: dotnet test dotnet/tests/ChiaWalletSdkTests.csproj --nologo -v minimal --filter "Category!=Integration" + + pack: + name: Pack NuGet + runs-on: ubuntu-latest + needs: [generate, build, test] + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.x' + + - name: Download C# bindings + uses: actions/download-artifact@v8 + with: + name: cs-bindings + path: dotnet/cs + + - name: Download osx-arm64 artifact + uses: actions/download-artifact@v8 + with: + name: native-osx-arm64 + path: dotnet/cs/runtimes/osx-arm64/native + + - name: Download osx-x64 artifact + uses: actions/download-artifact@v8 + with: + name: native-osx-x64 + path: dotnet/cs/runtimes/osx-x64/native + + - name: Download win-x64 artifact + uses: actions/download-artifact@v8 + with: + name: native-win-x64 + path: dotnet/cs/runtimes/win-x64/native + + - name: Download linux-x64 artifact + uses: actions/download-artifact@v8 + with: + name: native-linux-x64 + path: dotnet/cs/runtimes/linux-x64/native + + # - name: Download linux-musl-x64 artifact + # uses: actions/download-artifact@v8 + # with: + # name: native-linux-musl-x64 + # path: dotnet/cs/runtimes/linux-musl-x64/native + + - name: Determine version + id: version + env: + REF_NAME: ${{ github.ref_name }} + run: | + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + # Strip an optional leading 'v' (NuGet rejects 'v1.2.3') and validate semver + # so a malformed tag fails here instead of poisoning the published version. + version="${REF_NAME#v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Tag '${REF_NAME}' is not a valid semantic version" + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + else + echo "version=0.0.0-dev" >> "$GITHUB_OUTPUT" + fi + + - name: Pack + run: dotnet pack dotnet/cs/ChiaWalletSdk.csproj -c Release -o ./nuget-out -p:Version=${{ steps.version.outputs.version }} + + - name: Upload nupkg artifact + uses: actions/upload-artifact@v7 + with: + name: nuget-package + path: nuget-out/*.nupkg + if-no-files-found: error + + publish: + name: Publish to NuGet.org + runs-on: ubuntu-latest + needs: pack + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: read + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '8.x' + + - name: Download nupkg artifact + uses: actions/download-artifact@v8 + with: + name: nuget-package + path: nuget-out + + - name: Publish to NuGet.org + run: dotnet nuget push nuget-out/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 000000000..faf121a16 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,153 @@ +name: Go + +on: + push: + branches: + - main + tags: + - "**" + pull_request: + branches: + - "**" + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow_ref, github.event.pull_request.number) || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + generate: + name: Generate Go bindings + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: x86_64-unknown-linux-gnu-cargo-ubuntu-latest-${{ hashFiles('**/Cargo.lock') }} + + - name: Build native library (linux-x64, symbols intact for bindgen) + run: cargo build --profile release-go -p chia-wallet-sdk-go --target x86_64-unknown-linux-gnu + + - name: Install uniffi-bindgen-go + run: cargo install uniffi-bindgen-go --git https://github.com/NordSecurity/uniffi-bindgen-go --tag v0.5.0+v0.29.5 + + - name: Generate Go bindings + run: | + uniffi-bindgen-go \ + --library target/x86_64-unknown-linux-gnu/release-go/libchia_wallet_sdk.so \ + --out-dir go + test -f go/chia_wallet_sdk/chia_wallet_sdk.go || { echo "ERROR: chia_wallet_sdk.go was not generated"; exit 1; } + + - name: Patch naming collisions + run: | + sed -i \ + -e 's/^func OptionTypeCat(/func NewOptionTypeFromCat(/' \ + -e 's/^func OptionTypeNft(/func NewOptionTypeFromNft(/' \ + -e 's/^func OptionTypeRevocableCat(/func NewOptionTypeFromRevocableCat(/' \ + -e 's/^func OptionTypeXch(/func NewOptionTypeFromXch(/' \ + go/chia_wallet_sdk/chia_wallet_sdk.go + + - name: Upload Go bindings artifact + uses: actions/upload-artifact@v7 + with: + name: go-bindings + path: | + go/chia_wallet_sdk/chia_wallet_sdk.go + go/chia_wallet_sdk/chia_wallet_sdk.h + if-no-files-found: error + + build: + name: Build native - ${{ matrix.settings.target }} + strategy: + fail-fast: false + matrix: + settings: + - host: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact-name: native-linux-x64 + lib-name: libchia_wallet_sdk.so + - host: macos-latest + target: aarch64-apple-darwin + artifact-name: native-osx-arm64 + lib-name: libchia_wallet_sdk.dylib + - host: macos-latest + target: x86_64-apple-darwin + artifact-name: native-osx-x64 + lib-name: libchia_wallet_sdk.dylib + - host: windows-latest + target: x86_64-pc-windows-msvc + artifact-name: native-win-x64 + lib-name: chia_wallet_sdk.dll + runs-on: ${{ matrix.settings.host }} + steps: + - uses: actions/checkout@v6 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.settings.target }} + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ matrix.settings.target }}-cargo-${{ matrix.settings.host }}-${{ hashFiles('**/Cargo.lock') }} + + - name: Build native library + run: cargo build --profile release-go -p chia-wallet-sdk-go --target ${{ matrix.settings.target }} + + - name: Upload native artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.settings.artifact-name }} + path: target/${{ matrix.settings.target }}/release-go/${{ matrix.settings.lib-name }} + if-no-files-found: error + + test: + name: Test Go bindings + runs-on: ubuntu-latest + needs: [generate, build] + steps: + - uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go/go.mod + + - name: Download Go bindings + uses: actions/download-artifact@v8 + with: + name: go-bindings + path: go/chia_wallet_sdk + + - name: Download linux-x64 native library + uses: actions/download-artifact@v8 + with: + name: native-linux-x64 + path: go/chia_wallet_sdk + + - name: Run Go tests + working-directory: go + env: + CGO_LDFLAGS: "-L${{ github.workspace }}/go/chia_wallet_sdk -lchia_wallet_sdk" + LD_LIBRARY_PATH: "${{ github.workspace }}/go/chia_wallet_sdk" + run: go test ./tests/... diff --git a/.github/workflows/pyo3.yml b/.github/workflows/pyo3.yml index a9ead1c02..3717359dc 100644 --- a/.github/workflows/pyo3.yml +++ b/.github/workflows/pyo3.yml @@ -190,6 +190,9 @@ jobs: with: python-version: 3.x + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Test run: | set -e @@ -205,9 +208,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - name: Check type stubs run: | cargo run -p pyo3-stub-generator diff --git a/.gitignore b/.gitignore index c7037bf74..3257d63ee 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,19 @@ cobertura.xml venv/ *.costs *.bin + +# NuGet native staging and generated bindings (CI-only, never committed) +uniffi/cs/runtimes/ +uniffi/cs/chia_wallet_sdk.cs +nuget-out/ + +# Go generated bindings and native library (CI-only, never committed) +go/chia_wallet_sdk/ + +# C++ generated bindings and native library (CI-only, never committed) +cpp/chia_wallet_sdk/ +cpp/tests/build/ + +# Local dev scripts +pack-local.sh +.remember/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 224d177f6..f82658d7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,48 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "askama" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash 2.1.1", + "serde", + "serde_derive", + "syn 2.0.106", +] + +[[package]] +name = "askama_parser" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.13", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -133,6 +175,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + [[package]] name = "autocfg" version = "1.3.0" @@ -219,6 +274,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bech32" version = "0.9.1" @@ -331,6 +395,7 @@ dependencies = [ "reqwest", "signature 2.2.0", "thiserror 2.0.17", + "uniffi", ] [[package]] @@ -434,6 +499,38 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "cc" version = "1.2.38" @@ -949,6 +1046,42 @@ dependencies = [ "hex-literal", ] +[[package]] +name = "chia-wallet-sdk-cpp" +version = "0.33.0" +dependencies = [ + "bindy", + "bindy-macro", + "chia-sdk-bindings", + "openssl", + "openssl-sys", + "uniffi", +] + +[[package]] +name = "chia-wallet-sdk-cs" +version = "0.33.0" +dependencies = [ + "bindy", + "bindy-macro", + "chia-sdk-bindings", + "openssl", + "openssl-sys", + "uniffi", +] + +[[package]] +name = "chia-wallet-sdk-go" +version = "0.33.0" +dependencies = [ + "bindy", + "bindy-macro", + "chia-sdk-bindings", + "openssl", + "openssl-sys", + "uniffi", +] + [[package]] name = "chia-wallet-sdk-napi" version = "0.0.0" @@ -1684,6 +1817,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1855,6 +1997,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "group" version = "0.13.0" @@ -2793,6 +2946,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" version = "1.10.0" @@ -3435,7 +3594,7 @@ checksum = "12f94c541b1397b7fbc4aaba36440c0bc11ddb703b024a424dd2fe193ec413b7" dependencies = [ "serde", "thiserror 2.0.17", - "toml", + "toml 0.9.8", ] [[package]] @@ -3607,6 +3766,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "sec1" version = "0.7.3" @@ -3649,6 +3828,9 @@ name = "semver" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -3806,6 +3988,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" @@ -3821,6 +4009,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.5.10" @@ -3861,6 +4055,12 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -3963,6 +4163,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4128,6 +4337,15 @@ dependencies = [ "webpki-roots 0.26.5", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.9.8" @@ -4344,6 +4562,126 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "uniffi" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4" +dependencies = [ + "anyhow", + "cargo_metadata", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b39ef1acbe1467d5d210f274fae344cb6f8766339330cb4c9688752899bf6b" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.5.11", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d990b553d6b9a7ee9c3ae71134674739913d52350b56152b0e613595bb5a6f" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f4f224becf14885c10e6e400b95cc4d1985738140cb194ccc2044563f8a56b" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "uniffi_macros" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b481d385af334871d70904e6a5f129be7cd38c18fcf8dd8fd1f646b426a56d58" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.106", + "toml 0.5.11", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f817868a3b171bb7bf259e882138d104deafde65684689b4694c846d322491" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b147e133ad7824e32426b90bc41fda584363563f2ba747f590eca1fd6fd14e6" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.29.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caed654fb73da5abbc7a7e9c741532284532ba4762d6fe5071372df22a41730a" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "unindent" version = "0.2.3" @@ -4609,6 +4947,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "which" version = "4.4.2" diff --git a/Cargo.toml b/Cargo.toml index 935915e2e..e6541a769 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,10 @@ members = [ "napi", "wasm", "pyo3", - "pyo3/stub-generator" + "pyo3/stub-generator", + "dotnet", + "go", + "cpp", ] [workspace.package] @@ -178,6 +181,7 @@ getrandom = "0.3.4" sha2 = "0.10.9" sha3 = "0.10.8" pyo3 = "0.23.5" +uniffi = { version = "=0.29.4", features = ["tokio"] } js-sys = "0.3.77" parking_lot = "0.12.5" chialisp = "0.4.1" @@ -195,3 +199,20 @@ colored = "3.1.1" [profile.release] lto = true strip = "symbols" + +# Used by the uniffi-bindgen-cs generate step — strip = "none" is required +# because uniffi reads UNIFFI_META_* symbols from the static symbol table, +# which strip = "symbols" removes. See mozilla/uniffi-rs#2520. +[profile.release-cs] +inherits = "release" +strip = "none" + +# Same requirement applies to the uniffi-bindgen-go generate step. +[profile.release-go] +inherits = "release" +strip = "none" + +# Same requirement applies to the uniffi-bindgen-cpp generate step. +[profile.release-cpp] +inherits = "release" +strip = "none" diff --git a/bindings.json b/bindings.json index 50b993492..5d4ec3c00 100644 --- a/bindings.json +++ b/bindings.json @@ -59,6 +59,11 @@ "String": "str", "()": "None" }, + "uniffi": { + "{bytes}": "Vec", + "{bigint}": "String", + "{usize}": "u64" + }, "clvm_types": [ "Program", "PublicKey", diff --git a/bindings/peer.json b/bindings/peer.json index 91837fd9a..17b478596 100644 --- a/bindings/peer.json +++ b/bindings/peer.json @@ -36,7 +36,9 @@ "type": "class", "no_wasm": true, "fields": { - "rate_limit_factor": "f64" + "rate_limit_factor": "f64", + "connect_timeout_ms": "Option", + "request_timeout_ms": "Option" }, "methods": { "new": { diff --git a/bindings/rpc.json b/bindings/rpc.json index 5dbf2843a..275eb3c31 100644 --- a/bindings/rpc.json +++ b/bindings/rpc.json @@ -1,4 +1,12 @@ { + "RpcClientOptions": { + "type": "class", + "new": true, + "fields": { + "request_timeout_ms": "Option", + "connect_timeout_ms": "Option" + } + }, "RpcClient": { "type": "class", "methods": { @@ -31,6 +39,12 @@ "key_bytes": "Bytes" } }, + "with_options": { + "args": { + "options": "RpcClientOptions" + }, + "return": "RpcClient" + }, "get_blockchain_state": { "type": "async", "return": "BlockchainStateResponse" diff --git a/cpp/Cargo.toml b/cpp/Cargo.toml new file mode 100644 index 000000000..58c06b830 --- /dev/null +++ b/cpp/Cargo.toml @@ -0,0 +1,39 @@ +[package] +publish = false +name = "chia-wallet-sdk-cpp" +version = "0.33.0" +edition = "2024" + +[lib] +name = "chia_wallet_sdk" +crate-type = ["cdylib"] +doc = false +test = false + +[dependencies] +uniffi = { workspace = true } +chia-sdk-bindings = { workspace = true, features = ["uniffi"] } +bindy = { workspace = true, features = ["uniffi"] } +bindy-macro = { workspace = true } + +[target.aarch64-unknown-linux-gnu.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.aarch64-unknown-linux-musl.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.x86_64-unknown-linux-gnu.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.x86_64-unknown-linux-musl.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[package.metadata.cargo-machete] +ignored = ["bindy", "chia-sdk-bindings"] + +[lints] +workspace = true diff --git a/cpp/README.md b/cpp/README.md new file mode 100644 index 000000000..da21a31f2 --- /dev/null +++ b/cpp/README.md @@ -0,0 +1,286 @@ +# chia-wallet-sdk C++ Bindings + +C++ bindings for the [Chia Wallet SDK](https://github.com/xch-dev/chia-wallet-sdk), generated via [UniFFI](https://mozilla.github.io/uniffi-rs/) and [uniffi-bindgen-cpp](https://github.com/NordSecurity/uniffi-bindgen-cpp). + +The Rust library exposes the full SDK surface — BLS keys, addresses, CLVM, coins, conditions, offers, puzzles, RPC, and more — as a native shared library that C++ consumes through the generated UniFFI scaffolding layer. + +--- + +## Prerequisites + +- Rust toolchain (1.81+) — [rustup.rs](https://rustup.rs) +- A C++20 compiler (Clang 14+, GCC 11+, or MSVC 2022) +- `uniffi-bindgen-cpp` — installed once per machine (see below) + +--- + +## Building + +The `local-build.sh` script handles all steps: compiling the native library, installing `uniffi-bindgen-cpp` if needed, generating the C++ source, and staging the shared library. + +```bash +cd cpp +./local-build.sh # defaults: aarch64-apple-darwin +./local-build.sh -t x86_64-apple-darwin +./local-build.sh -t x86_64-unknown-linux-gnu +./local-build.sh -t x86_64-pc-windows-msvc # PowerShell: .\local-build.ps1 +``` + +After running, the `cpp/chia_wallet_sdk/` directory contains: + +| File | Description | +| ---- | ----------- | +| `chia_wallet_sdk.hpp` | Public C++ header — include this from your code | +| `chia_wallet_sdk.cpp` | Implementation — compile and link into your binary | +| `chia_wallet_sdk_scaffolding.hpp` | FFI scaffolding declarations | +| `libchia_wallet_sdk.dylib` / `.so` / `.dll` | Native shared library | + +These files are git-ignored and must be regenerated per platform. + +--- + +## Step-by-step (manual) + +### 1 — Build the native library + +```bash +# From the repo root +cargo build --profile release-cpp -p chia-wallet-sdk-cpp --target aarch64-apple-darwin +``` + +Output by platform: + +| Platform | File | +| -------- | ---- | +| macOS | `target//release-cpp/libchia_wallet_sdk.dylib` | +| Linux | `target//release-cpp/libchia_wallet_sdk.so` | +| Windows | `target//release-cpp/chia_wallet_sdk.dll` | + +The `release-cpp` profile inherits from `release` but sets `strip = "none"`, which is +required so that `uniffi-bindgen-cpp` can read the `UNIFFI_META_*` symbols that the +standard `--release` profile strips. + +### 2 — Install `uniffi-bindgen-cpp` + +This tool generates the C++ source from the compiled library. Install it once; the +version **must match** the `uniffi` crate version used here (`0.29.4`). + +```bash +cargo install uniffi-bindgen-cpp \ + --git https://github.com/NordSecurity/uniffi-bindgen-cpp \ + --tag v0.8.1+v0.29.4 +``` + +> **Version note:** the workspace pins `uniffi = "=0.29.4"`, and `uniffi-bindgen-cpp` +> `v0.8.1+v0.29.4` targets exactly that version — no patch-version mismatch (unlike the +> Go backend, which tolerates a benign 0.29.4→0.29.5 skew). + +### 3 — Generate the C++ source + +```bash +uniffi-bindgen-cpp \ + --library target/aarch64-apple-darwin/release-cpp/libchia_wallet_sdk.dylib \ + --out-dir cpp/chia_wallet_sdk +``` + +This writes `chia_wallet_sdk.hpp`, `chia_wallet_sdk.cpp`, and +`chia_wallet_sdk_scaffolding.hpp` into `cpp/chia_wallet_sdk/`. + +> **Note:** `uniffi-bindgen-cpp` does not support `--config` in `--library` mode, so the +> namespace is taken from the UniFFI component name (`chia_wallet_sdk`). Everything lives +> in the `chia_wallet_sdk` C++ namespace. + +--- + +## Using in a C++ Project + +Compile the generated `chia_wallet_sdk.cpp` alongside your code (C++20) and link the +native shared library: + +```bash +c++ -std=c++20 -Icpp/chia_wallet_sdk \ + your_app.cpp cpp/chia_wallet_sdk/chia_wallet_sdk.cpp \ + -Lcpp/chia_wallet_sdk -lchia_wallet_sdk -o your_app + +# Run (point the loader at the shared library) +DYLD_LIBRARY_PATH=cpp/chia_wallet_sdk ./your_app # macOS +LD_LIBRARY_PATH=cpp/chia_wallet_sdk ./your_app # Linux +``` + +Or use the provided CMake target — see `cpp/tests/CMakeLists.txt` for a working example. + +--- + +## Quick Start + +```cpp +#include +#include "chia_wallet_sdk.hpp" + +using namespace chia_wallet_sdk; + +int main() { + // CLVM round-trip + auto clvm = std::make_shared(); + auto nil = clvm->nil(); + auto atom = clvm->atom({1, 2, 3}); + + auto program = clvm->list({nil, atom}); + auto bytes = program->serialize(); + std::cout << "serialized " << bytes.size() << " bytes\n"; + + // Encode a puzzle hash as a Chia address + std::vector puzzle_hash(32, 0); + auto address = std::make_shared
(puzzle_hash, "xch"); + std::cout << address->encode() << "\n"; // "xch1..." + return 0; +} +``` + +> Method, type, and constructor names in the generated header follow +> `uniffi-bindgen-cpp` conventions. Inspect `chia_wallet_sdk.hpp` after generating for the +> exact signatures (objects are `std::shared_ptr`, errors are thrown as exceptions). + +--- + +## API Surface + +The bindings cover the full SDK: + +| Module | Types / Functions | +| ------ | ----------------- | +| BLS keys | `SecretKey`, `PublicKey`, `Signature`, `AggregateSignature` | +| Secp keys | `K1SecretKey`, `K1PublicKey`, `K1Signature`, `R1SecretKey`, `R1PublicKey`, `R1Signature` | +| Address | `Address` | +| Mnemonic | `Mnemonic` | +| Coin | `Coin`, `CoinSpend`, `CoinState` | +| CLVM | `Clvm`, `Program`, `CurriedProgram`, `Spend` | +| Conditions | `CreateCoin`, `AggSigMe`, `ReserveFee`, … (47 condition types) | +| Puzzles | `StandardPuzzle`, `CatPuzzle`, `NftPuzzle`, `DlPuzzle`, `SingletonPuzzle`, … | +| Offers | `Offer`, `ParsedOffer` | +| RPC | `RpcClient` (blocking — see *Async / RPC*) | +| Peer protocol | `Peer`, `Connector`, `Certificate` (blocking) | +| Simulator | `Simulator` (testing) | +| Utils | `sha256`, `hash_to_g2`, … | +| Constants | `Constants` (puzzle hashes for all built-in puzzles) | + +--- + +## Type Mapping Reference + +| Rust type | C++ type | Notes | +| --------- | -------- | ----- | +| `Vec` / bytes types | `std::vector` | Puzzle hashes, keys, signatures | +| `u64`, `u128`, `BigInt` | `std::string` | Parse as a decimal integer | +| `u8`–`u32`, `i8`–`i32` | fixed-width int types | Passed through directly | +| `f64` | `double` | | +| `bool` | `bool` | | +| `String` | `std::string` | | +| `Option` | `std::optional` | | +| `Vec` | `std::vector` | | +| Rust struct / class | `std::shared_ptr` | Reference-counted via `Arc` | +| Rust enum | C++ enum / variant | | + +### BigInt / amounts + +Chia amounts, heights, and arbitrary-precision integers are passed as decimal strings. +Parse them with your integer library of choice (e.g. `std::stoull` for values that fit in +64 bits, or a big-integer type for larger values). + +### Object lifetime + +Objects returned by the bindings are `std::shared_ptr` wrapping a reference-counted +Rust allocation. The Rust object is freed automatically when the last `shared_ptr` goes +out of scope — no manual `Destroy()` call is required (unlike the Go backend). + +--- + +## Async / RPC + +`uniffi-bindgen-cpp` does **not** support async functions. Rather than drop the async +surface, the C++ backend is generated with the `bindy_uniffi_sync!` macro, which exposes +every `async` method and async factory as an ordinary **blocking** call. Each one drives +the underlying future to completion on a shared Tokio runtime +(`chia_sdk_bindings::block_on`) before returning. + +```cpp +auto client = RpcClient::mainnet(); +auto state = client->get_blockchain_state(); // blocks until the HTTP request completes +std::cout << (state->get_success() ? "ok" : "failed") << "\n"; +``` + +Because these calls block the calling thread, run them on a worker thread if you need the +caller to stay responsive. This applies to `RpcClient` request methods and to the +peer-protocol classes (`Peer.connect`, `Peer.next`, etc.). The Go and C# backends keep +the native async API (Go: blocking; C#: `Task`); only the C++ surface is synchronous. + +--- + +## Architecture + +```text +bindings/*.json ← single source of truth for the API surface + ↓ +bindy_uniffi_sync! macro ← generates #[derive(uniffi::Object)] / #[uniffi::export] + ↓ (the `_sync` variant turns async methods into blocking calls) +cpp/ crate ← cdylib: setup_scaffolding! + generated + hand-written alloc + ↓ cargo build --profile release-cpp +libchia_wallet_sdk.dylib ← native shared library + ↓ uniffi-bindgen-cpp +chia_wallet_sdk.{hpp,cpp} ← C++ sources with all types and FFI bindings +``` + +The same `bindings/*.json` schemas also drive the Go (`go/`), C# (`dotnet/`), Node.js +(`napi/`), WebAssembly (`wasm/`), and Python (`pyo3/`) backends. Adding a method to a JSON +schema automatically adds it to all backends. + +--- + +## Known Limitations + +| Limitation | Details | +| ---------- | ------- | +| BigInt as string | `u64`/`u128`/`BigInt` map to `std::string`; parse on the C++ side. | +| `Clvm.alloc()` is typed | Unlike Python, which accepts dynamic types, `alloc` takes a `ClvmType` variant. Use the `Clvm` helper methods — `nil()`, `int()`, `atom()`, etc. — for primitive values. | +| Field setters return new objects | `set_field(value)` returns a new object with the field changed; the original is unchanged. Use the return value. | +| No `--config` in library mode | The namespace is fixed to `chia_wallet_sdk` by the UniFFI component name. | +| Async is blocking | `uniffi-bindgen-cpp` cannot generate async functions, so async methods (RPC, peer protocol) are exposed as blocking calls that run on a shared Tokio runtime (see *Async / RPC*). Run them off the main thread if responsiveness matters. | +| Generated-code patches | Two `uniffi-bindgen-cpp` defects are patched after generation: `Clvm::bool`/`int` → `bool_`/`int_` (reserved keywords), and the `VDFInfo`/`VDFProof` forward declarations → `VdfInfo`/`VdfProof` (acronym casing). Handled automatically by `local-build.sh`. | +| C++20 required | The generated code uses C++20 features. | + +--- + +## Running the Tests + +The `cpp/tests/` directory contains a CMake project whose suite mirrors the C# tests in +`dotnet/tests/BasicTests.cs` (hex/bytes utils, coin IDs, CLVM atom/string/int/pair +round-trips, public keys, serialization, currying, `alloc`, and condition parsing). + +```bash +# 1. Generate bindings + native library +cd cpp +./local-build.sh -t aarch64-apple-darwin + +# 2. Configure and build the test +cmake -S tests -B tests/build +cmake --build tests/build + +# 3. Run +ctest --test-dir tests/build --output-on-failure +``` + +--- + +## Rebuilding After SDK Updates + +When the SDK version bumps or new API is added, re-run the build script for each target +platform: + +```bash +cd cpp +./local-build.sh -t aarch64-apple-darwin +./local-build.sh -t x86_64-unknown-linux-gnu +``` + +No manual patches are needed — the generated files are always a complete, self-contained +replacement. diff --git a/cpp/build.rs b/cpp/build.rs new file mode 100644 index 000000000..e72dbff1c --- /dev/null +++ b/cpp/build.rs @@ -0,0 +1,4 @@ +fn main() { + println!("cargo::rerun-if-changed=../bindings"); + println!("cargo::rerun-if-changed=../bindings.json"); +} diff --git a/cpp/local-build.ps1 b/cpp/local-build.ps1 new file mode 100644 index 000000000..9d64255ce --- /dev/null +++ b/cpp/local-build.ps1 @@ -0,0 +1,73 @@ +[CmdletBinding()] +param( + [string]$Target = "x86_64-pc-windows-msvc" +) + +$ScriptDir = $PSScriptRoot + +# uniffi-bindgen-cpp v0.8.1 targets uniffi 0.29.4, which is exactly the version +# the workspace is pinned to (uniffi = "=0.29.4"). No patch-version mismatch here. +# See https://github.com/NordSecurity/uniffi-bindgen-cpp/tags for available tags. +$UniffiBindgenCppTag = "v0.8.1+v0.29.4" + +switch -Wildcard ($Target) { + "*-apple-*" { $LibExt = "dylib" } + "*-linux-*" { $LibExt = "so" } + "*-windows-*" { $LibExt = "dll" } + default { Write-Error "Unrecognized target triple: $Target"; exit 1 } +} + +$LibPrefix = if ($LibExt -eq "dll") { "" } else { "lib" } +$LibName = "${LibPrefix}chia_wallet_sdk.$LibExt" +$LibPath = Join-Path $ScriptDir ".." "target" $Target "release-cpp" $LibName + +Write-Host "Building native library for $Target..." +Push-Location (Join-Path $ScriptDir "..") +try { + cargo build --profile release-cpp -p chia-wallet-sdk-cpp --target $Target + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} finally { + Pop-Location +} + +if (-not (Get-Command uniffi-bindgen-cpp -ErrorAction SilentlyContinue)) { + Write-Host "Installing uniffi-bindgen-cpp $UniffiBindgenCppTag..." + cargo install uniffi-bindgen-cpp ` + --git https://github.com/NordSecurity/uniffi-bindgen-cpp ` + --tag $UniffiBindgenCppTag + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +Write-Host "Generating C++ bindings..." +$OutDir = Join-Path $ScriptDir "chia_wallet_sdk" +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +uniffi-bindgen-cpp --library $LibPath --out-dir $OutDir +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Patch two known uniffi-bindgen-cpp code-generation defects so the output compiles: +# 1. Clvm methods `bool`/`int` are emitted with their (reserved) C++ keyword names; +# rename them to `bool_`/`int_`. +# 2. The forward declarations for VdfInfo/VdfProof are emitted with all-caps acronyms +# (`VDFInfo`/`VDFProof`) while every other reference uses `VdfInfo`/`VdfProof`. +Write-Host "Patching generated bindings..." +$Hpp = Join-Path $OutDir "chia_wallet_sdk.hpp" +$Cpp = Join-Path $OutDir "chia_wallet_sdk.cpp" +$hppText = [System.IO.File]::ReadAllText($Hpp) +$hppText = $hppText ` + -replace 'std::shared_ptr bool\(', 'std::shared_ptr bool_(' ` + -replace 'std::shared_ptr int\(', 'std::shared_ptr int_(' ` + -replace 'struct VDFInfo;', 'struct VdfInfo;' ` + -replace 'struct VDFProof;', 'struct VdfProof;' +[System.IO.File]::WriteAllText($Hpp, $hppText) +$cppText = [System.IO.File]::ReadAllText($Cpp) +$cppText = $cppText ` + -replace 'Clvm::bool\(', 'Clvm::bool_(' ` + -replace 'Clvm::int\(', 'Clvm::int_(' +[System.IO.File]::WriteAllText($Cpp, $cppText) + +Write-Host "Staging native library..." +Copy-Item $LibPath $OutDir + +Write-Host "" +Write-Host "C++ bindings generated in: $OutDir" +Write-Host "Compile chia_wallet_sdk.cpp with C++20 and link against chia_wallet_sdk." diff --git a/cpp/local-build.sh b/cpp/local-build.sh new file mode 100755 index 000000000..184ff083e --- /dev/null +++ b/cpp/local-build.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# uniffi-bindgen-cpp v0.8.1 targets uniffi 0.29.4, which is exactly the version +# the workspace is pinned to (uniffi = "=0.29.4"). No patch-version mismatch here. +# See https://github.com/NordSecurity/uniffi-bindgen-cpp/tags for available tags. +UNIFFI_BINDGEN_CPP_TAG="v0.8.1+v0.29.4" + +usage() { + echo "Usage: $0 [-t TARGET]" + echo " -t TARGET Rust target triple (default: aarch64-apple-darwin)" + exit 1 +} + +TARGET="aarch64-apple-darwin" + +while getopts ":t:h" opt; do + case $opt in + t) TARGET="$OPTARG" ;; + h) usage ;; + :) echo "Option -$OPTARG requires an argument." >&2; usage ;; + \?) echo "Unknown option: -$OPTARG" >&2; usage ;; + esac +done + +# Derive library filename from the target triple +case "$TARGET" in + *-apple-*) LIB_EXT="dylib" ;; + *-linux-*) LIB_EXT="so" ;; + *-windows-*) LIB_EXT="dll" ;; + *) echo "Unrecognized target triple: $TARGET" >&2; exit 1 ;; +esac + +LIB_PREFIX=$( [ "$LIB_EXT" = "dll" ] && echo "" || echo "lib" ) +LIB_NAME="${LIB_PREFIX}chia_wallet_sdk.$LIB_EXT" +LIB_PATH="$SCRIPT_DIR/../target/$TARGET/release-cpp/$LIB_NAME" + +echo "Building native library for $TARGET..." +cd .. +cargo build --profile release-cpp -p chia-wallet-sdk-cpp --target "$TARGET" +cd "$SCRIPT_DIR" + +if ! command -v uniffi-bindgen-cpp &>/dev/null; then + echo "Installing uniffi-bindgen-cpp $UNIFFI_BINDGEN_CPP_TAG..." + cargo install uniffi-bindgen-cpp \ + --git https://github.com/NordSecurity/uniffi-bindgen-cpp \ + --tag "$UNIFFI_BINDGEN_CPP_TAG" +fi + +echo "Generating C++ bindings..." +mkdir -p "$SCRIPT_DIR/chia_wallet_sdk" +uniffi-bindgen-cpp \ + --library "$LIB_PATH" \ + --out-dir "$SCRIPT_DIR/chia_wallet_sdk" + +# Patch two known uniffi-bindgen-cpp code-generation defects so the output compiles: +# 1. Clvm methods `bool`/`int` are emitted with their (reserved) C++ keyword names; +# rename them to `bool_`/`int_`. +# 2. The forward declarations for VdfInfo/VdfProof are emitted with all-caps acronyms +# (`VDFInfo`/`VDFProof`) while every other reference uses `VdfInfo`/`VdfProof`. +echo "Patching generated bindings..." +HPP="$SCRIPT_DIR/chia_wallet_sdk/chia_wallet_sdk.hpp" +CPP="$SCRIPT_DIR/chia_wallet_sdk/chia_wallet_sdk.cpp" +sed -i.bak \ + -e 's/std::shared_ptr bool(/std::shared_ptr bool_(/' \ + -e 's/std::shared_ptr int(/std::shared_ptr int_(/' \ + -e 's/struct VDFInfo;/struct VdfInfo;/' \ + -e 's/struct VDFProof;/struct VdfProof;/' \ + "$HPP" +sed -i.bak \ + -e 's/Clvm::bool(/Clvm::bool_(/' \ + -e 's/Clvm::int(/Clvm::int_(/' \ + "$CPP" +rm -f "$HPP.bak" "$CPP.bak" + +echo "Staging native library..." +cp "$LIB_PATH" "$SCRIPT_DIR/chia_wallet_sdk/" + +echo "" +echo "C++ bindings generated in: $SCRIPT_DIR/chia_wallet_sdk/" +echo " chia_wallet_sdk.hpp - public C++ header" +echo " chia_wallet_sdk.cpp - implementation (compile and link into your app)" +echo " chia_wallet_sdk_scaffolding.hpp - FFI scaffolding declarations" +echo " $LIB_NAME - native shared library" +echo "" +echo "Build a consumer with C++20, compiling chia_wallet_sdk.cpp and linking the library:" +echo " c++ -std=c++20 -I$SCRIPT_DIR/chia_wallet_sdk your_app.cpp \\" +echo " $SCRIPT_DIR/chia_wallet_sdk/chia_wallet_sdk.cpp \\" +echo " -L$SCRIPT_DIR/chia_wallet_sdk -lchia_wallet_sdk -o your_app" diff --git a/cpp/src/lib.rs b/cpp/src/lib.rs new file mode 100644 index 000000000..357be4fb7 --- /dev/null +++ b/cpp/src/lib.rs @@ -0,0 +1,187 @@ +#![allow(clippy::too_many_arguments)] +// UniFFI's setup_scaffolding! macro emits raw FFI extern blocks which require unsafe. +#![allow(unsafe_code)] + +use std::sync::Arc; + +// UniFFI scaffolding — must match the [lib] name in Cargo.toml +uniffi::setup_scaffolding!("chia_wallet_sdk"); + +// Generate all bindings from the JSON schemas. +// The C++ backend uses the `_sync` variant because uniffi-bindgen-cpp does not support +// async functions. Async methods (e.g. RpcClient request methods, Peer) are therefore +// exposed as blocking calls that drive the future to completion on a shared Tokio runtime. +// Bound them with the client-level timeouts (RpcClientOptions / PeerOptions); there is no +// global backstop. +bindy_macro::bindy_uniffi_sync!("bindings.json"); + +// Hand-written alloc method for Clvm. +// The JSON schema marks alloc as stub_only because the argument type (ClvmType) +// requires manual dispatch. Here we implement it using the macro-generated ClvmType enum. +#[uniffi::export] +impl Clvm { + // Unlike the Python backend which accepts dynamic types (None, int, bool, str, bytes, list, tuple), + // the UniFFI backend uses a statically-typed ClvmType enum. To allocate nil/int/bool/string/bytes + // use the Clvm helper methods directly: nil(), int(), bool_(), string(), atom(), pair(), list(). + pub fn alloc(&self, value: ClvmType) -> Result, ChiaError> { + let result = match value { + ClvmType::Program { value } => value.0.clone(), + ClvmType::Pair { value } => self.0.pair(value.0.first.clone(), value.0.rest.clone())?, + ClvmType::CurriedProgram { value } => { + value.0.program.clone().curry(value.0.args.clone())? + } + ClvmType::PublicKey { value } => { + let bytes = value.0.to_bytes(); + self.0.atom(bytes.to_vec().into())? + } + ClvmType::Signature { value } => { + let bytes = value.0.to_bytes(); + self.0.atom(bytes.to_vec().into())? + } + ClvmType::K1PublicKey { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::K1Signature { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::R1PublicKey { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::R1Signature { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::Remark { value } => self.0.remark(value.0.rest.clone())?, + ClvmType::AggSigParent { value } => self + .0 + .agg_sig_parent(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigPuzzle { value } => self + .0 + .agg_sig_puzzle(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigAmount { value } => self + .0 + .agg_sig_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigPuzzleAmount { value } => self + .0 + .agg_sig_puzzle_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigParentAmount { value } => self + .0 + .agg_sig_parent_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigParentPuzzle { value } => self + .0 + .agg_sig_parent_puzzle(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigUnsafe { value } => self + .0 + .agg_sig_unsafe(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigMe { value } => self + .0 + .agg_sig_me(value.0.public_key, value.0.message.clone())?, + ClvmType::CreateCoin { value } => { + self.0 + .create_coin(value.0.puzzle_hash, value.0.amount, value.0.memos.clone())? + } + ClvmType::ReserveFee { value } => self.0.reserve_fee(value.0.amount)?, + ClvmType::CreateCoinAnnouncement { value } => { + self.0.create_coin_announcement(value.0.message.clone())? + } + ClvmType::CreatePuzzleAnnouncement { value } => { + self.0.create_puzzle_announcement(value.0.message.clone())? + } + ClvmType::AssertCoinAnnouncement { value } => { + self.0.assert_coin_announcement(value.0.announcement_id)? + } + ClvmType::AssertPuzzleAnnouncement { value } => { + self.0.assert_puzzle_announcement(value.0.announcement_id)? + } + ClvmType::AssertConcurrentSpend { value } => { + self.0.assert_concurrent_spend(value.0.coin_id)? + } + ClvmType::AssertConcurrentPuzzle { value } => { + self.0.assert_concurrent_puzzle(value.0.puzzle_hash)? + } + ClvmType::AssertSecondsRelative { value } => { + self.0.assert_seconds_relative(value.0.seconds)? + } + ClvmType::AssertSecondsAbsolute { value } => { + self.0.assert_seconds_absolute(value.0.seconds)? + } + ClvmType::AssertHeightRelative { value } => { + self.0.assert_height_relative(value.0.height)? + } + ClvmType::AssertHeightAbsolute { value } => { + self.0.assert_height_absolute(value.0.height)? + } + ClvmType::AssertBeforeSecondsRelative { value } => { + self.0.assert_before_seconds_relative(value.0.seconds)? + } + ClvmType::AssertBeforeSecondsAbsolute { value } => { + self.0.assert_before_seconds_absolute(value.0.seconds)? + } + ClvmType::AssertBeforeHeightRelative { value } => { + self.0.assert_before_height_relative(value.0.height)? + } + ClvmType::AssertBeforeHeightAbsolute { value } => { + self.0.assert_before_height_absolute(value.0.height)? + } + ClvmType::AssertMyCoinId { value } => self.0.assert_my_coin_id(value.0.coin_id)?, + ClvmType::AssertMyParentId { value } => { + self.0.assert_my_parent_id(value.0.parent_id)? + } + ClvmType::AssertMyPuzzleHash { value } => { + self.0.assert_my_puzzle_hash(value.0.puzzle_hash)? + } + ClvmType::AssertMyAmount { value } => self.0.assert_my_amount(value.0.amount)?, + ClvmType::AssertMyBirthSeconds { value } => { + self.0.assert_my_birth_seconds(value.0.seconds)? + } + ClvmType::AssertMyBirthHeight { value } => { + self.0.assert_my_birth_height(value.0.height)? + } + ClvmType::AssertEphemeral { .. } => self.0.assert_ephemeral()?, + ClvmType::SendMessage { value } => { + self.0 + .send_message(value.0.mode, value.0.message.clone(), value.0.data.clone())? + } + ClvmType::ReceiveMessage { value } => self.0.receive_message( + value.0.mode, + value.0.message.clone(), + value.0.data.clone(), + )?, + ClvmType::Softfork { value } => self.0.softfork(value.0.cost, value.0.rest.clone())?, + ClvmType::MeltSingleton { .. } => self.0.melt_singleton()?, + ClvmType::TransferNft { value } => self.0.transfer_nft( + value.0.launcher_id, + value.0.trade_prices.clone(), + value.0.singleton_inner_puzzle_hash, + )?, + ClvmType::RunCatTail { value } => self + .0 + .run_cat_tail(value.0.program.clone(), value.0.solution.clone())?, + ClvmType::UpdateNftMetadata { value } => self.0.update_nft_metadata( + value.0.updater_puzzle_reveal.clone(), + value.0.updater_solution.clone(), + )?, + ClvmType::UpdateDataStoreMerkleRoot { value } => self + .0 + .update_data_store_merkle_root(value.0.new_merkle_root, value.0.memos.clone())?, + ClvmType::NftMetadata { value } => self.0.nft_metadata(value.0.clone())?, + ClvmType::MipsMemo { value } => self.0.mips_memo(value.0.clone())?, + ClvmType::InnerPuzzleMemo { value } => self.0.inner_puzzle_memo(value.0.clone())?, + ClvmType::RestrictionMemo { value } => self.0.restriction_memo(value.0.clone())?, + ClvmType::WrapperMemo { value } => self.0.wrapper_memo(value.0.clone())?, + ClvmType::Force1of2RestrictedVariableMemo { value } => self + .0 + .force_1_of_2_restricted_variable_memo(value.0.clone())?, + ClvmType::MemoKind { value } => self.0.memo_kind(value.0.clone())?, + ClvmType::MemberMemo { value } => self.0.member_memo(value.0.clone())?, + ClvmType::MofNMemo { value } => self.0.m_of_n_memo(value.0.clone())?, + ClvmType::OptionMetadata { value } => self.0.option_metadata(value.0)?, + ClvmType::NotarizedPayment { value } => self.0.notarized_payment(value.0.clone())?, + ClvmType::Payment { value } => self.0.payment(value.0.clone())?, + }; + Ok(Arc::new(Program(result))) + } +} diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt new file mode 100644 index 000000000..abfc130ec --- /dev/null +++ b/cpp/tests/CMakeLists.txt @@ -0,0 +1,76 @@ +cmake_minimum_required(VERSION 3.20) +project(chia_wallet_sdk_cpp_tests CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Directory holding the generated bindings + staged native library, produced by +# `../local-build.sh`. Override with -DBINDINGS_DIR=... if generated elsewhere. +set(BINDINGS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../chia_wallet_sdk" + CACHE PATH "Directory with generated chia_wallet_sdk.{hpp,cpp} and the native library") + +if(NOT EXISTS "${BINDINGS_DIR}/chia_wallet_sdk.cpp") + message(FATAL_ERROR + "Generated bindings not found in ${BINDINGS_DIR}.\n" + "Run ../local-build.sh first (from the cpp/ directory).") +endif() + +add_executable(sdk_tests + sdk_tests.cpp + "${BINDINGS_DIR}/chia_wallet_sdk.cpp") + +target_include_directories(sdk_tests PRIVATE "${BINDINGS_DIR}") + +# Link the staged native shared library (libchia_wallet_sdk.{dylib,so} / chia_wallet_sdk.dll). +find_library(CHIA_WALLET_SDK_LIB + NAMES chia_wallet_sdk + PATHS "${BINDINGS_DIR}" + NO_DEFAULT_PATH) + +if(NOT CHIA_WALLET_SDK_LIB) + message(FATAL_ERROR "Native library not found in ${BINDINGS_DIR}. Run ../local-build.sh first.") +endif() + +target_link_libraries(sdk_tests PRIVATE "${CHIA_WALLET_SDK_LIB}") + +enable_testing() + +# One ctest entry per logical test, kept in sync with kTests in sdk_tests.cpp. +set(TEST_NAMES + to_hex_from_hex_roundtrip + bytes_equal_equal + bytes_equal_not_equal + coin_id_known_value + atom_roundtrip + string_roundtrip + int_roundtrip + pair_roundtrip + public_key_roundtrip + clvm_serialization + curry_roundtrip + alloc_multiple_types + create_and_parse_condition) + +if(APPLE) + set(LOADER_ENV "DYLD_LIBRARY_PATH=${BINDINGS_DIR}") +else() + set(LOADER_ENV "LD_LIBRARY_PATH=${BINDINGS_DIR}") +endif() + +foreach(test_name IN LISTS TEST_NAMES) + add_test(NAME ${test_name} COMMAND sdk_tests ${test_name}) + set_tests_properties(${test_name} PROPERTIES ENVIRONMENT "${LOADER_ENV}") +endforeach() + +# Async integration tests (network-dependent, run manually via build-and-test-async.sh) +add_executable(async_sdk_tests + async_tests.cpp + "${BINDINGS_DIR}/chia_wallet_sdk.cpp") + +target_include_directories(async_sdk_tests PRIVATE "${BINDINGS_DIR}") +target_link_libraries(async_sdk_tests PRIVATE "${CHIA_WALLET_SDK_LIB}") + +add_test(NAME async_get_blockchain_state COMMAND async_sdk_tests) +set_tests_properties(async_get_blockchain_state PROPERTIES + ENVIRONMENT "${LOADER_ENV}" + LABELS "integration") diff --git a/cpp/tests/async_tests.cpp b/cpp/tests/async_tests.cpp new file mode 100644 index 000000000..ce155ae95 --- /dev/null +++ b/cpp/tests/async_tests.cpp @@ -0,0 +1,78 @@ +// Async integration smoke test for the C++ binding's Tokio async runtime bridge. +// Calls RpcClient::mainnet()->get_blockchain_state() to verify that the block_on +// wrapper works correctly for Rust async functions exposed as synchronous C++ calls. +// +// Exit codes: +// 0 — test passed, or skipped due to network unavailability +// 1 — assertion failure or unexpected exception + +#include +#include +#include +#include + +#include "chia_wallet_sdk.hpp" + +using namespace chia_wallet_sdk; + +namespace { + +// Heuristic: treat failures that look like real network unavailability as a skip, +// not a test failure. Kept narrow on purpose — overly broad substrings (like "request") +// would mask genuine programming bugs that incidentally surface a similar word. +bool is_network_error(const std::string& msg) { + auto lower = msg; + std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower); + return lower.find("timeout:") != std::string::npos || + lower.find("connect") != std::string::npos || + lower.find("network") != std::string::npos || + lower.find("dns") != std::string::npos || + lower.find("host") != std::string::npos; +} + +} // namespace + +int main() { + try { + // Bound the request so a hung endpoint surfaces as a timeout (treated as a + // network skip below) instead of blocking forever. + auto options = RpcClientOptions::init(/*request_timeout_ms=*/30000, /*connect_timeout_ms=*/10000); + auto rpc = RpcClient::mainnet()->with_options(options); + auto response = rpc->get_blockchain_state(); + + if (!response->get_success()) { + std::cerr << "FAILED: get_blockchain_state returned success=false\n"; + return 1; + } + + // get_blockchain_state() returns std::shared_ptr (not optional) + auto state = response->get_blockchain_state(); + if (state == nullptr) { + std::cerr << "FAILED: blockchain_state is null\n"; + return 1; + } + + std::cout << "async_get_blockchain_state passed\n"; + + // Second Coinset call over the same timeout-bounded client. + auto network_info = rpc->get_network_info(); + if (!network_info->get_success()) { + std::cerr << "FAILED: get_network_info returned success=false\n"; + return 1; + } + if (!network_info->get_network_name().has_value()) { + std::cerr << "FAILED: network_name is empty\n"; + return 1; + } + + std::cout << "async_get_network_info passed\n"; + return 0; + } catch (const std::exception& e) { + if (is_network_error(e.what())) { + std::cout << "SKIP: network unavailable: " << e.what() << "\n"; + return 0; + } + std::cerr << "FAILED: " << e.what() << "\n"; + return 1; + } +} diff --git a/cpp/tests/sdk_tests.cpp b/cpp/tests/sdk_tests.cpp new file mode 100644 index 000000000..dd54883fe --- /dev/null +++ b/cpp/tests/sdk_tests.cpp @@ -0,0 +1,225 @@ +// C++ binding tests. Mirrors dotnet/tests/BasicTests.cs (the canonical suite) so that +// the Go, C#, C++, and Python bindings all exercise equivalent behavior. Each test is a +// function registered in `kTests`; CMake/ctest runs each one by name. + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "chia_wallet_sdk.hpp" + +using namespace chia_wallet_sdk; + +namespace { + +void require(bool condition, const std::string &message) { + if (!condition) { + throw std::runtime_error(message); + } +} + +void require_eq_str(const std::string &actual, const std::string &expected, + const std::string &what) { + if (actual != expected) { + throw std::runtime_error(what + " mismatch\n got: " + actual + "\n want: " + expected); + } +} + +// -- Tests ---------------------------------------------------------------------------- + +void to_hex_from_hex_roundtrip() { + auto bytes = from_hex("ff"); + require_eq_str(to_hex(bytes), "ff", "to_hex(from_hex)"); +} + +void bytes_equal_equal() { + require(bytes_equal({1, 2, 3}, {1, 2, 3}), "bytes_equal should be true"); +} + +void bytes_equal_not_equal() { + require(!bytes_equal({1, 2, 3}, {1, 2, 4}), "bytes_equal should be false"); +} + +void coin_id_known_value() { + auto coin = Coin::init( + from_hex("4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"), + from_hex("dbc1b4c900ffe48d575b5da5c638040125f65db0fe3e24494b76ea986457d986"), + "100"); + require_eq_str(to_hex(coin->coin_id()), + "fd3e669c27be9d634fe79f1f7d7d8aaacc3597b855cffea1d708f4642f1d542a", + "coin_id"); +} + +void atom_roundtrip() { + auto clvm = Clvm::init(); + std::vector expected{1, 2, 3}; + auto program = clvm->atom(expected); + auto atom = program->to_atom(); + require(atom.has_value(), "to_atom should be present"); + require(*atom == expected, "atom roundtrip"); +} + +void string_roundtrip() { + auto clvm = Clvm::init(); + const std::string expected = "hello world"; + auto program = clvm->atom(std::vector(expected.begin(), expected.end())); + auto str = program->to_string(); + require(str.has_value(), "to_string should be present"); + require_eq_str(*str, expected, "string roundtrip"); +} + +void int_roundtrip() { + auto clvm = Clvm::init(); + for (const std::string &value : {"0", "1", "420", "-1", "-100", "67108863"}) { + auto program = clvm->int_(value); + auto got = program->to_int(); + require(got.has_value(), "to_int should be present"); + require_eq_str(*got, value, "int roundtrip"); + } +} + +void pair_roundtrip() { + auto clvm = Clvm::init(); + auto first = clvm->int_("1"); + auto rest = clvm->int_("100"); + auto pair = clvm->pair(first, rest); + auto result = pair->to_pair(); + require(result != nullptr, "to_pair should be present"); + require_eq_str(result->get_first()->to_int().value(), "1", "pair first"); + require_eq_str(result->get_rest()->to_int().value(), "100", "pair rest"); +} + +void public_key_roundtrip() { + auto original = PublicKey::infinity(); + auto bytes = original->to_bytes(); + auto restored = PublicKey::from_bytes(bytes); + require(bytes_equal(original->to_bytes(), restored->to_bytes()), "public key roundtrip"); +} + +void clvm_serialization() { + auto clvm = Clvm::init(); + struct Case { + std::shared_ptr program; + std::string hex; + }; + std::vector cases{ + {clvm->atom({1, 2, 3}), "83010203"}, + {clvm->int_("420"), "8201a4"}, + {clvm->int_("100"), "64"}, + {clvm->pair(clvm->atom({1, 2, 3}), clvm->int_("100")), "ff8301020364"}, + }; + for (auto &c : cases) { + auto serialized = c.program->serialize(); + auto deserialized = clvm->deserialize(serialized); + require_eq_str(to_hex(serialized), c.hex, "serialize"); + require(bytes_equal(c.program->tree_hash(), deserialized->tree_hash()), "tree_hash"); + } +} + +void curry_roundtrip() { + auto clvm = Clvm::init(); + std::vector> items; + for (int i = 0; i < 10; ++i) { + items.push_back(clvm->int_(std::to_string(i))); + } + auto curried = clvm->nil()->curry(items); + auto uncurried = curried->uncurry(); + require(uncurried != nullptr, "uncurry should be present"); + require(bytes_equal(clvm->nil()->tree_hash(), uncurried->get_program()->tree_hash()), + "uncurried program"); + auto args = uncurried->get_args(); + require(args.size() == 10, "uncurried args count"); + for (int i = 0; i < 10; ++i) { + require_eq_str(args[i]->to_int().value(), std::to_string(i), "uncurried arg"); + } +} + +void alloc_multiple_types() { + auto clvm = Clvm::init(); + auto run_cat_tail = RunCatTail::init(clvm->nil(), clvm->nil()); + auto program = clvm->list(std::vector>{ + clvm->nil(), + clvm->alloc(ClvmType(ClvmType::kPublicKey{PublicKey::infinity()})), + clvm->atom([] { + const std::string s = "Hello, world!"; + return std::vector(s.begin(), s.end()); + }()), + clvm->int_("42"), + clvm->int_("100"), + clvm->bool_(true), + clvm->atom({1, 2, 3}), + clvm->atom(std::vector(32, 0)), + clvm->nil(), + clvm->nil(), + clvm->alloc(ClvmType(ClvmType::kRunCatTail{run_cat_tail})), + }); + require_eq_str( + to_hex(program->serialize()), + "ff80ffb0c0000000000000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000ff8d48656c6c6f2c20776f726c6421ff2aff64ff01ff83010203ffa00000" + "000000000000000000000000000000000000000000000000000000000000ff80ff80ffff33ff80ff81" + "8fff80ff808080", + "alloc serialization"); +} + +void create_and_parse_condition() { + auto clvm = Clvm::init(); + std::vector puzzle_hash(32, 0xff); + auto memos = clvm->list(std::vector>{clvm->atom(puzzle_hash)}); + auto condition = clvm->create_coin(puzzle_hash, "1", memos); + auto parsed = condition->parse_create_coin(); + require(parsed != nullptr, "parse_create_coin should be present"); + require(bytes_equal(puzzle_hash, parsed->get_puzzle_hash()), "parsed puzzle hash"); + require_eq_str(parsed->get_amount(), "1", "parsed amount"); +} + +const std::map> kTests{ + {"to_hex_from_hex_roundtrip", to_hex_from_hex_roundtrip}, + {"bytes_equal_equal", bytes_equal_equal}, + {"bytes_equal_not_equal", bytes_equal_not_equal}, + {"coin_id_known_value", coin_id_known_value}, + {"atom_roundtrip", atom_roundtrip}, + {"string_roundtrip", string_roundtrip}, + {"int_roundtrip", int_roundtrip}, + {"pair_roundtrip", pair_roundtrip}, + {"public_key_roundtrip", public_key_roundtrip}, + {"clvm_serialization", clvm_serialization}, + {"curry_roundtrip", curry_roundtrip}, + {"alloc_multiple_types", alloc_multiple_types}, + {"create_and_parse_condition", create_and_parse_condition}, +}; + +int run_one(const std::string &name) { + auto it = kTests.find(name); + if (it == kTests.end()) { + std::cerr << "unknown test: " << name << "\n"; + return 2; + } + try { + it->second(); + } catch (const std::exception &e) { + std::cerr << name << " FAILED: " << e.what() << "\n"; + return 1; + } + std::cout << name << " passed\n"; + return 0; +} + +} // namespace + +int main(int argc, char **argv) { + if (argc > 1) { + return run_one(argv[1]); + } + int failures = 0; + for (const auto &[name, _] : kTests) { + failures += run_one(name) == 0 ? 0 : 1; + } + return failures == 0 ? 0 : 1; +} diff --git a/crates/chia-sdk-bindings/Cargo.toml b/crates/chia-sdk-bindings/Cargo.toml index 248270a89..2366842ce 100644 --- a/crates/chia-sdk-bindings/Cargo.toml +++ b/crates/chia-sdk-bindings/Cargo.toml @@ -18,6 +18,7 @@ workspace = true napi = ["dep:chia-sdk-client", "dep:chia-ssl", "dep:tokio", "bindy/napi"] wasm = ["bindy/wasm"] pyo3 = ["dep:chia-sdk-client", "dep:chia-ssl", "dep:tokio", "bindy/pyo3"] +uniffi = ["dep:chia-sdk-client", "dep:chia-ssl", "dep:tokio", "bindy/uniffi", "tokio/rt-multi-thread"] [dependencies] chia-sdk-utils = { workspace = true } diff --git a/crates/chia-sdk-bindings/bindy-macro/src/lib.rs b/crates/chia-sdk-bindings/bindy-macro/src/lib.rs index a404142e1..b2e37aede 100644 --- a/crates/chia-sdk-bindings/bindy-macro/src/lib.rs +++ b/crates/chia-sdk-bindings/bindy-macro/src/lib.rs @@ -29,6 +29,8 @@ struct Bindy { #[serde(default)] pyo3_stubs: IndexMap, #[serde(default)] + uniffi: IndexMap, + #[serde(default)] clvm_types: Vec, } @@ -1717,3 +1719,421 @@ fn function_args( results.reverse(); results.join(", ") } + +#[proc_macro] +pub fn bindy_uniffi(input: TokenStream) -> TokenStream { + bindy_uniffi_impl(input, true) +} + +/// Same as [`bindy_uniffi`], but exposes async methods and async factories as blocking +/// (synchronous) calls. +/// +/// `uniffi-bindgen-cpp` does not support async functions, so the C++ backend uses this +/// variant. Each `async`/`async_factory` method is generated as a plain `fn` that drives +/// the future to completion on the shared Tokio runtime via `chia_sdk_bindings::block_on`. +/// The Go and C# backends keep using [`bindy_uniffi`] (true async) and are unaffected. +#[proc_macro] +pub fn bindy_uniffi_sync(input: TokenStream) -> TokenStream { + bindy_uniffi_impl(input, false) +} + +fn bindy_uniffi_impl(input: TokenStream, include_async: bool) -> TokenStream { + let input = syn::parse_macro_input!(input as LitStr).value(); + let (bindy, bindings) = load_bindings(&input); + + let entrypoint = Ident::new(&bindy.entrypoint, Span::mixed_site()); + + let mut mappings = bindy.uniffi.clone(); + build_base_mappings(&bindy, &mut mappings, &mut IndexMap::new()); + + // Wrap all class types in Arc so apply_mappings("Program") → "std::sync::Arc" + for (name, binding) in &bindings { + if matches!(binding, Binding::Class { .. }) { + mappings.insert(name.clone(), format!("std::sync::Arc<{name}>")); + } + } + + // Generate ChiaError + From + let mut output = quote! { + #[derive(Debug, uniffi::Error)] + #[uniffi(flat_error)] + pub enum ChiaError { + Error { message: String }, + } + + impl std::fmt::Display for ChiaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Error { message } => write!(f, "{message}"), + } + } + } + + impl std::error::Error for ChiaError {} + + impl From for ChiaError { + fn from(e: bindy::Error) -> Self { + Self::Error { message: e.to_string() } + } + } + }; + + // Generate ClvmType enum — UniFFI enum with one struct-variant per CLVM type + let clvm_types = bindy + .clvm_types + .iter() + .map(|s| Ident::new(s, Span::mixed_site())) + .collect::>(); + + output.extend(quote! { + #[derive(uniffi::Enum)] + pub enum ClvmType { + #( #clvm_types { value: std::sync::Arc<#clvm_types> }, )* + } + }); + + // Process each binding + for (name, binding) in &bindings { + let bound_ident = Ident::new(name, Span::mixed_site()); + + match binding { + Binding::Class { + new, + remote, + methods, + fields, + no_wasm: _, + } => { + let rust_struct_ident = quote!( #entrypoint::#bound_ident ); + let fully_qualified_ident = if *remote { + let ext_ident = Ident::new(&format!("{name}Ext"), Span::mixed_site()); + quote!( <#rust_struct_ident as #entrypoint::#ext_ident> ) + } else { + quote!( #rust_struct_ident ) + }; + + let mut method_tokens = quote!(); + let mut static_fn_tokens = quote!(); + + // Whether the generated impl block ends up containing any async fns. Used + // below to decide whether to tag the `#[uniffi::export]` with + // `async_runtime = "tokio"` — UniFFI rejects that attribute on impl blocks + // that have no async methods. + let has_async_method = include_async + && methods.values().any(|m| { + !m.stub_only + && matches!(m.kind, MethodKind::Async | MethodKind::AsyncFactory) + }); + + // Methods from JSON schema + for (method_name, method) in methods { + if method.stub_only { + continue; // alloc and others marked stub_only are hand-written + } + + let method_ident = Ident::new(method_name, Span::mixed_site()); + let arg_idents = method + .args + .keys() + .map(|k| Ident::new(k, Span::mixed_site())) + .collect::>(); + let arg_types = method + .args + .values() + .map(|v| parse_str::(&apply_mappings(v, &mappings)).unwrap()) + .collect::>(); + + // For Factory/Constructor, return Arc; otherwise map the return type + let ret_str = if method.ret.is_none() + && matches!( + method.kind, + MethodKind::Constructor + | MethodKind::Factory + | MethodKind::AsyncFactory + ) { + "std::sync::Arc".to_string() + } else { + apply_mappings(method.ret.as_deref().unwrap_or("()"), &mappings) + }; + let ret = parse_str::(&ret_str).unwrap(); + + match method.kind { + MethodKind::Constructor | MethodKind::Factory => { + method_tokens.extend(quote! { + #[uniffi::constructor] + pub fn #method_ident( + #( #arg_idents: #arg_types ),* + ) -> Result, ChiaError> { + Ok(std::sync::Arc::new( + bindy::FromRust::<_, _, bindy::Uniffi>::from_rust( + #fully_qualified_ident::#method_ident( + #( bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)? ),* + )?, + &bindy::UniffiContext + )? + )) + } + }); + } + MethodKind::AsyncFactory if include_async => { + method_tokens.extend(quote! { + #[uniffi::constructor] + pub async fn #method_ident( + #( #arg_idents: #arg_types ),* + ) -> Result, ChiaError> { + Ok(std::sync::Arc::new( + bindy::FromRust::<_, _, bindy::Uniffi>::from_rust( + #fully_qualified_ident::#method_ident( + #( bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)? ),* + ).await?, + &bindy::UniffiContext + )? + )) + } + }); + } + // C++ (uniffi-bindgen-cpp) cannot generate async functions, so the + // bindy_uniffi_sync! variant exposes async factories as blocking calls + // that drive the future to completion on the shared Tokio runtime. + MethodKind::AsyncFactory => { + method_tokens.extend(quote! { + #[uniffi::constructor] + pub fn #method_ident( + #( #arg_idents: #arg_types ),* + ) -> Result, ChiaError> { + #( let #arg_idents = bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)?; )* + let __value = #entrypoint::block_on(async move { + #fully_qualified_ident::#method_ident( #( #arg_idents ),* ).await + })?; + Ok(std::sync::Arc::new( + bindy::FromRust::<_, _, bindy::Uniffi>::from_rust(__value, &bindy::UniffiContext)? + )) + } + }); + } + MethodKind::Normal | MethodKind::ToString => { + method_tokens.extend(quote! { + pub fn #method_ident( + &self, + #( #arg_idents: #arg_types ),* + ) -> Result<#ret, ChiaError> { + Ok(bindy::FromRust::<_, _, bindy::Uniffi>::from_rust( + #fully_qualified_ident::#method_ident( + &self.0, + #( bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)? ),* + )?, + &bindy::UniffiContext + )?) + } + }); + } + MethodKind::Static => { + // UniFFI 0.28 does not support associated functions (static methods + // without &self) inside #[uniffi::export] impl blocks. Emit them as + // standalone free functions with a name prefixed by the class name in + // snake_case to avoid collisions. + let fn_name = Ident::new( + &format!("{}_{}", name.to_case(Case::Snake), method_name), + Span::mixed_site(), + ); + static_fn_tokens.extend(quote! { + #[uniffi::export] + pub fn #fn_name( + #( #arg_idents: #arg_types ),* + ) -> Result<#ret, ChiaError> { + Ok(bindy::FromRust::<_, _, bindy::Uniffi>::from_rust( + #fully_qualified_ident::#method_ident( + #( bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)? ),* + )?, + &bindy::UniffiContext + )?) + } + }); + } + // NOTE: Async methods on remote: true classes (Ext trait pattern) will fail to + // compile because clone_of_self.method() calls the inherent method, not the + // extension trait. No remote class currently has async methods in the schemas, + // but if one is added this arm must use fully_qualified_ident instead. + MethodKind::Async if include_async => { + method_tokens.extend(quote! { + pub async fn #method_ident( + &self, + #( #arg_idents: #arg_types ),* + ) -> Result<#ret, ChiaError> { + let clone_of_self = self.0.clone(); + #( let #arg_idents = bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)?; )* + Ok(bindy::FromRust::<_, _, bindy::Uniffi>::from_rust( + clone_of_self.#method_ident( #( #arg_idents ),* ).await?, + &bindy::UniffiContext + )?) + } + }); + } + // C++ (uniffi-bindgen-cpp) cannot generate async functions, so the + // bindy_uniffi_sync! variant exposes async methods as blocking calls that + // drive the future to completion on the shared Tokio runtime. + MethodKind::Async => { + method_tokens.extend(quote! { + pub fn #method_ident( + &self, + #( #arg_idents: #arg_types ),* + ) -> Result<#ret, ChiaError> { + let clone_of_self = self.0.clone(); + #( let #arg_idents = bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)?; )* + let __value = #entrypoint::block_on(async move { + clone_of_self.#method_ident( #( #arg_idents ),* ).await + })?; + Ok(bindy::FromRust::<_, _, bindy::Uniffi>::from_rust(__value, &bindy::UniffiContext)?) + } + }); + } + } + } + + // Field getters/setters + let mut field_tokens = quote!(); + for (field_name, ty) in fields { + let field_ident = Ident::new(field_name, Span::mixed_site()); + let get_ident = Ident::new(&format!("get_{field_name}"), Span::mixed_site()); + let set_ident = Ident::new(&format!("set_{field_name}"), Span::mixed_site()); + let field_ty = parse_str::(&apply_mappings(ty, &mappings)).unwrap(); + + field_tokens.extend(quote! { + pub fn #get_ident(&self) -> Result<#field_ty, ChiaError> { + Ok(bindy::FromRust::<_, _, bindy::Uniffi>::from_rust( + self.0.#field_ident.clone(), + &bindy::UniffiContext + )?) + } + pub fn #set_ident(&self, value: #field_ty) -> Result, ChiaError> { + let mut inner = self.0.clone(); + inner.#field_ident = bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(value, &bindy::UniffiContext)?; + Ok(std::sync::Arc::new(Self(inner))) + } + }); + } + + // new: true → constructor from fields + if *new { + let arg_idents = fields + .keys() + .map(|k| Ident::new(k, Span::mixed_site())) + .collect::>(); + let arg_types = fields + .values() + .map(|v| parse_str::(&apply_mappings(v, &mappings)).unwrap()) + .collect::>(); + + method_tokens.extend(quote! { + #[uniffi::constructor] + pub fn new( + #( #arg_idents: #arg_types ),* + ) -> Result, ChiaError> { + Ok(std::sync::Arc::new(Self(#rust_struct_ident { + #( #arg_idents: bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)? ),* + }))) + } + }); + } + + // When the generated impl block exposes any async methods (Go/C# backends), + // tag the block with `async_runtime = "tokio"` so UniFFI wraps each polled + // future in `async_compat::Compat` + let export_attr = if has_async_method { + quote!( #[uniffi::export(async_runtime = "tokio")] ) + } else { + quote!( #[uniffi::export] ) + }; + + output.extend(quote! { + #[derive(Clone, uniffi::Object)] + pub struct #bound_ident(#rust_struct_ident); + + #export_attr + impl #bound_ident { + #method_tokens + #field_tokens + } + + // Static methods emitted as free functions (UniFFI 0.28 limitation) + #static_fn_tokens + + impl bindy::FromRust<#rust_struct_ident, bindy::UniffiContext, bindy::Uniffi> + for #bound_ident + { + fn from_rust(value: #rust_struct_ident, _context: &bindy::UniffiContext) -> bindy::Result { + Ok(#bound_ident(value)) + } + } + + impl bindy::IntoRust<#rust_struct_ident, bindy::UniffiContext, bindy::Uniffi> + for #bound_ident + { + fn into_rust(self, _context: &bindy::UniffiContext) -> bindy::Result<#rust_struct_ident> { + Ok(self.0.clone()) + } + } + }); + } + Binding::Enum { values } => { + let rust_ident = quote!( #entrypoint::#bound_ident ); + let value_idents = values + .iter() + .map(|v| Ident::new(v, Span::mixed_site())) + .collect::>(); + + output.extend(quote! { + #[derive(uniffi::Enum, Clone, PartialEq, Eq)] + pub enum #bound_ident { + #( #value_idents ),* + } + + impl bindy::FromRust<#rust_ident, bindy::UniffiContext, bindy::Uniffi> for #bound_ident { + fn from_rust(value: #rust_ident, _context: &bindy::UniffiContext) -> bindy::Result { + Ok(match value { + #( #rust_ident::#value_idents => Self::#value_idents ),* + }) + } + } + + impl bindy::IntoRust<#rust_ident, bindy::UniffiContext, bindy::Uniffi> for #bound_ident { + fn into_rust(self, _context: &bindy::UniffiContext) -> bindy::Result<#rust_ident> { + Ok(match self { + #( Self::#value_idents => #rust_ident::#value_idents ),* + }) + } + } + }); + } + Binding::Function { args, ret } => { + let arg_idents = args + .keys() + .map(|k| Ident::new(k, Span::mixed_site())) + .collect::>(); + let arg_types = args + .values() + .map(|v| parse_str::(&apply_mappings(v, &mappings)).unwrap()) + .collect::>(); + let ret = + parse_str::(&apply_mappings(ret.as_deref().unwrap_or("()"), &mappings)) + .unwrap(); + + output.extend(quote! { + #[uniffi::export] + pub fn #bound_ident( + #( #arg_idents: #arg_types ),* + ) -> Result<#ret, ChiaError> { + Ok(bindy::FromRust::<_, _, bindy::Uniffi>::from_rust( + #entrypoint::#bound_ident( + #( bindy::IntoRust::<_, _, bindy::Uniffi>::into_rust(#arg_idents, &bindy::UniffiContext)? ),* + )?, + &bindy::UniffiContext + )?) + } + }); + } + } + } + + output.into() +} diff --git a/crates/chia-sdk-bindings/bindy/Cargo.toml b/crates/chia-sdk-bindings/bindy/Cargo.toml index 1c4cbf8c7..b98dded67 100644 --- a/crates/chia-sdk-bindings/bindy/Cargo.toml +++ b/crates/chia-sdk-bindings/bindy/Cargo.toml @@ -18,12 +18,14 @@ workspace = true napi = ["dep:napi", "dep:chia-sdk-client", "dep:chia-ssl"] wasm = ["dep:js-sys"] pyo3 = ["dep:pyo3", "dep:chia-sdk-client", "dep:chia-ssl"] +uniffi = ["dep:uniffi", "dep:chia-sdk-client", "dep:chia-ssl"] [dependencies] thiserror = { workspace = true } napi = { workspace = true, default-features = false, optional = true, features = ["napi6"] } pyo3 = { workspace = true, optional = true } js-sys = { workspace = true, optional = true } +uniffi = { workspace = true, optional = true } chia-protocol = { workspace = true } chia-traits = { workspace = true } bip39 = { workspace = true } @@ -40,3 +42,6 @@ clvm-traits = { workspace = true } clvm-utils = { workspace = true } signature = { workspace = true } num-bigint = { workspace = true } + +[package.metadata.cargo-machete] +ignored = ["uniffi"] diff --git a/crates/chia-sdk-bindings/bindy/src/lib.rs b/crates/chia-sdk-bindings/bindy/src/lib.rs index 8a1c3a05e..43352ba8a 100644 --- a/crates/chia-sdk-bindings/bindy/src/lib.rs +++ b/crates/chia-sdk-bindings/bindy/src/lib.rs @@ -7,6 +7,9 @@ mod wasm_impls; #[cfg(feature = "pyo3")] mod pyo3_impls; +#[cfg(feature = "uniffi")] +mod uniffi_impls; + use clvmr::error::EvalErr; #[cfg(feature = "napi")] pub use napi_impls::*; @@ -17,6 +20,9 @@ pub use wasm_impls::*; #[cfg(feature = "pyo3")] pub use pyo3_impls::*; +#[cfg(feature = "uniffi")] +pub use uniffi_impls::*; + use std::{net::AddrParseError, string::FromUtf8Error}; use chia_protocol::{RejectCoinState, RejectPuzzleSolution, RejectPuzzleState}; @@ -55,9 +61,15 @@ pub enum Error { #[error("Driver error: {0}")] Driver(#[from] DriverError), - #[cfg(any(feature = "napi", feature = "pyo3"))] + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] #[error("Client error: {0}")] - Client(#[from] chia_sdk_client::ClientError), + Client(chia_sdk_client::ClientError), + + /// Connection or request exceeded its configured timeout. The string includes + /// the duration when known. Binding consumers can pattern-match on this variant + /// (or substring-match "Timeout:" in the display) to drive retry logic. + #[error("Timeout: {0}")] + Timeout(String), #[error("Reject coin state: {0:?}")] RejectCoinState(RejectCoinState), @@ -68,7 +80,7 @@ pub enum Error { #[error("Reject puzzle solution: {0:?}")] RejectPuzzleSolution(RejectPuzzleSolution), - #[cfg(any(feature = "napi", feature = "pyo3"))] + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] #[error("SSL error: {0}")] Ssl(#[from] chia_ssl::Error), @@ -130,7 +142,7 @@ pub enum Error { Custom(String), #[error("Reqwest error: {0}")] - Reqwest(#[from] reqwest::Error), + Reqwest(reqwest::Error), #[error("Streamable error: {0}")] Streamable(#[from] chia_traits::Error), @@ -139,6 +151,31 @@ pub enum Error { CoinSelection(#[from] chia_sdk_utils::CoinSelectionError), } +// Manual `From` impls route timeout errors to the structured `Error::Timeout` variant +// so binding consumers can discriminate them from generic Client/Reqwest errors. + +#[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] +impl From for Error { + fn from(value: chia_sdk_client::ClientError) -> Self { + match value { + chia_sdk_client::ClientError::Timeout(duration) => { + Self::Timeout(format!("operation timed out after {duration:?}")) + } + other => Self::Client(other), + } + } +} + +impl From for Error { + fn from(value: reqwest::Error) -> Self { + if value.is_timeout() { + Self::Timeout(value.to_string()) + } else { + Self::Reqwest(value) + } + } +} + pub type Result = std::result::Result; pub trait IntoRust { @@ -167,13 +204,15 @@ macro_rules! impl_self { } impl_self!(bool); -impl_self!(usize); impl_self!(u8); impl_self!(i8); impl_self!(u16); impl_self!(i16); impl_self!(u32); impl_self!(i32); +impl_self!(i64); +impl_self!(i128); +impl_self!(usize); impl_self!(f64); impl_self!(String); diff --git a/crates/chia-sdk-bindings/bindy/src/pyo3_impls.rs b/crates/chia-sdk-bindings/bindy/src/pyo3_impls.rs index f374d7d6a..95f57c1ed 100644 --- a/crates/chia-sdk-bindings/bindy/src/pyo3_impls.rs +++ b/crates/chia-sdk-bindings/bindy/src/pyo3_impls.rs @@ -16,9 +16,7 @@ pub struct Pyo3; pub struct Pyo3Context; impl_self!(u64); -impl_self!(i64); impl_self!(u128); -impl_self!(i128); impl_self!(BigInt); impl FromRust<(), T, Pyo3> for () { diff --git a/crates/chia-sdk-bindings/bindy/src/uniffi_impls.rs b/crates/chia-sdk-bindings/bindy/src/uniffi_impls.rs new file mode 100644 index 000000000..556fc77b7 --- /dev/null +++ b/crates/chia-sdk-bindings/bindy/src/uniffi_impls.rs @@ -0,0 +1,184 @@ +use std::sync::Arc; + +use crate::{Error, FromRust, IntoRust, Result}; +use chia_protocol::{Bytes, BytesImpl, ClassgroupElement, Program}; +use clvm_utils::TreeHash; +use num_bigint::BigInt; + +// Blanket Arc adapter: wraps/unwraps Arc around types that implement FromRust/IntoRust. +// This allows Vec> and Option> to work through the existing blanket impls. +impl FromRust for Arc +where + U: FromRust, +{ + fn from_rust(value: T, context: &C) -> Result { + Ok(Arc::new(U::from_rust(value, context)?)) + } +} + +impl IntoRust for Arc +where + U: Clone + IntoRust, +{ + fn into_rust(self, context: &C) -> Result { + match Arc::try_unwrap(self) { + Ok(inner) => inner.into_rust(context), + Err(arc) => (*arc).clone().into_rust(context), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Uniffi; + +#[derive(Debug, Clone, Copy)] +pub struct UniffiContext; + +// --- Unit type --- + +impl FromRust<(), T, Uniffi> for () { + fn from_rust(_value: (), _context: &T) -> Result { + Ok(()) + } +} + +impl IntoRust<(), T, Uniffi> for () { + fn into_rust(self, _context: &T) -> Result { + Ok(()) + } +} + +// --- BigInt, u64, u128 → String --- +// UniFFI has no native BigInt. We use String; C# parses with BigInteger.Parse(). + +impl FromRust for String { + fn from_rust(value: BigInt, _context: &T) -> Result { + Ok(value.to_string()) + } +} + +impl IntoRust for String { + fn into_rust(self, _context: &T) -> Result { + Ok(self.parse()?) + } +} + +impl FromRust for String { + fn from_rust(value: u64, _context: &T) -> Result { + Ok(value.to_string()) + } +} + +impl IntoRust for String { + fn into_rust(self, _context: &T) -> Result { + self.parse() + .map_err(|_| Error::Custom(format!("cannot parse '{self}' as u64"))) + } +} + +impl FromRust for String { + fn from_rust(value: u128, _context: &T) -> Result { + Ok(value.to_string()) + } +} + +impl IntoRust for String { + fn into_rust(self, _context: &T) -> Result { + self.parse() + .map_err(|_| Error::Custom(format!("cannot parse '{self}' as u128"))) + } +} + +// usize → u64 for UniFFI (UniFFI doesn't support usize natively). +impl FromRust for u64 { + fn from_rust(value: usize, _context: &T) -> Result { + Ok(value as u64) + } +} + +impl IntoRust for u64 { + fn into_rust(self, _context: &T) -> Result { + usize::try_from(self) + .map_err(|_| Error::Custom(format!("u64 {self} does not fit in usize"))) + } +} + +// --- Bytes types → Vec (same pattern as pyo3_impls.rs) --- + +impl FromRust, T, Uniffi> for Vec { + fn from_rust(value: BytesImpl, _context: &T) -> Result { + Ok(value.to_vec()) + } +} + +impl IntoRust, T, Uniffi> for Vec { + fn into_rust(self, _context: &T) -> Result> { + if self.len() != N { + return Err(Error::WrongLength { + expected: N, + found: self.len(), + }); + } + Ok(self.try_into().unwrap()) + } +} + +impl FromRust for Vec { + fn from_rust(value: ClassgroupElement, _context: &T) -> Result { + Ok(value.data.to_vec()) + } +} + +impl IntoRust for Vec { + fn into_rust(self, _context: &T) -> Result { + if self.len() != 100 { + return Err(Error::WrongLength { + expected: 100, + found: self.len(), + }); + } + Ok(ClassgroupElement::new(self.try_into().unwrap())) + } +} + +impl FromRust for Vec { + fn from_rust(value: TreeHash, _context: &T) -> Result { + Ok(value.to_vec()) + } +} + +impl IntoRust for Vec { + fn into_rust(self, _context: &T) -> Result { + if self.len() != 32 { + return Err(Error::WrongLength { + expected: 32, + found: self.len(), + }); + } + Ok(TreeHash::new(self.try_into().unwrap())) + } +} + +impl FromRust for Vec { + fn from_rust(value: Bytes, _context: &T) -> Result { + Ok(value.to_vec()) + } +} + +impl IntoRust for Vec { + fn into_rust(self, _context: &T) -> Result { + Ok(self.into()) + } +} + +impl FromRust for Vec { + fn from_rust(value: Program, _context: &T) -> Result { + Ok(value.to_vec()) + } +} + +impl IntoRust for Vec { + fn into_rust(self, _context: &T) -> Result { + Ok(self.into()) + } +} diff --git a/crates/chia-sdk-bindings/src/lib.rs b/crates/chia-sdk-bindings/src/lib.rs index 8c2bb817b..a3e4b37e0 100644 --- a/crates/chia-sdk-bindings/src/lib.rs +++ b/crates/chia-sdk-bindings/src/lib.rs @@ -26,6 +26,7 @@ mod offer; mod program; mod puzzle; mod rpc; +mod runtime; mod secp; mod simulator; mod utils; @@ -51,10 +52,15 @@ pub use secp::*; pub use simulator::*; pub use utils::*; -#[cfg(any(feature = "napi", feature = "pyo3"))] +// Exposed for the bindy_uniffi_sync! macro (C++ backend), which turns async binding +// methods into blocking calls. Other backends use the native async path. +#[cfg(feature = "uniffi")] +pub use runtime::block_on; + +#[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] mod peer; -#[cfg(any(feature = "napi", feature = "pyo3"))] +#[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] pub use peer::*; pub use chia_bls::{PublicKey, SecretKey, Signature}; @@ -90,7 +96,7 @@ pub use chia_sdk_types::{ }, }; -#[cfg(any(feature = "napi", feature = "pyo3"))] +#[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] pub use chia_protocol::{ CoinStateUpdate, NewPeakWallet, PuzzleSolutionResponse, RespondCoinState, RespondPuzzleState, }; diff --git a/crates/chia-sdk-bindings/src/peer.rs b/crates/chia-sdk-bindings/src/peer.rs index 2618bdddc..d1dd36d50 100644 --- a/crates/chia-sdk-bindings/src/peer.rs +++ b/crates/chia-sdk-bindings/src/peer.rs @@ -13,6 +13,8 @@ use chia_ssl::ChiaCertificate; use chia_traits::Streamable; use tokio::sync::{Mutex, mpsc::Receiver}; +use crate::runtime::ms_to_duration; + #[derive(Clone)] pub struct Certificate { pub cert_pem: String, @@ -50,9 +52,17 @@ impl Connector { } } +/// Opt-in configuration for a [`Peer`]. Timeout values are in milliseconds. +/// +/// `connect_timeout_ms` is a single budget covering the websocket TLS connect plus the +/// chia handshake exchange; `None` leaves it unbounded. `request_timeout_ms` bounds each +/// request/response round-trip on the established connection; `None` leaves requests +/// unbounded. #[derive(Clone)] pub struct PeerOptions { pub rate_limit_factor: f64, + pub connect_timeout_ms: Option, + pub request_timeout_ms: Option, } impl PeerOptions { @@ -60,6 +70,8 @@ impl PeerOptions { let options = SdkPeerOptions::default(); Ok(Self { rate_limit_factor: options.rate_limit_factor, + connect_timeout_ms: None, + request_timeout_ms: None, }) } } @@ -93,15 +105,15 @@ impl Peer { connector: Connector, options: PeerOptions, ) -> Result { - let (peer, receiver) = connect_peer( - network_id, - connector.0.clone(), - socket_addr.parse()?, - SdkPeerOptions { - rate_limit_factor: options.rate_limit_factor, - }, - ) - .await?; + let socket_addr = socket_addr.parse()?; + let sdk_options = SdkPeerOptions { + rate_limit_factor: options.rate_limit_factor, + connect_timeout: ms_to_duration(options.connect_timeout_ms), + request_timeout: ms_to_duration(options.request_timeout_ms), + }; + + let (peer, receiver) = + connect_peer(network_id, connector.0.clone(), socket_addr, sdk_options).await?; Ok(Self(peer, Arc::new(Mutex::new(receiver)))) } diff --git a/crates/chia-sdk-bindings/src/rpc.rs b/crates/chia-sdk-bindings/src/rpc.rs index 561e69fb0..d370e38c6 100644 --- a/crates/chia-sdk-bindings/src/rpc.rs +++ b/crates/chia-sdk-bindings/src/rpc.rs @@ -11,12 +11,14 @@ use chia_sdk_coinset::{ }; use serde::{Serialize, de::DeserializeOwned}; -#[cfg(any(feature = "napi", feature = "pyo3"))] +use crate::runtime::ms_to_duration; + +#[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] use chia_protocol::Bytes; enum RpcClientImpl { Coinset(chia_sdk_coinset::CoinsetClient), - #[cfg(any(feature = "napi", feature = "pyo3"))] + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] FullNode(chia_sdk_coinset::FullNodeClient), } @@ -26,7 +28,7 @@ impl ChiaRpcClient for RpcClientImpl { fn base_url(&self) -> &str { match self { RpcClientImpl::Coinset(client) => client.base_url(), - #[cfg(any(feature = "napi", feature = "pyo3"))] + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] RpcClientImpl::FullNode(client) => client.base_url(), } } @@ -42,76 +44,177 @@ impl ChiaRpcClient for RpcClientImpl { { match self { RpcClientImpl::Coinset(client) => client.make_post_request(endpoint, body).await, - #[cfg(any(feature = "napi", feature = "pyo3"))] + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] RpcClientImpl::FullNode(client) => client.make_post_request(endpoint, body).await, } } } +/// Opt-in timeout configuration for an [`RpcClient`]. All values are in milliseconds. +/// +/// `request_timeout_ms` is the whole-request budget for one HTTP call (connect + send + +/// receive); `None` leaves requests unbounded. `connect_timeout_ms` bounds just the +/// connection phase; `None` falls back to the OS-level TCP connect timeout, not +/// "unbounded." Field naming mirrors [`PeerOptions`] for consistency across binding APIs. +#[derive(Clone, Default)] +pub struct RpcClientOptions { + pub request_timeout_ms: Option, + pub connect_timeout_ms: Option, +} + +impl RpcClientOptions { + fn to_client_options(&self) -> chia_sdk_coinset::ClientOptions { + chia_sdk_coinset::ClientOptions { + timeout: ms_to_duration(self.request_timeout_ms), + connect_timeout: ms_to_duration(self.connect_timeout_ms), + } + } +} + +// Construction params retained on the client so `with_options` can rebuild the +// underlying reqwest Client (cert/key bytes are consumed into `reqwest::Identity` +// and cannot be recovered from a built FullNodeClient). +enum ClientConfig { + Coinset { + base_url: String, + }, + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] + FullNode { + base_url: String, + cert_bytes: Vec, + key_bytes: Vec, + }, +} + +#[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] +const LOCAL_FULL_NODE_URL: &str = "https://localhost:8555"; + #[derive(Clone)] -pub struct RpcClient(Arc); +pub struct RpcClient { + inner: Arc, + config: Arc, +} impl RpcClient { - pub fn new(base_url: String) -> Result { - Ok(Self(Arc::new(RpcClientImpl::Coinset( - chia_sdk_coinset::CoinsetClient::new(base_url), - )))) + pub fn new(coinset_url: String) -> Result { + Self::from_coinset(coinset_url, &RpcClientOptions::default()) } pub fn testnet11() -> Result { - Ok(Self(Arc::new(RpcClientImpl::Coinset( - chia_sdk_coinset::CoinsetClient::testnet11(), - )))) + Self::from_coinset( + "https://testnet11.api.coinset.org".to_string(), + &RpcClientOptions::default(), + ) } pub fn mainnet() -> Result { - Ok(Self(Arc::new(RpcClientImpl::Coinset( - chia_sdk_coinset::CoinsetClient::mainnet(), - )))) + Self::from_coinset( + "https://api.coinset.org".to_string(), + &RpcClientOptions::default(), + ) } - #[cfg(any(feature = "napi", feature = "pyo3"))] + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] pub fn local(cert_bytes: Bytes, key_bytes: Bytes) -> Result { - Ok(Self(Arc::new(RpcClientImpl::FullNode( - chia_sdk_coinset::FullNodeClient::new(&cert_bytes, &key_bytes)?, - )))) + Self::from_full_node( + LOCAL_FULL_NODE_URL.to_string(), + cert_bytes.to_vec(), + key_bytes.to_vec(), + &RpcClientOptions::default(), + ) } - #[cfg(any(feature = "napi", feature = "pyo3"))] + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] pub fn local_with_url(base_url: String, cert_bytes: Bytes, key_bytes: Bytes) -> Result { - Ok(Self(Arc::new(RpcClientImpl::FullNode( - chia_sdk_coinset::FullNodeClient::with_base_url(base_url, &cert_bytes, &key_bytes)?, - )))) + Self::from_full_node( + base_url, + cert_bytes.to_vec(), + key_bytes.to_vec(), + &RpcClientOptions::default(), + ) + } + + /// Returns a new [`RpcClient`] reconfigured with the given options, preserving + /// the original endpoint (and, for local clients, the cert/key). + pub fn with_options(&self, options: RpcClientOptions) -> Result { + match self.config.as_ref() { + ClientConfig::Coinset { base_url } => Self::from_coinset(base_url.clone(), &options), + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] + ClientConfig::FullNode { + base_url, + cert_bytes, + key_bytes, + } => Self::from_full_node( + base_url.clone(), + cert_bytes.clone(), + key_bytes.clone(), + &options, + ), + } + } + + fn from_coinset(base_url: String, options: &RpcClientOptions) -> Result { + let client = chia_sdk_coinset::CoinsetClient::with_options( + base_url.clone(), + options.to_client_options(), + )?; + Ok(Self { + inner: Arc::new(RpcClientImpl::Coinset(client)), + config: Arc::new(ClientConfig::Coinset { base_url }), + }) + } + + #[cfg(any(feature = "napi", feature = "pyo3", feature = "uniffi"))] + fn from_full_node( + base_url: String, + cert_bytes: Vec, + key_bytes: Vec, + options: &RpcClientOptions, + ) -> Result { + let client = chia_sdk_coinset::FullNodeClient::with_options( + base_url.clone(), + &cert_bytes, + &key_bytes, + options.to_client_options(), + )?; + Ok(Self { + inner: Arc::new(RpcClientImpl::FullNode(client)), + config: Arc::new(ClientConfig::FullNode { + base_url, + cert_bytes, + key_bytes, + }), + }) } pub async fn get_blockchain_state(&self) -> Result { - Ok(self.0.get_blockchain_state().await?) + Ok(self.inner.get_blockchain_state().await?) } pub async fn get_additions_and_removals( &self, header_hash: Bytes32, ) -> Result { - Ok(self.0.get_additions_and_removals(header_hash).await?) + Ok(self.inner.get_additions_and_removals(header_hash).await?) } pub async fn get_block(&self, header_hash: Bytes32) -> Result { - Ok(self.0.get_block(header_hash).await?) + Ok(self.inner.get_block(header_hash).await?) } pub async fn get_block_record(&self, header_hash: Bytes32) -> Result { - Ok(self.0.get_block_record(header_hash).await?) + Ok(self.inner.get_block_record(header_hash).await?) } pub async fn get_block_record_by_height( &self, height: u32, ) -> Result { - Ok(self.0.get_block_record_by_height(height).await?) + Ok(self.inner.get_block_record_by_height(height).await?) } pub async fn get_block_records(&self, start: u32, end: u32) -> Result { - Ok(self.0.get_block_records(start, end).await?) + Ok(self.inner.get_block_records(start, end).await?) } pub async fn get_blocks( @@ -122,17 +225,17 @@ impl RpcClient { exclude_reorged: bool, ) -> Result { Ok(self - .0 + .inner .get_blocks(start, end, exclude_header_hash, exclude_reorged) .await?) } pub async fn get_block_spends(&self, header_hash: Bytes32) -> Result { - Ok(self.0.get_block_spends(header_hash).await?) + Ok(self.inner.get_block_spends(header_hash).await?) } pub async fn get_coin_record_by_name(&self, name: Bytes32) -> Result { - Ok(self.0.get_coin_record_by_name(name).await?) + Ok(self.inner.get_coin_record_by_name(name).await?) } pub async fn get_coin_records_by_hint( @@ -144,7 +247,7 @@ impl RpcClient { cursor: Option, ) -> Result { Ok(self - .0 + .inner .get_coin_records_by_hint(hint, start_height, end_height, include_spent_coins, cursor) .await?) } @@ -158,7 +261,7 @@ impl RpcClient { cursor: Option, ) -> Result { Ok(self - .0 + .inner .get_coin_records_by_hints(hints, start_height, end_height, include_spent_coins, cursor) .await?) } @@ -172,7 +275,7 @@ impl RpcClient { cursor: Option, ) -> Result { Ok(self - .0 + .inner .get_coin_records_by_names(names, start_height, end_height, include_spent_coins, cursor) .await?) } @@ -186,7 +289,7 @@ impl RpcClient { cursor: Option, ) -> Result { Ok(self - .0 + .inner .get_coin_records_by_parent_ids( parent_ids, start_height, @@ -206,7 +309,7 @@ impl RpcClient { cursor: Option, ) -> Result { Ok(self - .0 + .inner .get_coin_records_by_puzzle_hash( puzzle_hash, start_height, @@ -226,7 +329,7 @@ impl RpcClient { cursor: Option, ) -> Result { Ok(self - .0 + .inner .get_coin_records_by_puzzle_hashes( puzzle_hashes, start_height, @@ -242,28 +345,28 @@ impl RpcClient { coin_id: Bytes32, height: Option, ) -> Result { - Ok(self.0.get_puzzle_and_solution(coin_id, height).await?) + Ok(self.inner.get_puzzle_and_solution(coin_id, height).await?) } pub async fn push_tx(&self, spend_bundle: SpendBundle) -> Result { - Ok(self.0.push_tx(spend_bundle).await?) + Ok(self.inner.push_tx(spend_bundle).await?) } pub async fn get_network_info(&self) -> Result { - Ok(self.0.get_network_info().await?) + Ok(self.inner.get_network_info().await?) } pub async fn get_mempool_item_by_tx_id( &self, tx_id: Bytes32, ) -> Result { - Ok(self.0.get_mempool_item_by_tx_id(tx_id).await?) + Ok(self.inner.get_mempool_item_by_tx_id(tx_id).await?) } pub async fn get_mempool_items_by_coin_name( &self, coin_name: Bytes32, ) -> Result { - Ok(self.0.get_mempool_items_by_coin_name(coin_name).await?) + Ok(self.inner.get_mempool_items_by_coin_name(coin_name).await?) } } diff --git a/crates/chia-sdk-bindings/src/runtime.rs b/crates/chia-sdk-bindings/src/runtime.rs new file mode 100644 index 000000000..56a3c7169 --- /dev/null +++ b/crates/chia-sdk-bindings/src/runtime.rs @@ -0,0 +1,34 @@ +use std::time::Duration; + +#[cfg(feature = "uniffi")] +use bindy::Result; + +/// Converts a binding-facing `Option` milliseconds value into a `Duration`. +/// Used to bridge the bindings (which expose `u32` ms for FFI portability) to the +/// SDK layer (which takes `Duration`). +pub(crate) fn ms_to_duration(ms: Option) -> Option { + ms.map(|ms| Duration::from_millis(u64::from(ms))) +} + +// Tokio runtime for the C++ sync backend's `block_on` +#[cfg(feature = "uniffi")] +static TOKIO_RUNTIME: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }); + +/// Drives an async binding method to completion synchronously on a shared Tokio runtime. +/// +/// Used by the `bindy_uniffi_sync!` macro (the C++ backend), since `uniffi-bindgen-cpp` +/// cannot generate async functions. Must be called from a foreign (non-async) thread. +/// +/// There is intentionally no timeout here: per-operation timeouts are configured on the +/// clients themselves (`RpcClientOptions`, `PeerOptions`) so they apply uniformly across +/// every binding backend, not just this synchronous one. +#[cfg(feature = "uniffi")] +pub fn block_on(future: F) -> Result +where + F: std::future::Future>, +{ + TOKIO_RUNTIME.block_on(future) +} diff --git a/crates/chia-sdk-client/Cargo.toml b/crates/chia-sdk-client/Cargo.toml index e2511e233..5470edc61 100644 --- a/crates/chia-sdk-client/Cargo.toml +++ b/crates/chia-sdk-client/Cargo.toml @@ -37,5 +37,8 @@ tokio-tungstenite = { workspace = true } # https://aws.github.io/aws-lc-rs/platform_support.html#tested-platforms aws-lc-rs = { version = "1", features = ["bindgen"], optional = true } +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread", "time"] } + [package.metadata.cargo-machete] ignored = ["aws-lc-rs"] diff --git a/crates/chia-sdk-client/src/connect.rs b/crates/chia-sdk-client/src/connect.rs index dbb70ae9f..c24f6b146 100644 --- a/crates/chia-sdk-client/src/connect.rs +++ b/crates/chia-sdk-client/src/connect.rs @@ -15,48 +15,62 @@ pub async fn connect_peer( socket_addr: SocketAddr, options: PeerOptions, ) -> Result<(Peer, mpsc::Receiver), ClientError> { - let (peer, mut receiver) = Peer::connect(socket_addr, connector, options).await?; - - peer.send(Handshake { - network_id: network_id.clone(), - protocol_version: "0.0.37".to_string(), - software_version: "0.0.0".to_string(), - server_port: 0, - node_type: NodeType::Wallet, - capabilities: vec![ - (1, "1".to_string()), - (2, "1".to_string()), - (3, "1".to_string()), - ], - }) - .await?; - - let Some(message) = receiver.recv().await else { - return Err(ClientError::MissingHandshake); - }; + let connect_timeout = options.connect_timeout; - if message.msg_type != ProtocolMessageTypes::Handshake { - return Err(ClientError::InvalidResponse( - vec![ProtocolMessageTypes::Handshake], - message.msg_type, - )); - } + // `connect_timeout` is a single budget covering both the websocket connect and the + // chia handshake exchange. The inner `Peer::connect` also honors it for direct + // (non-handshake) callers; in this path it is bounded by the outer timeout below. + let inner = async move { + let (peer, mut receiver) = Peer::connect(socket_addr, connector, options).await?; - let handshake = Handshake::from_bytes(&message.data)?; + peer.send(Handshake { + network_id: network_id.clone(), + protocol_version: "0.0.37".to_string(), + software_version: "0.0.0".to_string(), + server_port: 0, + node_type: NodeType::Wallet, + capabilities: vec![ + (1, "1".to_string()), + (2, "1".to_string()), + (3, "1".to_string()), + ], + }) + .await?; - if handshake.node_type != NodeType::FullNode { - return Err(ClientError::WrongNodeType( - NodeType::FullNode, - handshake.node_type, - )); - } + let Some(message) = receiver.recv().await else { + return Err(ClientError::MissingHandshake); + }; - if handshake.network_id != network_id { - return Err(ClientError::WrongNetwork( - network_id.clone(), - handshake.network_id, - )); - } + if message.msg_type != ProtocolMessageTypes::Handshake { + return Err(ClientError::InvalidResponse( + vec![ProtocolMessageTypes::Handshake], + message.msg_type, + )); + } + + let handshake = Handshake::from_bytes(&message.data)?; - Ok((peer, receiver)) + if handshake.node_type != NodeType::FullNode { + return Err(ClientError::WrongNodeType( + NodeType::FullNode, + handshake.node_type, + )); + } + + if handshake.network_id != network_id { + return Err(ClientError::WrongNetwork( + network_id.clone(), + handshake.network_id, + )); + } + + Ok((peer, receiver)) + }; + + match connect_timeout { + Some(duration) => tokio::time::timeout(duration, inner) + .await + .map_err(|_| ClientError::Timeout(duration))?, + None => inner.await, + } } diff --git a/crates/chia-sdk-client/src/error.rs b/crates/chia-sdk-client/src/error.rs index a778c4df4..4104912c5 100644 --- a/crates/chia-sdk-client/src/error.rs +++ b/crates/chia-sdk-client/src/error.rs @@ -55,4 +55,7 @@ pub enum ClientError { #[error("The peer is banned")] BannedPeer, + + #[error("Operation timed out after {0:?}")] + Timeout(std::time::Duration), } diff --git a/crates/chia-sdk-client/src/peer.rs b/crates/chia-sdk-client/src/peer.rs index 108553f20..f2828be1d 100644 --- a/crates/chia-sdk-client/src/peer.rs +++ b/crates/chia-sdk-client/src/peer.rs @@ -36,12 +36,22 @@ type Response = std::result::Result; #[derive(Debug, Clone, Copy)] pub struct PeerOptions { pub rate_limit_factor: f64, + /// Total wall-clock budget for establishing a connection. In [`connect_peer`](crate::connect_peer) + /// this bounds the combined websocket TLS connect plus the chia handshake exchange; + /// in [`Peer::connect_full_uri`] (used directly without the handshake wrapper) it bounds + /// just the websocket connect. `None` (the default) leaves it unbounded. + pub connect_timeout: Option, + /// Timeout for each request/response round-trip. `None` (the default) leaves + /// requests unbounded. + pub request_timeout: Option, } impl Default for PeerOptions { fn default() -> Self { Self { rate_limit_factor: 0.6, + connect_timeout: None, + request_timeout: None, } } } @@ -56,6 +66,7 @@ struct PeerInner { requests: Arc, socket_addr: SocketAddr, outbound_rate_limiter: Mutex, + request_timeout: Option, } impl Peer { @@ -77,9 +88,16 @@ impl Peer { connector: Connector, options: PeerOptions, ) -> Result<(Self, mpsc::Receiver), ClientError> { - let (ws, _) = - tokio_tungstenite::connect_async_tls_with_config(uri, None, false, Some(connector)) - .await?; + let connect = + tokio_tungstenite::connect_async_tls_with_config(uri, None, false, Some(connector)); + + let (ws, _) = match options.connect_timeout { + Some(duration) => tokio::time::timeout(duration, connect) + .await + .map_err(|_| ClientError::Timeout(duration))??, + None => connect.await?, + }; + Self::from_websocket(ws, options) } @@ -128,6 +146,7 @@ impl Peer { options.rate_limit_factor, V2_RATE_LIMITS.clone(), )), + request_timeout: options.request_timeout, })); Ok((peer, receiver)) @@ -305,14 +324,30 @@ impl Peer { { let (sender, receiver) = oneshot::channel(); + let id = self.0.requests.insert(sender).await; + self.send_raw(Message { msg_type: T::msg_type(), - id: Some(self.0.requests.insert(sender).await), + id: Some(id), data: body.to_bytes()?.into(), }) .await?; - Ok(receiver.await?) + match self.0.request_timeout { + // On timeout the request id must be removed from the map. Otherwise it stays + // (its sender is only purged lazily on the next `insert`) and can be reused by a + // later request before the peer's delayed response arrives — at which point that + // stale response would be delivered to the unrelated newer request. + Some(duration) => { + if let Ok(result) = tokio::time::timeout(duration, receiver).await { + Ok(result?) + } else { + self.0.requests.remove(id).await; + Err(ClientError::Timeout(duration)) + } + } + None => Ok(receiver.await?), + } } async fn send_raw(&self, message: Message) -> Result<(), ClientError> { @@ -390,3 +425,48 @@ async fn handle_inbound_messages( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + use tokio::net::TcpListener; + use tokio_tungstenite::{accept_async, connect_async}; + + /// A request that times out must be removed from the `RequestMap`. Otherwise its id + /// stays allocated (purged only lazily on the next `insert`) and can be reused by a + /// later request before the peer's delayed response arrives — which would route that + /// stale response to the unrelated newer request. + #[tokio::test] + async fn request_timeout_frees_request_id() { + // Server that completes the websocket handshake but never sends a response. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let _ws = accept_async(stream).await.unwrap(); + // Hold the connection open without ever responding. + std::future::pending::<()>().await; + }); + + let (ws, _) = connect_async(format!("ws://{addr}")).await.unwrap(); + let options = PeerOptions { + request_timeout: Some(Duration::from_millis(100)), + ..Default::default() + }; + let (peer, _receiver) = Peer::from_websocket(ws, options).unwrap(); + + // Issue a request the server will never answer; it must time out. + let result = peer.request_children(Bytes32::default()).await; + assert!( + matches!(result, Err(ClientError::Timeout(_))), + "expected timeout, got {result:?}" + ); + + // The timed-out request must have been removed from the map, so the id is free + // again rather than lingering until the next `insert`. + assert_eq!(peer.0.requests.len().await, 0); + + server.abort(); + } +} diff --git a/crates/chia-sdk-client/src/request_map.rs b/crates/chia-sdk-client/src/request_map.rs index 2ed78f92c..010001d0e 100644 --- a/crates/chia-sdk-client/src/request_map.rs +++ b/crates/chia-sdk-client/src/request_map.rs @@ -58,4 +58,9 @@ impl RequestMap { pub(crate) async fn remove(&self, id: u16) -> Option { self.items.lock().await.remove(&id) } + + #[cfg(test)] + pub(crate) async fn len(&self) -> usize { + self.items.lock().await.len() + } } diff --git a/crates/chia-sdk-coinset/src/client_options.rs b/crates/chia-sdk-coinset/src/client_options.rs new file mode 100644 index 000000000..af0b630f8 --- /dev/null +++ b/crates/chia-sdk-coinset/src/client_options.rs @@ -0,0 +1,40 @@ +use std::time::Duration; + +/// Opt-in timeout configuration for the HTTP clients. +/// +/// Both fields default to `None`, which preserves reqwest's default behavior of +/// no request timeout (the only implicit bound is the OS-level TCP connect timeout). +#[derive(Debug, Clone, Copy, Default)] +pub struct ClientOptions { + /// Whole-request timeout, covering connect, send, and receiving the response. + /// `None` leaves the request unbounded. + pub timeout: Option, + /// Connection-phase timeout only. `None` falls back to the OS default. + pub connect_timeout: Option, +} + +impl ClientOptions { + /// Applies the configured timeouts to a reqwest [`ClientBuilder`](reqwest::ClientBuilder). + /// + /// On `wasm32` the reqwest fetch backend does not support builder-level timeouts + /// (the browser controls them), so this is a no-op there. + pub(crate) fn apply(self, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { + #[cfg(not(target_arch = "wasm32"))] + { + let mut builder = builder; + if let Some(timeout) = self.timeout { + builder = builder.timeout(timeout); + } + if let Some(connect_timeout) = self.connect_timeout { + builder = builder.connect_timeout(connect_timeout); + } + builder + } + + #[cfg(target_arch = "wasm32")] + { + let _ = (self.timeout, self.connect_timeout); + builder + } + } +} diff --git a/crates/chia-sdk-coinset/src/coinset_client.rs b/crates/chia-sdk-coinset/src/coinset_client.rs index b70979da7..04b8ab21c 100644 --- a/crates/chia-sdk-coinset/src/coinset_client.rs +++ b/crates/chia-sdk-coinset/src/coinset_client.rs @@ -1,7 +1,7 @@ use reqwest::Client; use serde::{Serialize, de::DeserializeOwned}; -use crate::ChiaRpcClient; +use crate::{ChiaRpcClient, ClientOptions}; #[derive(Debug, Clone)] pub struct CoinsetClient { @@ -9,6 +9,9 @@ pub struct CoinsetClient { client: Client, } +const TESTNET11_URL: &str = "https://testnet11.api.coinset.org"; +const MAINNET_URL: &str = "https://api.coinset.org"; + impl CoinsetClient { pub fn new(base_url: String) -> Self { Self { @@ -17,12 +20,20 @@ impl CoinsetClient { } } + /// Creates a client with opt-in [`ClientOptions`] (e.g. request timeouts). + pub fn with_options(base_url: String, options: ClientOptions) -> reqwest::Result { + Ok(Self { + base_url, + client: options.apply(Client::builder()).build()?, + }) + } + pub fn testnet11() -> Self { - Self::new("https://testnet11.api.coinset.org".to_string()) + Self::new(TESTNET11_URL.to_string()) } pub fn mainnet() -> Self { - Self::new("https://api.coinset.org".to_string()) + Self::new(MAINNET_URL.to_string()) } } diff --git a/crates/chia-sdk-coinset/src/full_node_client.rs b/crates/chia-sdk-coinset/src/full_node_client.rs index 74f1c80c6..e315a43ae 100644 --- a/crates/chia-sdk-coinset/src/full_node_client.rs +++ b/crates/chia-sdk-coinset/src/full_node_client.rs @@ -1,7 +1,7 @@ use reqwest::{Client, Identity}; use serde::{Serialize, de::DeserializeOwned}; -use crate::ChiaRpcClient; +use crate::{ChiaRpcClient, ClientOptions}; #[derive(Debug)] pub struct FullNodeClient { @@ -18,6 +18,16 @@ impl FullNodeClient { base_url: String, cert_bytes: &[u8], key_bytes: &[u8], + ) -> reqwest::Result { + Self::with_options(base_url, cert_bytes, key_bytes, ClientOptions::default()) + } + + /// Creates a client with opt-in [`ClientOptions`] (e.g. request timeouts). + pub fn with_options( + base_url: String, + cert_bytes: &[u8], + key_bytes: &[u8], + options: ClientOptions, ) -> reqwest::Result { #[cfg(feature = "native-tls")] let identity = Identity::from_pkcs8_pem(cert_bytes, key_bytes)?; @@ -27,9 +37,12 @@ impl FullNodeClient { Ok(Self { base_url, - client: Client::builder() - .danger_accept_invalid_certs(true) - .identity(identity) + client: options + .apply( + Client::builder() + .danger_accept_invalid_certs(true) + .identity(identity), + ) .build()?, }) } diff --git a/crates/chia-sdk-coinset/src/lib.rs b/crates/chia-sdk-coinset/src/lib.rs index 98915c652..f98400b2d 100644 --- a/crates/chia-sdk-coinset/src/lib.rs +++ b/crates/chia-sdk-coinset/src/lib.rs @@ -1,10 +1,12 @@ mod chia_rpc_client; +mod client_options; mod coinset_client; mod mock_client; mod models; mod types; pub use chia_rpc_client::*; +pub use client_options::*; pub use coinset_client::*; pub use mock_client::*; pub use models::*; diff --git a/crates/chia-sdk-test/src/peer_simulator.rs b/crates/chia-sdk-test/src/peer_simulator.rs index d1253f7ef..93dd78a2f 100644 --- a/crates/chia-sdk-test/src/peer_simulator.rs +++ b/crates/chia-sdk-test/src/peer_simulator.rs @@ -104,6 +104,7 @@ impl PeerSimulator { ws, PeerOptions { rate_limit_factor: 0.6, + ..Default::default() }, )?) } diff --git a/dotnet/Cargo.toml b/dotnet/Cargo.toml new file mode 100644 index 000000000..da0f643e4 --- /dev/null +++ b/dotnet/Cargo.toml @@ -0,0 +1,39 @@ +[package] +publish = false +name = "chia-wallet-sdk-cs" +version = "0.33.0" +edition = "2024" + +[lib] +name = "chia_wallet_sdk" +crate-type = ["cdylib"] +doc = false +test = false + +[dependencies] +uniffi = { workspace = true } +chia-sdk-bindings = { workspace = true, features = ["uniffi"] } +bindy = { workspace = true, features = ["uniffi"] } +bindy-macro = { workspace = true } + +[target.aarch64-unknown-linux-gnu.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.aarch64-unknown-linux-musl.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.x86_64-unknown-linux-gnu.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.x86_64-unknown-linux-musl.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[package.metadata.cargo-machete] +ignored = ["bindy", "chia-sdk-bindings"] + +[lints] +workspace = true diff --git a/dotnet/README.md b/dotnet/README.md new file mode 100644 index 000000000..8affbdb5c --- /dev/null +++ b/dotnet/README.md @@ -0,0 +1,271 @@ +# chia-wallet-sdk C# Bindings + +C# (and .NET) bindings for the [Chia Wallet SDK](https://github.com/xch-dev/chia-wallet-sdk), generated via [UniFFI](https://mozilla.github.io/uniffi-rs/) and [uniffi-bindgen-cs](https://github.com/NordSecurity/uniffi-bindgen-cs). + +The Rust library exposes the full SDK surface — BLS keys, addresses, CLVM, coins, conditions, offers, puzzles, RPC, and more — as a native shared library that C# loads via P/Invoke through the UniFFI scaffolding layer. + +--- + +## Prerequisites + +- Rust toolchain (1.81+) — [rustup.rs](https://rustup.rs) +- .NET 8+ SDK — [dot.net](https://dotnet.microsoft.com) +- `uniffi-bindgen-cs` — installed once per machine (see below) + +--- + +## Step 1: Build the Native Library + +```bash +# From the repo root +cargo build --profile release-cs -p chia-wallet-sdk-cs --target aarch64-apple-darwin +``` + +Output location by platform (substitute your target triple for ``): + +| Platform | File | +|----------|------| +| macOS | `target//release-cs/libchia_wallet_sdk.dylib` | +| Linux | `target//release-cs/libchia_wallet_sdk.so` | +| Windows | `target//release-cs/chia_wallet_sdk.dll` | + +The `release-cs` profile inherits from `release` but sets `strip = "none"`, which is required so that `uniffi-bindgen-cs` can read the `UNIFFI_META_*` symbols that the standard `--release` profile strips. + +--- + +## Step 2: Install `uniffi-bindgen-cs` + +This tool generates the C# source file from the compiled library. Install it once; the version **must match** the `uniffi` crate version used here (`0.29`). + +```bash +cargo install uniffi-bindgen-cs \ + --git https://github.com/NordSecurity/uniffi-bindgen-cs \ + --tag v0.10.0+v0.29.4 +``` + +--- + +## Step 3: Generate the C# Source + +```bash +# macOS (substitute your target triple, e.g. aarch64-apple-darwin or x86_64-apple-darwin) +uniffi-bindgen-cs \ + --library \ + --out-dir dotnet/cs \ + --config dotnet/uniffi.toml \ + target/aarch64-apple-darwin/release-cs/libchia_wallet_sdk.dylib + +# Linux +uniffi-bindgen-cs \ + --library \ + --out-dir dotnet/cs \ + --config dotnet/uniffi.toml \ + target/x86_64-unknown-linux-gnu/release-cs/libchia_wallet_sdk.so + +# Windows +uniffi-bindgen-cs \ + --library \ + --out-dir dotnet/cs \ + --config dotnet/uniffi.toml \ + target/x86_64-pc-windows-msvc/release-cs/chia_wallet_sdk.dll +``` + +This produces `dotnet/cs/chia_wallet_sdk.cs` — a single self-contained C# file that includes all types, P/Invoke declarations, and marshalling logic. + +Re-run this step any time the Rust library is rebuilt after API changes. + +--- + +## Step 4: Use in a .NET Project + +### Option A — Via NuGet (recommended) + +```bash +dotnet add package ChiaWalletSdk +``` + +The package bundles the native library for each supported platform; no manual copy step is needed. + +### Option B — In-repo via ProjectReference + +If you are working inside a clone of this repository, reference the library project directly: + +```xml + + + + +``` + +Ensure the native library is in the output directory (set `Copy to Output Directory` in your `.csproj` or add it to the build pipeline). + +Everything lives in the `ChiaWalletSdk` namespace. + +--- + +## Quick Start + +```csharp +using ChiaWalletSdk; +using System.Numerics; + +// Generate a BLS key from a seed phrase +var mnemonic = Mnemonic.Generate(); +var seed = mnemonic.ToSeed(""); +var secretKey = SecretKey.FromSeed(seed); +var publicKey = secretKey.PublicKey(); + +// Encode a puzzle hash as a Chia address +var puzzleHash = /* 32-byte array */ new byte[32]; +var address = new Address(puzzleHash, "xch"); +Console.WriteLine(address.Encode()); // "xch1..." + +// Decode an address back to a puzzle hash +var decoded = Address.Decode("xch1..."); +byte[] ph = decoded.GetPuzzleHash(); + +// Work with amounts — bigint values come back as strings +var clvm = new Clvm(); +// amounts like coin.amount are strings; parse with BigInteger +BigInteger amount = BigInteger.Parse(someAmountString); +``` + +--- + +## Running the Tests + +An xUnit test suite in `dotnet/tests/` exercises the core binding surface — CLVM, keys, coins, addresses, and conditions. + +### Prerequisites + +Build the native library first (required before `dotnet test` can load it): + +```bash +# From the repo root +cargo build --profile release-cs -p chia-wallet-sdk-cs --target aarch64-apple-darwin +``` + +### Run + +```bash +cd dotnet/tests +dotnet test +``` + +All 13 tests should pass. + +--- + +## API Surface + +The bindings cover the full SDK: + +| Module | Classes / Functions | +|--------|-------------------| +| BLS keys | `SecretKey`, `PublicKey`, `Signature`, `AggregateSignature` | +| Secp keys | `K1SecretKey`, `K1PublicKey`, `K1Signature`, `R1SecretKey`, `R1PublicKey`, `R1Signature` | +| Address | `Address` | +| Mnemonic | `Mnemonic` | +| Coin | `Coin`, `CoinSpend`, `CoinState` | +| CLVM | `Clvm`, `Program`, `CurriedProgram`, `Spend` | +| Conditions | `CreateCoin`, `AggSigMe`, `ReserveFee`, … (47 condition types) | +| Puzzles | `StandardPuzzle`, `CatPuzzle`, `NftPuzzle`, `DlPuzzle`, `SingletonPuzzle`, … | +| Offers | `Offer`, `ParsedOffer` | +| RPC | `RpcClient` (async) | +| Simulator | `Simulator` (async, testing) | +| Utils | `sha256`, `hash_to_g2`, … | +| Constants | `Constants` (puzzle hashes for all built-in puzzles) | + +--- + +## Type Mapping Reference + +| Rust type | C# type | Notes | +|-----------|---------|-------| +| `Vec` / bytes types | `byte[]` | Puzzle hashes, keys, signatures | +| `u64`, `u128`, `BigInt` | `string` | Parse with `BigInteger.Parse()` | +| `u8`–`u32`, `i8`–`i32` | native int types | Passed through directly | +| `usize` | `uint` | Mapped to `u32` | +| `f64` | `double` | | +| `bool` | `bool` | | +| `String` | `string` | | +| `Option` | `T?` (nullable) | | +| `Vec` | `List` | | +| Rust struct / class | C# class | Reference-counted via `Arc` | +| Rust enum | C# enum | | + +### BigInt / amounts + +Chia amounts, heights, and arbitrary-precision integers are all passed as decimal strings. On the C# side: + +```csharp +// Rust returns u64/u128/BigInt → string +string amountStr = coin.GetAmount(); +BigInteger amount = BigInteger.Parse(amountStr); + +// Passing back in +string newAmount = (amount + 1).ToString(); +``` + +--- + +## Async Methods + +Async methods (RPC calls, simulator) return `Task` in C#. They run on the Rust tokio runtime bridged through UniFFI: + +```csharp +var client = await RpcClient.New("https://api.coinset.org"); +var state = await client.GetBlockchainState(); +``` + +--- + +## Architecture + +``` +bindings/*.json ← single source of truth for the API surface + ↓ +bindy_uniffi! macro ← generates #[derive(uniffi::Object)] / #[uniffi::export] + ↓ +dotnet/ crate ← cdylib: setup_scaffolding! + generated + hand-written alloc + ↓ cargo build +libchia_wallet_sdk.dylib ← native shared library + ↓ uniffi-bindgen-cs +chia_wallet_sdk.cs ← C# source with all types and P/Invoke bindings +``` + +The same `bindings/*.json` schemas also drive the Go (`go/`), Node.js (`napi/`), WebAssembly (`wasm/`), and Python (`pyo3/`) backends. Adding a method to a JSON schema automatically adds it to all backends. + +--- + +## Known Limitations + +| Limitation | Details | +|------------|---------| +| BigInt as string | `u64`/`u128`/`BigInt` map to `string`; parse with `BigInteger.Parse()`. A typed UniFFI custom type could improve this in a future version. | +| `Clvm.Alloc()` is typed | Unlike Python, which accepts `None`/`int`/`bool`/`str`/`bytes`/`list` dynamically, the C# `Alloc` takes a `ClvmType` enum. Use `Clvm.Nil()`, `Clvm.Int()`, `Clvm.Atom()` etc. for primitive values. | +| Field setters are immutable | `SetField(value)` returns a new object with the field changed; the original is unchanged. Use the return value. | +| Version pinning | `uniffi-bindgen-cs` must match the `uniffi` crate version (`0.29`). Check the tag when upgrading. | +| No static methods on objects | UniFFI 0.29 does not support non-`self` associated functions in `impl` blocks. These are exposed as free functions prefixed with the class name (e.g. `constants_puzzle_name()`). | + +--- + +## Rebuilding After SDK Updates + +When the SDK version bumps or new API is added: + +```bash +# 1. Rebuild the native library +cargo build --profile release-cs -p chia-wallet-sdk-cs --target aarch64-apple-darwin + +# 2. Regenerate C# source +uniffi-bindgen-cs \ + --library \ + --out-dir dotnet/cs \ + --config dotnet/uniffi.toml \ + target/aarch64-apple-darwin/release-cs/libchia_wallet_sdk.dylib + +# 3. Replace the .cs file in your project +``` + +No manual patches needed — the `PatchClvmStringAmbiguity` MSBuild target in `ChiaWalletSdk.csproj` automatically fixes a name-shadowing bug in the generated code on every compile. (Root cause: `Clvm.String(string)` shadows `System.String` inside the class body, causing the generated lifecycle guards to fail. Tracked upstream at [NordSecurity/uniffi-bindgen-cs](https://github.com/NordSecurity/uniffi-bindgen-cs).) diff --git a/dotnet/build.rs b/dotnet/build.rs new file mode 100644 index 000000000..e72dbff1c --- /dev/null +++ b/dotnet/build.rs @@ -0,0 +1,4 @@ +fn main() { + println!("cargo::rerun-if-changed=../bindings"); + println!("cargo::rerun-if-changed=../bindings.json"); +} diff --git a/dotnet/chia-wallet-sdk.sln b/dotnet/chia-wallet-sdk.sln new file mode 100644 index 000000000..4d90e1954 --- /dev/null +++ b/dotnet/chia-wallet-sdk.sln @@ -0,0 +1,32 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChiaWalletSdk", "cs\ChiaWalletSdk.csproj", "{C41A9FCF-60EF-705D-F213-E8FC5BDF7A42}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ChiaWalletSdkTests", "tests\ChiaWalletSdkTests.csproj", "{338878C0-E927-E5BA-61D6-8125848A1B21}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C41A9FCF-60EF-705D-F213-E8FC5BDF7A42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C41A9FCF-60EF-705D-F213-E8FC5BDF7A42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C41A9FCF-60EF-705D-F213-E8FC5BDF7A42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C41A9FCF-60EF-705D-F213-E8FC5BDF7A42}.Release|Any CPU.Build.0 = Release|Any CPU + {338878C0-E927-E5BA-61D6-8125848A1B21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {338878C0-E927-E5BA-61D6-8125848A1B21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {338878C0-E927-E5BA-61D6-8125848A1B21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {338878C0-E927-E5BA-61D6-8125848A1B21}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {241A5A30-0330-4A8B-A036-5E520709F419} + EndGlobalSection +EndGlobal diff --git a/dotnet/cs/.gitignore b/dotnet/cs/.gitignore new file mode 100644 index 000000000..5ac89e9b9 --- /dev/null +++ b/dotnet/cs/.gitignore @@ -0,0 +1,17 @@ +# Generated by uniffi-bindgen-cs — run local-build.sh or the nuget CI workflow to regenerate +chia_wallet_sdk.cs + +# Native libraries staged by local-build.sh / CI — not source files +runtimes/ + +# .NET build output +bin/ +obj/ + +# NuGet packages (restored by dotnet restore) +*.user +*.suo +.vs/ + +# Test results +TestResults/ diff --git a/dotnet/cs/ChiaWalletSdk.csproj b/dotnet/cs/ChiaWalletSdk.csproj new file mode 100644 index 000000000..0fcf98491 --- /dev/null +++ b/dotnet/cs/ChiaWalletSdk.csproj @@ -0,0 +1,211 @@ + + + + net8.0 + enable + true + + + ChiaWalletSdk + 0.33.0 + dkackman + C# bindings for the Chia Wallet SDK — a Rust library for building Chia blockchain wallets. + Apache-2.0 + https://github.com/dkackman/chia-wallet-sdk + https://github.com/dkackman/chia-wallet-sdk + git + chia;blockchain;wallet;sdk + PACKAGE_README.md + false + + + + + + + <_Parameter1>ChiaWalletSdkTests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_BindingsFile>$(MSBuildThisFileDirectory)chia_wallet_sdk.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_BindingsFile>$(MSBuildThisFileDirectory)chia_wallet_sdk.cs + + + + + + + + + + + diff --git a/dotnet/cs/PACKAGE_README.md b/dotnet/cs/PACKAGE_README.md new file mode 100644 index 000000000..14206fdcf --- /dev/null +++ b/dotnet/cs/PACKAGE_README.md @@ -0,0 +1,80 @@ +# ChiaWalletSdk + +C# bindings for the [Chia Wallet SDK](https://github.com/xch-dev/chia-wallet-sdk) -- a Rust library for building Chia blockchain wallets, exposed to .NET via [UniFFI](https://mozilla.github.io/uniffi-rs/). + +Native libraries for macOS (arm64, x64), Windows (x64), and Linux (x64) are bundled in the package. + +## Quick Start + +```csharp +using ChiaWalletSdk; +using System.Numerics; + +// Generate a BLS key from a seed phrase +var mnemonic = Mnemonic.Generate(); +var seed = mnemonic.ToSeed(""); +var secretKey = SecretKey.FromSeed(seed); +var publicKey = secretKey.PublicKey(); + +// Encode a puzzle hash as a Chia address +var puzzleHash = new byte[32]; +var address = new Address(puzzleHash, "xch"); +Console.WriteLine(address.Encode()); // "xch1..." + +// Async RPC +var client = await RpcClient.New("https://api.coinset.org"); +var state = await client.GetBlockchainState(); +``` + +## API Surface + +| Module | Classes / Functions | +|--------|-------------------| +| BLS keys | `SecretKey`, `PublicKey`, `Signature`, `AggregateSignature` | +| Secp keys | `K1SecretKey`, `K1PublicKey`, `K1Signature`, `R1SecretKey`, `R1PublicKey`, `R1Signature` | +| Address | `Address` | +| Mnemonic | `Mnemonic` | +| Coin | `Coin`, `CoinSpend`, `CoinState` | +| CLVM | `Clvm`, `Program`, `CurriedProgram`, `Spend` | +| Conditions | `CreateCoin`, `AggSigMe`, `ReserveFee`, and more | +| Puzzles | `StandardPuzzle`, `CatPuzzle`, `NftPuzzle`, `DlPuzzle`, `SingletonPuzzle`, etc. | +| Offers | `Offer`, `ParsedOffer` | +| RPC | `RpcClient` (async) | +| Simulator | `Simulator` (async, testing) | +| Utils | `sha256`, `hash_to_g2`, etc. | +| Constants | `Constants` (puzzle hashes for all built-in puzzles) | + +## Type Mapping + +| Rust type | C# type | Notes | +|-----------|---------|-------| +| `Vec` / bytes | `byte[]` | Puzzle hashes, keys, signatures | +| `u64`, `u128`, `BigInt` | `string` | Parse with `BigInteger.Parse()` | +| `u8`--`u32`, `i8`--`i32` | native int types | Passed through directly | +| `Option` | `T?` (nullable) | | +| `Vec` | `List` | | +| Rust struct / class | C# class | Reference-counted via `Arc` | + +### Amounts and large integers + +Chia amounts and arbitrary-precision integers are passed as decimal strings: + +```csharp +string amountStr = coin.GetAmount(); +BigInteger amount = BigInteger.Parse(amountStr); +``` + +## Async Methods + +Async methods (RPC, simulator) return `Task` and run on the Rust tokio runtime: + +```csharp +var client = await RpcClient.New("https://api.coinset.org"); +var state = await client.GetBlockchainState(); +``` + +## More Information + +- [Source & full documentation](https://github.com/dkackman/chia-wallet-sdk/tree/main/dotnet) +- [Upstream Chia Wallet SDK](https://github.com/xch-dev/chia-wallet-sdk) +- License: Apache-2.0 diff --git a/dotnet/cs/chia-wallet-sdk.code-workspace b/dotnet/cs/chia-wallet-sdk.code-workspace new file mode 100644 index 000000000..eaa1e3915 --- /dev/null +++ b/dotnet/cs/chia-wallet-sdk.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../.." + }, + { + "path": "../../../chia.wallet.dotnet" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/dotnet/local-build.ps1 b/dotnet/local-build.ps1 new file mode 100644 index 000000000..c7840824d --- /dev/null +++ b/dotnet/local-build.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding()] +param( + [string]$Version = "0.0.4-local", + [string]$Target = "x86_64-pc-windows-msvc" +) + +$ScriptDir = $PSScriptRoot + +function Show-Usage { + Write-Host "Usage: .\local-build.ps1 [-Version ] [-Target ]" + Write-Host " -Version NuGet package version (default: 0.0.4-local)" + Write-Host " -Target Rust target triple (default: x86_64-pc-windows-msvc)" + exit 1 +} + +# Derive library filename and .NET RID from the target triple +switch -Wildcard ($Target) { + "*-apple-*" { + $LibExt = "dylib" + switch -Wildcard ($Target) { + "aarch64-*" { $DotnetRid = "osx-arm64" } + "x86_64-*" { $DotnetRid = "osx-x64" } + default { Write-Error "Unsupported macOS arch in target: $Target"; exit 1 } + } + } + "*-linux-*" { + $LibExt = "so" + switch -Wildcard ($Target) { + "aarch64-*" { $DotnetRid = "linux-arm64" } + "x86_64-*" { $DotnetRid = "linux-x64" } + default { Write-Error "Unsupported Linux arch in target: $Target"; exit 1 } + } + } + "*-windows-*" { + $LibExt = "dll" + switch -Wildcard ($Target) { + "aarch64-*" { $DotnetRid = "win-arm64" } + "x86_64-*" { $DotnetRid = "win-x64" } + default { Write-Error "Unsupported Windows arch in target: $Target"; exit 1 } + } + } + default { + Write-Error "Unrecognized target triple: $Target" + exit 1 + } +} + +# Windows DLLs have no "lib" prefix; all other platforms do. +$LibPrefix = if ($LibExt -eq "dll") { "" } else { "lib" } +$LibName = "${LibPrefix}chia_wallet_sdk.$LibExt" +$LibPath = Join-Path $ScriptDir ".." "target" $Target "release-cs" $LibName + +# Force Ninja generator on Windows to avoid MSBuild's VCTargetsPath detection, +# which fails with MSB4136/System.MarvinHash on some VS BuildTools installs. +if ($IsWindows -and -not $env:CMAKE_GENERATOR) { + $env:CMAKE_GENERATOR = "Ninja" +} + +Write-Host "Building native library for $Target..." +Push-Location (Join-Path $ScriptDir "..") +try { + cargo build --profile release-cs -p chia-wallet-sdk-cs --target $Target + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} finally { + Pop-Location +} + +Write-Host "Generating C# bindings..." +uniffi-bindgen-cs ` + --library ` + --out-dir "$ScriptDir\cs" ` + --config "$ScriptDir\uniffi.toml" ` + $LibPath +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "Staging native library..." +$NativeDir = Join-Path $ScriptDir "cs" "runtimes" $DotnetRid "native" +New-Item -ItemType Directory -Force -Path $NativeDir | Out-Null +Copy-Item $LibPath $NativeDir + +Write-Host "Packing NuGet (version: $Version)..." +dotnet pack "$ScriptDir\cs\ChiaWalletSdk.csproj" ` + -c Release ` + -o "$ScriptDir\nuget-out" ` + -p:Version=$Version +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +Write-Host "" +Write-Host "Package ready: $ScriptDir\nuget-out\ChiaWalletSdk.$Version.nupkg" +Write-Host "" +Write-Host "To register the local feed (once):" +Write-Host " dotnet nuget add source $ScriptDir\nuget-out --name chia-local" +Write-Host "" +Write-Host "To add to a project:" +Write-Host " dotnet add package ChiaWalletSdk --version $Version" diff --git a/dotnet/local-build.sh b/dotnet/local-build.sh new file mode 100755 index 000000000..ccba4fded --- /dev/null +++ b/dotnet/local-build.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +usage() { + echo "Usage: $0 [-v VERSION] [-t TARGET]" + echo " -v VERSION NuGet package version (default: 0.0.4-local)" + echo " -t TARGET Rust target triple (default: aarch64-apple-darwin)" + exit 1 +} + +VERSION="0.33.0-dotnet.2" +TARGET="aarch64-apple-darwin" + +while getopts ":v:t:h" opt; do + case $opt in + v) VERSION="$OPTARG" ;; + t) TARGET="$OPTARG" ;; + h) usage ;; + :) echo "Option -$OPTARG requires an argument." >&2; usage ;; + \?) echo "Unknown option: -$OPTARG" >&2; usage ;; + esac +done + +# Derive library filename and .NET RID from the target triple +case "$TARGET" in + *-apple-*) + LIB_EXT="dylib" + case "$TARGET" in + aarch64-*) DOTNET_RID="osx-arm64" ;; + x86_64-*) DOTNET_RID="osx-x64" ;; + *) echo "Unsupported macOS arch in target: $TARGET" >&2; exit 1 ;; + esac + ;; + *-linux-*) + LIB_EXT="so" + case "$TARGET" in + aarch64-*) DOTNET_RID="linux-arm64" ;; + x86_64-*) DOTNET_RID="linux-x64" ;; + *) echo "Unsupported Linux arch in target: $TARGET" >&2; exit 1 ;; + esac + ;; + *-windows-*) + LIB_EXT="dll" + case "$TARGET" in + aarch64-*) DOTNET_RID="win-arm64" ;; + x86_64-*) DOTNET_RID="win-x64" ;; + *) echo "Unsupported Windows arch in target: $TARGET" >&2; exit 1 ;; + esac + ;; + *) + echo "Unrecognized target triple: $TARGET" >&2; exit 1 ;; +esac + +# Windows DLLs have no "lib" prefix; all other platforms do. +LIB_PREFIX=$( [ "$LIB_EXT" = "dll" ] && echo "" || echo "lib" ) +LIB_NAME="${LIB_PREFIX}chia_wallet_sdk.$LIB_EXT" +LIB_PATH="$SCRIPT_DIR/../target/$TARGET/release-cs/$LIB_NAME" + +UNIFFI_BINDGEN_CS_TAG="v0.10.0+v0.29.4" + +echo "Building native library for $TARGET..." +cd .. +cargo build --profile release-cs -p chia-wallet-sdk-cs --target "$TARGET" +cd "$SCRIPT_DIR" + +if ! command -v uniffi-bindgen-cs &>/dev/null; then + echo "Installing uniffi-bindgen-cs $UNIFFI_BINDGEN_CS_TAG..." + cargo install uniffi-bindgen-cs \ + --git https://github.com/NordSecurity/uniffi-bindgen-cs \ + --tag "$UNIFFI_BINDGEN_CS_TAG" +fi + +echo "Generating C# bindings..." +uniffi-bindgen-cs \ + --library \ + --out-dir "$SCRIPT_DIR/cs" \ + --config "$SCRIPT_DIR/uniffi.toml" \ + "$LIB_PATH" + +echo "Staging native library..." +mkdir -p "$SCRIPT_DIR/cs/runtimes/$DOTNET_RID/native" +cp "$LIB_PATH" "$SCRIPT_DIR/cs/runtimes/$DOTNET_RID/native/" + +echo "Packing NuGet (version: $VERSION)..." +dotnet pack "$SCRIPT_DIR/cs/ChiaWalletSdk.csproj" \ + -c Release \ + -o "$SCRIPT_DIR/nuget-out" \ + -p:Version="$VERSION" + +echo "" +echo "Package ready: $SCRIPT_DIR/nuget-out/ChiaWalletSdk.$VERSION.nupkg" +echo "" +echo "To register the local feed (once):" +echo " dotnet nuget add source $SCRIPT_DIR/nuget-out --name chia-local" +echo "" +echo "To add to a project:" +echo " dotnet add package ChiaWalletSdk --version $VERSION" diff --git a/dotnet/src/lib.rs b/dotnet/src/lib.rs new file mode 100644 index 000000000..8dd2dd3be --- /dev/null +++ b/dotnet/src/lib.rs @@ -0,0 +1,182 @@ +#![allow(clippy::too_many_arguments)] +// UniFFI's setup_scaffolding! macro emits raw FFI extern blocks which require unsafe. +#![allow(unsafe_code)] + +use std::sync::Arc; + +// UniFFI scaffolding — must match the [lib] name in Cargo.toml +uniffi::setup_scaffolding!("chia_wallet_sdk"); + +// Generate all bindings from the JSON schemas +bindy_macro::bindy_uniffi!("bindings.json"); + +// Hand-written alloc method for Clvm. +// The JSON schema marks alloc as stub_only because the argument type (ClvmType) +// requires manual dispatch. Here we implement it using the macro-generated ClvmType enum. +#[uniffi::export] +impl Clvm { + // Unlike the Python backend which accepts dynamic types (None, int, bool, str, bytes, list, tuple), + // the UniFFI backend uses a statically-typed ClvmType enum. To allocate nil/int/bool/string/bytes + // use the Clvm helper methods directly: nil(), int(), bool_(), string(), atom(), pair(), list(). + pub fn alloc(&self, value: ClvmType) -> Result, ChiaError> { + let result = match value { + ClvmType::Program { value } => value.0.clone(), + ClvmType::Pair { value } => self.0.pair(value.0.first.clone(), value.0.rest.clone())?, + ClvmType::CurriedProgram { value } => { + value.0.program.clone().curry(value.0.args.clone())? + } + ClvmType::PublicKey { value } => { + let bytes = value.0.to_bytes(); + self.0.atom(bytes.to_vec().into())? + } + ClvmType::Signature { value } => { + let bytes = value.0.to_bytes(); + self.0.atom(bytes.to_vec().into())? + } + ClvmType::K1PublicKey { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::K1Signature { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::R1PublicKey { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::R1Signature { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::Remark { value } => self.0.remark(value.0.rest.clone())?, + ClvmType::AggSigParent { value } => self + .0 + .agg_sig_parent(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigPuzzle { value } => self + .0 + .agg_sig_puzzle(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigAmount { value } => self + .0 + .agg_sig_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigPuzzleAmount { value } => self + .0 + .agg_sig_puzzle_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigParentAmount { value } => self + .0 + .agg_sig_parent_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigParentPuzzle { value } => self + .0 + .agg_sig_parent_puzzle(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigUnsafe { value } => self + .0 + .agg_sig_unsafe(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigMe { value } => self + .0 + .agg_sig_me(value.0.public_key, value.0.message.clone())?, + ClvmType::CreateCoin { value } => { + self.0 + .create_coin(value.0.puzzle_hash, value.0.amount, value.0.memos.clone())? + } + ClvmType::ReserveFee { value } => self.0.reserve_fee(value.0.amount)?, + ClvmType::CreateCoinAnnouncement { value } => { + self.0.create_coin_announcement(value.0.message.clone())? + } + ClvmType::CreatePuzzleAnnouncement { value } => { + self.0.create_puzzle_announcement(value.0.message.clone())? + } + ClvmType::AssertCoinAnnouncement { value } => { + self.0.assert_coin_announcement(value.0.announcement_id)? + } + ClvmType::AssertPuzzleAnnouncement { value } => { + self.0.assert_puzzle_announcement(value.0.announcement_id)? + } + ClvmType::AssertConcurrentSpend { value } => { + self.0.assert_concurrent_spend(value.0.coin_id)? + } + ClvmType::AssertConcurrentPuzzle { value } => { + self.0.assert_concurrent_puzzle(value.0.puzzle_hash)? + } + ClvmType::AssertSecondsRelative { value } => { + self.0.assert_seconds_relative(value.0.seconds)? + } + ClvmType::AssertSecondsAbsolute { value } => { + self.0.assert_seconds_absolute(value.0.seconds)? + } + ClvmType::AssertHeightRelative { value } => { + self.0.assert_height_relative(value.0.height)? + } + ClvmType::AssertHeightAbsolute { value } => { + self.0.assert_height_absolute(value.0.height)? + } + ClvmType::AssertBeforeSecondsRelative { value } => { + self.0.assert_before_seconds_relative(value.0.seconds)? + } + ClvmType::AssertBeforeSecondsAbsolute { value } => { + self.0.assert_before_seconds_absolute(value.0.seconds)? + } + ClvmType::AssertBeforeHeightRelative { value } => { + self.0.assert_before_height_relative(value.0.height)? + } + ClvmType::AssertBeforeHeightAbsolute { value } => { + self.0.assert_before_height_absolute(value.0.height)? + } + ClvmType::AssertMyCoinId { value } => self.0.assert_my_coin_id(value.0.coin_id)?, + ClvmType::AssertMyParentId { value } => { + self.0.assert_my_parent_id(value.0.parent_id)? + } + ClvmType::AssertMyPuzzleHash { value } => { + self.0.assert_my_puzzle_hash(value.0.puzzle_hash)? + } + ClvmType::AssertMyAmount { value } => self.0.assert_my_amount(value.0.amount)?, + ClvmType::AssertMyBirthSeconds { value } => { + self.0.assert_my_birth_seconds(value.0.seconds)? + } + ClvmType::AssertMyBirthHeight { value } => { + self.0.assert_my_birth_height(value.0.height)? + } + ClvmType::AssertEphemeral { .. } => self.0.assert_ephemeral()?, + ClvmType::SendMessage { value } => { + self.0 + .send_message(value.0.mode, value.0.message.clone(), value.0.data.clone())? + } + ClvmType::ReceiveMessage { value } => self.0.receive_message( + value.0.mode, + value.0.message.clone(), + value.0.data.clone(), + )?, + ClvmType::Softfork { value } => self.0.softfork(value.0.cost, value.0.rest.clone())?, + ClvmType::MeltSingleton { .. } => self.0.melt_singleton()?, + ClvmType::TransferNft { value } => self.0.transfer_nft( + value.0.launcher_id, + value.0.trade_prices.clone(), + value.0.singleton_inner_puzzle_hash, + )?, + ClvmType::RunCatTail { value } => self + .0 + .run_cat_tail(value.0.program.clone(), value.0.solution.clone())?, + ClvmType::UpdateNftMetadata { value } => self.0.update_nft_metadata( + value.0.updater_puzzle_reveal.clone(), + value.0.updater_solution.clone(), + )?, + ClvmType::UpdateDataStoreMerkleRoot { value } => self + .0 + .update_data_store_merkle_root(value.0.new_merkle_root, value.0.memos.clone())?, + ClvmType::NftMetadata { value } => self.0.nft_metadata(value.0.clone())?, + ClvmType::MipsMemo { value } => self.0.mips_memo(value.0.clone())?, + ClvmType::InnerPuzzleMemo { value } => self.0.inner_puzzle_memo(value.0.clone())?, + ClvmType::RestrictionMemo { value } => self.0.restriction_memo(value.0.clone())?, + ClvmType::WrapperMemo { value } => self.0.wrapper_memo(value.0.clone())?, + ClvmType::Force1of2RestrictedVariableMemo { value } => self + .0 + .force_1_of_2_restricted_variable_memo(value.0.clone())?, + ClvmType::MemoKind { value } => self.0.memo_kind(value.0.clone())?, + ClvmType::MemberMemo { value } => self.0.member_memo(value.0.clone())?, + ClvmType::MofNMemo { value } => self.0.m_of_n_memo(value.0.clone())?, + ClvmType::OptionMetadata { value } => self.0.option_metadata(value.0)?, + ClvmType::NotarizedPayment { value } => self.0.notarized_payment(value.0.clone())?, + ClvmType::Payment { value } => self.0.payment(value.0.clone())?, + }; + Ok(Arc::new(Program(result))) + } +} diff --git a/dotnet/tests/.gitignore b/dotnet/tests/.gitignore new file mode 100644 index 000000000..b2c6af321 --- /dev/null +++ b/dotnet/tests/.gitignore @@ -0,0 +1,11 @@ +# .NET build output +bin/ +obj/ + +# NuGet packages (restored by dotnet restore) +*.user +*.suo +.vs/ + +# Test results +TestResults/ diff --git a/dotnet/tests/AsyncTests.cs b/dotnet/tests/AsyncTests.cs new file mode 100644 index 000000000..b5ceea4fe --- /dev/null +++ b/dotnet/tests/AsyncTests.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading.Tasks; +using Xunit; + +namespace ChiaWalletSdk.Tests; + +public class AsyncTests +{ + [Fact] + [Trait("Category", "Integration")] + public async Task GetBlockchainState_AsyncWorks() + { + try + { + var options = new RpcClientOptions( + requestTimeoutMs: 30_000, // whole-request budget: connect + send + receive + connectTimeoutMs: null); // just the connection phase + + var rpc = RpcClient.Mainnet().WithOptions(options); + var response = await rpc.GetBlockchainState(); + Assert.True(response.GetSuccess(), "success should be true"); + Assert.NotNull(response.GetBlockchainState()); + } + catch (Exception ex) when (IsNetworkError(ex)) + { + Console.WriteLine($"SKIP: network unavailable: {ex.Message}"); + } + } + + [Fact] + [Trait("Category", "Integration")] + public async Task GetNetworkInfo_AsyncWorks() + { + try + { + var options = new RpcClientOptions( + requestTimeoutMs: 30_000, // whole-request budget: connect + send + receive + connectTimeoutMs: null); // just the connection phase + + var rpc = RpcClient.Mainnet().WithOptions(options); + var response = await rpc.GetNetworkInfo(); + Assert.True(response.GetSuccess(), "success should be true"); + Assert.NotNull(response.GetNetworkName()); + } + catch (Exception ex) when (IsNetworkError(ex)) + { + Console.WriteLine($"SKIP: network unavailable: {ex.Message}"); + } + } + + private static bool IsNetworkError(Exception ex) + { + var msg = ex.Message.ToLowerInvariant(); + return msg.Contains("connect") || msg.Contains("timeout") || + msg.Contains("network") || msg.Contains("dns") || + msg.Contains("request") || msg.Contains("host"); + } +} diff --git a/dotnet/tests/BasicTests.cs b/dotnet/tests/BasicTests.cs new file mode 100644 index 000000000..2f67d2459 --- /dev/null +++ b/dotnet/tests/BasicTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Linq; +using Xunit; + +namespace ChiaWalletSdk.Tests; + +public class BasicTests +{ + [Fact] + public void ToHexFromHex_Roundtrip() + { + var bytes = ChiaWalletSdkMethods.FromHex("ff"); + var hex = ChiaWalletSdkMethods.ToHex(bytes); + Assert.Equal("ff", hex); + } + + [Fact] + public void BytesEqual_Equal() + { + var a = new byte[] { 1, 2, 3 }; + var b = new byte[] { 1, 2, 3 }; + Assert.True(ChiaWalletSdkMethods.BytesEqual(a, b)); + } + + [Fact] + public void BytesEqual_NotEqual() + { + var a = new byte[] { 1, 2, 3 }; + var b = new byte[] { 1, 2, 4 }; + Assert.False(ChiaWalletSdkMethods.BytesEqual(a, b)); + } + + [Fact] + public void CoinId_KnownValue() + { + var coin = new Coin( + ChiaWalletSdkMethods.FromHex("4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a"), + ChiaWalletSdkMethods.FromHex("dbc1b4c900ffe48d575b5da5c638040125f65db0fe3e24494b76ea986457d986"), + "100" + ); + var coinId = coin.CoinId(); + Assert.Equal( + "fd3e669c27be9d634fe79f1f7d7d8aaacc3597b855cffea1d708f4642f1d542a", + ChiaWalletSdkMethods.ToHex(coinId) + ); + } + + [Fact] + public void AtomRoundtrip() + { + var clvm = new Clvm(); + var expected = new byte[] { 1, 2, 3 }; + var program = clvm.Atom(expected); + Assert.Equal(expected, program.ToAtom()); + } + + [Fact] + public void StringRoundtrip() + { + var clvm = new Clvm(); + var expected = "hello world"; + var program = clvm.Atom(System.Text.Encoding.UTF8.GetBytes(expected)); + Assert.Equal(expected, program.ToString()); + } + + [Fact] + public void IntRoundtrip() + { + var clvm = new Clvm(); + foreach (var value in new[] { "0", "1", "420", "-1", "-100", "67108863" }) + { + var program = clvm.Int(value); + Assert.Equal(value, program.ToInt()); + } + } + + [Fact] + public void PairRoundtrip() + { + var clvm = new Clvm(); + var first = clvm.Int("1"); + var rest = clvm.Int("100"); + var pair = clvm.Pair(first, rest); + var result = pair.ToPair(); + Assert.NotNull(result); + Assert.Equal("1", result.GetFirst().ToInt()); + Assert.Equal("100", result.GetRest().ToInt()); + } + + [Fact] + public void PublicKeyRoundtrip() + { + var original = PublicKey.Infinity(); + var bytes = original.ToBytes(); + var restored = PublicKey.FromBytes(bytes); + Assert.True(ChiaWalletSdkMethods.BytesEqual(original.ToBytes(), restored.ToBytes())); + } + + [Fact] + public void ClvmSerialization() + { + var clvm = new Clvm(); + + var cases = new (Program program, string hex)[] + { + (clvm.Atom(new byte[] { 1, 2, 3 }), "83010203"), + (clvm.Int("420"), "8201a4"), + (clvm.Int("100"), "64"), + (clvm.Pair(clvm.Atom(new byte[] { 1, 2, 3 }), clvm.Int("100")), "ff8301020364"), + }; + + foreach (var (program, expectedHex) in cases) + { + var serialized = program.Serialize(); + var deserialized = clvm.Deserialize(serialized); + Assert.Equal(expectedHex, ChiaWalletSdkMethods.ToHex(serialized)); + Assert.True(ChiaWalletSdkMethods.BytesEqual(program.TreeHash(), deserialized.TreeHash())); + } + } + + [Fact] + public void CurryRoundtrip() + { + var clvm = new Clvm(); + var items = Enumerable.Range(0, 10).Select(i => clvm.Int(i.ToString())).ToArray(); + var curried = clvm.Nil().Curry(items); + var uncurried = curried.Uncurry(); + Assert.NotNull(uncurried); + Assert.True(ChiaWalletSdkMethods.BytesEqual(clvm.Nil().TreeHash(), uncurried.GetProgram().TreeHash())); + var args = uncurried.GetArgs(); + Assert.NotNull(args); + var uncurriedArgs = args.Select(p => p.ToInt()!).ToList(); + var expectedArgs = Enumerable.Range(0, 10).Select(i => i.ToString()).ToList(); + Assert.Equal(expectedArgs, uncurriedArgs); + } + + [Fact] + public void AllocMultipleTypes() + { + var clvm = new Clvm(); + var program = clvm.List(new Program[] + { + clvm.Nil(), + clvm.Alloc(new ClvmType.PublicKey(PublicKey.Infinity())), + clvm.Atom(System.Text.Encoding.UTF8.GetBytes("Hello, world!")), + clvm.Int("42"), + clvm.Int("100"), + clvm.Bool(true), + clvm.Atom(new byte[] { 1, 2, 3 }), + clvm.Atom(new byte[32]), + clvm.Nil(), + clvm.Nil(), + clvm.Alloc(new ClvmType.RunCatTail(new RunCatTail(clvm.Nil(), clvm.Nil()))), + }); + + Assert.Equal( + "ff80ffb0c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ff8d48656c6c6f2c20776f726c6421ff2aff64ff01ff83010203ffa00000000000000000000000000000000000000000000000000000000000000000ff80ff80ffff33ff80ff818fff80ff808080", + ChiaWalletSdkMethods.ToHex(program.Serialize()) + ); + } + + [Fact] + public void CreateAndParseCondition() + { + var clvm = new Clvm(); + var puzzleHash = new byte[32]; + Array.Fill(puzzleHash, (byte)0xff); + + var memos = clvm.List(new Program[] { clvm.Atom(puzzleHash) }); + var condition = clvm.CreateCoin(puzzleHash, "1", memos); + var parsed = condition.ParseCreateCoin(); + + Assert.NotNull(parsed); + Assert.True(ChiaWalletSdkMethods.BytesEqual(puzzleHash, parsed.GetPuzzleHash())); + Assert.Equal("1", parsed.GetAmount()); + } +} diff --git a/dotnet/tests/ChiaWalletSdkTests.csproj b/dotnet/tests/ChiaWalletSdkTests.csproj new file mode 100644 index 000000000..9e7574efe --- /dev/null +++ b/dotnet/tests/ChiaWalletSdkTests.csproj @@ -0,0 +1,59 @@ + + + + net8.0 + enable + true + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/dotnet/uniffi.toml b/dotnet/uniffi.toml new file mode 100644 index 000000000..74ab7d16b --- /dev/null +++ b/dotnet/uniffi.toml @@ -0,0 +1,2 @@ +[bindings.csharp] +namespace = "ChiaWalletSdk" diff --git a/go/Cargo.toml b/go/Cargo.toml new file mode 100644 index 000000000..0b8492685 --- /dev/null +++ b/go/Cargo.toml @@ -0,0 +1,39 @@ +[package] +publish = false +name = "chia-wallet-sdk-go" +version = "0.33.0" +edition = "2024" + +[lib] +name = "chia_wallet_sdk" +crate-type = ["cdylib"] +doc = false +test = false + +[dependencies] +uniffi = { workspace = true } +chia-sdk-bindings = { workspace = true, features = ["uniffi"] } +bindy = { workspace = true, features = ["uniffi"] } +bindy-macro = { workspace = true } + +[target.aarch64-unknown-linux-gnu.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.aarch64-unknown-linux-musl.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.x86_64-unknown-linux-gnu.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[target.x86_64-unknown-linux-musl.dependencies] +openssl = { version = "0.10.73", features = ["vendored"] } +openssl-sys = { version = "0.9.108", features = ["vendored"] } + +[package.metadata.cargo-machete] +ignored = ["bindy", "chia-sdk-bindings"] + +[lints] +workspace = true diff --git a/go/README.md b/go/README.md new file mode 100644 index 000000000..269237ef2 --- /dev/null +++ b/go/README.md @@ -0,0 +1,320 @@ +# chia-wallet-sdk Go Bindings + +Go bindings for the [Chia Wallet SDK](https://github.com/xch-dev/chia-wallet-sdk), generated via [UniFFI](https://mozilla.github.io/uniffi-rs/) and [uniffi-bindgen-go](https://github.com/NordSecurity/uniffi-bindgen-go). + +The Rust library exposes the full SDK surface — BLS keys, addresses, CLVM, coins, conditions, offers, puzzles, RPC, and more — as a native shared library that Go loads via CGo through the UniFFI scaffolding layer. + +--- + +## Prerequisites + +- Rust toolchain (1.81+) — [rustup.rs](https://rustup.rs) +- Go 1.24+ — [go.dev](https://go.dev) +- A C compiler (required by CGo) — Xcode CLT on macOS, `build-essential` on Linux, MSVC or MinGW on Windows +- `uniffi-bindgen-go` — installed once per machine (see below) + +--- + +## Building + +The `local-build.sh` script handles all steps: compiling the native library, installing `uniffi-bindgen-go` if needed, generating the Go source, and staging the shared library. + +```bash +cd go +./local-build.sh # defaults: aarch64-apple-darwin +./local-build.sh -t x86_64-apple-darwin +./local-build.sh -t x86_64-unknown-linux-gnu +./local-build.sh -t x86_64-pc-windows-msvc # PowerShell: .\local-build.ps1 +``` + +After running, the `go/chia_wallet_sdk/` directory contains: + +| File | Description | +| ---- | ----------- | +| `chia_wallet_sdk.go` | All generated Go types, functions, and CGo glue | +| `chia_wallet_sdk.h` | C header required at CGo compile time | +| `libchia_wallet_sdk.dylib` / `.so` / `.dll` | Native shared library | + +These files are git-ignored and must be regenerated per platform. + +--- + +## Step-by-step (manual) + +### 1 — Build the native library + +```bash +# From the repo root +cargo build --profile release-go -p chia-wallet-sdk-go --target aarch64-apple-darwin +``` + +Output by platform: + +| Platform | File | +| -------- | ---- | +| macOS | `target//release-go/libchia_wallet_sdk.dylib` | +| Linux | `target//release-go/libchia_wallet_sdk.so` | +| Windows | `target//release-go/chia_wallet_sdk.dll` | + +### 2 — Install `uniffi-bindgen-go` + +```bash +cargo install uniffi-bindgen-go \ + --git https://github.com/NordSecurity/uniffi-bindgen-go \ + --tag v0.5.0+v0.29.5 +``` + +> **Note:** the workspace is pinned to `uniffi = "=0.29.4"`. The 0.29.4→0.29.5 patch-version mismatch between this tool and the compiled library is benign. When `uniffi-bindgen-cs` releases a `+v0.29.5` tag, both tools and the workspace pin can be aligned in one step. + +### 3 — Generate the Go source + +```bash +uniffi-bindgen-go \ + --library target/aarch64-apple-darwin/release-go/libchia_wallet_sdk.dylib \ + --out-dir go/ +``` + +This writes `go/chia_wallet_sdk/chia_wallet_sdk.go` and `go/chia_wallet_sdk/chia_wallet_sdk.h`. + +--- + +## Using in a Go Project + +### CGo linking + +The generated code uses `#include ` and links against `libchia_wallet_sdk`. The linker must be able to find the shared library. The simplest approach during development is to set `CGO_LDFLAGS`: + +```bash +export CGO_LDFLAGS="-L/path/to/chia-wallet-sdk/go/chia_wallet_sdk -lchia_wallet_sdk" +go build ./... +``` + +Or install the library system-wide: + +```bash +# macOS +sudo cp go/chia_wallet_sdk/libchia_wallet_sdk.dylib /usr/local/lib/ + +# Linux +sudo cp go/chia_wallet_sdk/libchia_wallet_sdk.so /usr/local/lib/ +sudo ldconfig +``` + +### Import + +```go +import chia "github.com/xch-dev/chia-wallet-sdk/go/chia_wallet_sdk" +``` + +--- + +## Quick Start + +```go +package main + +import ( + "fmt" + "math/big" + + chia "github.com/xch-dev/chia-wallet-sdk/go/chia_wallet_sdk" +) + +func main() { + // Generate a 24-word BLS key + mnemonic, err := chia.MnemonicGenerate(true) + if err != nil { + panic(err) + } + defer mnemonic.Destroy() + + seed, err := mnemonic.ToSeed("") + if err != nil { + panic(err) + } + + sk, err := chia.SecretKeyFromSeed(seed) + if err != nil { + panic(err) + } + defer sk.Destroy() + + pk, err := sk.PublicKey() + if err != nil { + panic(err) + } + defer pk.Destroy() + + // Encode a puzzle hash as a Chia address + puzzleHash := make([]byte, 32) + addr, err := chia.NewAddress(puzzleHash, "xch") + if err != nil { + panic(err) + } + defer addr.Destroy() + + encoded, err := addr.Encode() + if err != nil { + panic(err) + } + fmt.Println(encoded) // "xch1..." + + // Decode an address back to its puzzle hash + decoded, err := chia.AddressDecode("xch1...") + if err != nil { + panic(err) + } + defer decoded.Destroy() + + ph, err := decoded.GetPuzzleHash() + if err != nil { + panic(err) + } + _ = ph + + // Amounts are returned as decimal strings; use math/big for arithmetic + // (example: coin.GetAmount() returns a string) + amountStr := "1000000000000" + amount, _ := new(big.Int).SetString(amountStr, 10) + newAmount := new(big.Int).Add(amount, big.NewInt(1)).String() + _ = newAmount + + // RPC + client, err := chia.NewRpcClient("https://api.coinset.org") + if err != nil { + panic(err) + } + defer client.Destroy() + + state, err := client.GetBlockchainState() + if err != nil { + panic(err) + } + defer state.Destroy() +} +``` + +--- + +## API Surface + +The bindings cover the full SDK: + +| Module | Types / Functions | +| ------ | ----------------- | +| BLS keys | `SecretKey`, `PublicKey`, `Signature`, `AggregateSignature` | +| Secp keys | `K1SecretKey`, `K1PublicKey`, `K1Signature`, `R1SecretKey`, `R1PublicKey`, `R1Signature` | +| Address | `Address`, `AddressDecode` | +| Mnemonic | `MnemonicGenerate`, `NewMnemonic`, `MnemonicFromEntropy` | +| Coin | `Coin`, `CoinSpend`, `CoinState` | +| CLVM | `Clvm`, `Program`, `CurriedProgram`, `Spend` | +| Conditions | `CreateCoin`, `AggSigMe`, `ReserveFee`, … (47 condition types) | +| Puzzles | `StandardPuzzle`, `CatPuzzle`, `NftPuzzle`, `DlPuzzle`, `SingletonPuzzle`, … | +| Offers | `Offer`, `ParsedOffer` | +| RPC | `NewRpcClient`, `RpcClientMainnet`, `RpcClientTestnet11`, `RpcClientLocal` | +| Simulator | `Simulator` (testing) | +| Utils | `Sha256`, `HashToG2`, … | +| Constants | `Constants` (puzzle hashes for all built-in puzzles) | + +--- + +## Type Mapping Reference + +| Rust type | Go type | Notes | +| --------- | ------- | ----- | +| `Vec` / bytes types | `[]byte` | Puzzle hashes, keys, signatures | +| `u64`, `u128`, `BigInt` | `string` | Parse with `new(big.Int).SetString(s, 10)` | +| `u8`–`u32` | `uint8`–`uint32` | | +| `i8`–`i32` | `int8`–`int32` | | +| `u64` | `uint64` | | +| `f64` | `float64` | | +| `bool` | `bool` | | +| `String` | `string` | | +| `Option` | `*T` | `nil` represents `None` | +| `Vec` | `[]T` | | +| Rust struct / class | Go struct (pointer receiver) | Reference-counted via `Arc`; call `Destroy()` when done | +| Rust enum | Go type with constants | | + +### BigInt / amounts + +Chia amounts, heights, and arbitrary-precision integers are passed as decimal strings. Use `math/big` on the Go side: + +```go +amountStr, _ := coin.GetAmount() // returns "1750000000000" +amount, _ := new(big.Int).SetString(amountStr, 10) +incremented := new(big.Int).Add(amount, big.NewInt(1)).String() +``` + +### Object lifetime + +Every object returned by the bindings is heap-allocated on the Rust side and reference-counted. Call `Destroy()` when you are done with an object, or defer it immediately after construction: + +```go +sk, err := chia.SecretKeyFromSeed(seed) +if err != nil { ... } +defer sk.Destroy() +``` + +Failing to call `Destroy()` leaks the Rust allocation. The garbage collector does not automatically free Rust objects. + +--- + +## RPC + +RPC calls are synchronous (blocking). Wrap them in a goroutine if you need concurrency: + +```go +client, err := chia.RpcClientMainnet() +if err != nil { panic(err) } +defer client.Destroy() + +// Blocking call — runs on the Rust tokio runtime under the hood +state, err := client.GetBlockchainState() +if err != nil { panic(err) } +defer state.Destroy() +``` + +--- + +## Architecture + +```text +bindings/*.json ← single source of truth for the API surface + ↓ +bindy_uniffi! macro ← generates #[derive(uniffi::Object)] / #[uniffi::export] + ↓ +go/ crate ← cdylib: setup_scaffolding! + generated + hand-written alloc + ↓ cargo build --profile release-go +libchia_wallet_sdk.dylib ← native shared library + ↓ uniffi-bindgen-go +chia_wallet_sdk.go ← Go package with all types and CGo bindings +``` + +The same `bindings/*.json` schemas also drive the C# (`dotnet/`), Node.js (`napi/`), WebAssembly (`wasm/`), and Python (`pyo3/`) backends. Adding a method to a JSON schema automatically adds it to all backends. + +--- + +## Known Limitations + +| Limitation | Details | +| ---------- | ------- | +| BigInt as string | `u64`/`u128`/`BigInt` map to `string`; parse with `math/big`. | +| Manual `Destroy()` | Rust objects must be explicitly destroyed; the Go GC does not reach Rust heap allocations. | +| `Clvm.Alloc()` is typed | Unlike Python, which accepts dynamic types, `Alloc` takes a `ClvmType` enum. Use `NewClvm()` helper methods — `Nil()`, `Int()`, `Atom()`, etc. — for primitive values. | +| Field setters return new objects | `SetField(value)` returns a new object with the field changed; the original is unchanged. Use the return value. | +| CGo required | Pure-Go builds are not supported. A C compiler must be available in the build environment. | +| Version note | `uniffi-bindgen-go` `v0.5.0+v0.29.5` is used with a `uniffi = "=0.29.4"` workspace. The patch-version mismatch is benign. Align both when `uniffi-bindgen-cs` releases a `+v0.29.5` tag. | + +--- + +## Rebuilding After SDK Updates + +When the SDK version bumps or new API is added, re-run the build script for each target platform: + +```bash +cd go +./local-build.sh -t aarch64-apple-darwin +./local-build.sh -t x86_64-unknown-linux-gnu +``` + +No manual patches are needed — the generated file is always a complete, self-contained replacement. diff --git a/go/build.rs b/go/build.rs new file mode 100644 index 000000000..e72dbff1c --- /dev/null +++ b/go/build.rs @@ -0,0 +1,4 @@ +fn main() { + println!("cargo::rerun-if-changed=../bindings"); + println!("cargo::rerun-if-changed=../bindings.json"); +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 000000000..02307c990 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module github.com/xch-dev/chia-wallet-sdk/go + +go 1.24 diff --git a/go/local-build.ps1 b/go/local-build.ps1 new file mode 100644 index 000000000..5fee7d9e0 --- /dev/null +++ b/go/local-build.ps1 @@ -0,0 +1,74 @@ +[CmdletBinding()] +param( + [string]$Version = "v0.0.1-local", + [string]$Target = "x86_64-pc-windows-msvc" +) + +$ScriptDir = $PSScriptRoot + +# uniffi-bindgen-go v0.5.0 targets uniffi 0.29.5; the workspace is pinned to 0.29.4. +# No 0.29.4 tag exists for this tool — the patch-version mismatch is benign in practice. +# When uniffi-bindgen-cs releases a 0.29.5 tag, bump the workspace to =0.29.5 and align both. +# See https://github.com/NordSecurity/uniffi-bindgen-go/releases for available tags. +$UniffiBindgenGoTag = "v0.5.0+v0.29.5" + +switch -Wildcard ($Target) { + "*-apple-*" { $LibExt = "dylib" } + "*-linux-*" { $LibExt = "so" } + "*-windows-*" { $LibExt = "dll" } + default { Write-Error "Unrecognized target triple: $Target"; exit 1 } +} + +$LibPrefix = if ($LibExt -eq "dll") { "" } else { "lib" } +$LibName = "${LibPrefix}chia_wallet_sdk.$LibExt" +$LibPath = Join-Path $ScriptDir ".." "target" $Target "release-go" $LibName + +if ($IsWindows -and -not $env:CMAKE_GENERATOR) { + $env:CMAKE_GENERATOR = "Ninja" +} + +Write-Host "Building native library for $Target..." +Push-Location (Join-Path $ScriptDir "..") +try { + cargo build --profile release-go -p chia-wallet-sdk-go --target $Target + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} finally { + Pop-Location +} + +if (-not (Get-Command uniffi-bindgen-go -ErrorAction SilentlyContinue)) { + Write-Host "Installing uniffi-bindgen-go $UniffiBindgenGoTag..." + cargo install uniffi-bindgen-go ` + --git https://github.com/NordSecurity/uniffi-bindgen-go ` + --tag $UniffiBindgenGoTag + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +Write-Host "Generating Go bindings..." +uniffi-bindgen-go --library $LibPath --out-dir $ScriptDir +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# uniffi-bindgen-go generates OptionType factory constructors as OptionTypeCat(), +# OptionTypeNft(), etc., which collide with the separately-generated OptionTypeCat +# and OptionTypeNft struct types. Rename the constructors to resolve the conflict. +Write-Host "Patching naming collisions..." +$Generated = Join-Path $ScriptDir "chia_wallet_sdk" "chia_wallet_sdk.go" +$content = [System.IO.File]::ReadAllText($Generated) +$content = $content ` + -replace '(?m)^func OptionTypeCat\(', 'func NewOptionTypeFromCat(' ` + -replace '(?m)^func OptionTypeNft\(', 'func NewOptionTypeFromNft(' ` + -replace '(?m)^func OptionTypeRevocableCat\(', 'func NewOptionTypeFromRevocableCat(' ` + -replace '(?m)^func OptionTypeXch\(', 'func NewOptionTypeFromXch(' +[System.IO.File]::WriteAllText($Generated, $content) + +Write-Host "Staging native library..." +$OutDir = Join-Path $ScriptDir "chia_wallet_sdk" +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +Copy-Item $LibPath $OutDir + +Write-Host "" +Write-Host "Go bindings generated in: $OutDir" +Write-Host "" +Write-Host "To build a Go project using these bindings, set CGO_LDFLAGS:" +Write-Host " `$env:CGO_LDFLAGS = `"-L$OutDir -lchia_wallet_sdk`"" +Write-Host " go build ./..." diff --git a/go/local-build.sh b/go/local-build.sh new file mode 100755 index 000000000..4e652c4a3 --- /dev/null +++ b/go/local-build.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# uniffi-bindgen-go v0.5.0 targets uniffi 0.29.5; the workspace is pinned to 0.29.4. +# No 0.29.4 tag exists for this tool — the patch-version mismatch is benign in practice. +# When uniffi-bindgen-cs releases a 0.29.5 tag, bump the workspace to =0.29.5 and align both. +# See https://github.com/NordSecurity/uniffi-bindgen-go/releases for available tags. +UNIFFI_BINDGEN_GO_TAG="v0.5.0+v0.29.5" + +usage() { + echo "Usage: $0 [-v VERSION] [-t TARGET]" + echo " -v VERSION Go module version tag (default: v0.0.1-local)" + echo " -t TARGET Rust target triple (default: aarch64-apple-darwin)" + exit 1 +} + +VERSION="v0.0.1-local" +TARGET="aarch64-apple-darwin" + +while getopts ":v:t:h" opt; do + case $opt in + v) VERSION="$OPTARG" ;; + t) TARGET="$OPTARG" ;; + h) usage ;; + :) echo "Option -$OPTARG requires an argument." >&2; usage ;; + \?) echo "Unknown option: -$OPTARG" >&2; usage ;; + esac +done + +# Derive library filename from the target triple +case "$TARGET" in + *-apple-*) LIB_EXT="dylib" ;; + *-linux-*) LIB_EXT="so" ;; + *-windows-*) LIB_EXT="dll" ;; + *) echo "Unrecognized target triple: $TARGET" >&2; exit 1 ;; +esac + +LIB_PREFIX=$( [ "$LIB_EXT" = "dll" ] && echo "" || echo "lib" ) +LIB_NAME="${LIB_PREFIX}chia_wallet_sdk.$LIB_EXT" +LIB_PATH="$SCRIPT_DIR/../target/$TARGET/release-go/$LIB_NAME" + +echo "Building native library for $TARGET..." +cd .. +cargo build --profile release-go -p chia-wallet-sdk-go --target "$TARGET" +cd "$SCRIPT_DIR" + +if ! command -v uniffi-bindgen-go &>/dev/null; then + echo "Installing uniffi-bindgen-go $UNIFFI_BINDGEN_GO_TAG..." + cargo install uniffi-bindgen-go \ + --git https://github.com/NordSecurity/uniffi-bindgen-go \ + --tag "$UNIFFI_BINDGEN_GO_TAG" +fi + +echo "Generating Go bindings..." +uniffi-bindgen-go \ + --library "$LIB_PATH" \ + --out-dir "$SCRIPT_DIR" + +# uniffi-bindgen-go generates OptionType factory constructors as OptionTypeCat(), +# OptionTypeNft(), etc., which collide with the separately-generated OptionTypeCat +# and OptionTypeNft struct types. Rename the constructors to resolve the conflict. +echo "Patching naming collisions..." +GENERATED="$SCRIPT_DIR/chia_wallet_sdk/chia_wallet_sdk.go" +sed -i.bak \ + -e 's/^func OptionTypeCat(/func NewOptionTypeFromCat(/' \ + -e 's/^func OptionTypeNft(/func NewOptionTypeFromNft(/' \ + -e 's/^func OptionTypeRevocableCat(/func NewOptionTypeFromRevocableCat(/' \ + -e 's/^func OptionTypeXch(/func NewOptionTypeFromXch(/' \ + "$GENERATED" +rm -f "$GENERATED.bak" + +echo "Staging native library..." +mkdir -p "$SCRIPT_DIR/chia_wallet_sdk" +cp "$LIB_PATH" "$SCRIPT_DIR/chia_wallet_sdk/" + +echo "" +echo "Go bindings generated in: $SCRIPT_DIR/chia_wallet_sdk/" +echo "" +echo "To build a Go project using these bindings, the linker must find the native" +echo "library. Set CGO_LDFLAGS when building from outside this directory:" +echo " CGO_LDFLAGS=\"-L\$(realpath $SCRIPT_DIR/chia_wallet_sdk) -lchia_wallet_sdk\" go build ./..." +echo "" +echo "Or install the library system-wide (macOS example):" +echo " sudo cp $SCRIPT_DIR/chia_wallet_sdk/$LIB_NAME /usr/local/lib/" diff --git a/go/src/lib.rs b/go/src/lib.rs new file mode 100644 index 000000000..8dd2dd3be --- /dev/null +++ b/go/src/lib.rs @@ -0,0 +1,182 @@ +#![allow(clippy::too_many_arguments)] +// UniFFI's setup_scaffolding! macro emits raw FFI extern blocks which require unsafe. +#![allow(unsafe_code)] + +use std::sync::Arc; + +// UniFFI scaffolding — must match the [lib] name in Cargo.toml +uniffi::setup_scaffolding!("chia_wallet_sdk"); + +// Generate all bindings from the JSON schemas +bindy_macro::bindy_uniffi!("bindings.json"); + +// Hand-written alloc method for Clvm. +// The JSON schema marks alloc as stub_only because the argument type (ClvmType) +// requires manual dispatch. Here we implement it using the macro-generated ClvmType enum. +#[uniffi::export] +impl Clvm { + // Unlike the Python backend which accepts dynamic types (None, int, bool, str, bytes, list, tuple), + // the UniFFI backend uses a statically-typed ClvmType enum. To allocate nil/int/bool/string/bytes + // use the Clvm helper methods directly: nil(), int(), bool_(), string(), atom(), pair(), list(). + pub fn alloc(&self, value: ClvmType) -> Result, ChiaError> { + let result = match value { + ClvmType::Program { value } => value.0.clone(), + ClvmType::Pair { value } => self.0.pair(value.0.first.clone(), value.0.rest.clone())?, + ClvmType::CurriedProgram { value } => { + value.0.program.clone().curry(value.0.args.clone())? + } + ClvmType::PublicKey { value } => { + let bytes = value.0.to_bytes(); + self.0.atom(bytes.to_vec().into())? + } + ClvmType::Signature { value } => { + let bytes = value.0.to_bytes(); + self.0.atom(bytes.to_vec().into())? + } + ClvmType::K1PublicKey { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::K1Signature { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::R1PublicKey { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::R1Signature { value } => { + let bytes = value.0.to_bytes()?; + self.0.atom(bytes.into())? + } + ClvmType::Remark { value } => self.0.remark(value.0.rest.clone())?, + ClvmType::AggSigParent { value } => self + .0 + .agg_sig_parent(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigPuzzle { value } => self + .0 + .agg_sig_puzzle(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigAmount { value } => self + .0 + .agg_sig_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigPuzzleAmount { value } => self + .0 + .agg_sig_puzzle_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigParentAmount { value } => self + .0 + .agg_sig_parent_amount(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigParentPuzzle { value } => self + .0 + .agg_sig_parent_puzzle(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigUnsafe { value } => self + .0 + .agg_sig_unsafe(value.0.public_key, value.0.message.clone())?, + ClvmType::AggSigMe { value } => self + .0 + .agg_sig_me(value.0.public_key, value.0.message.clone())?, + ClvmType::CreateCoin { value } => { + self.0 + .create_coin(value.0.puzzle_hash, value.0.amount, value.0.memos.clone())? + } + ClvmType::ReserveFee { value } => self.0.reserve_fee(value.0.amount)?, + ClvmType::CreateCoinAnnouncement { value } => { + self.0.create_coin_announcement(value.0.message.clone())? + } + ClvmType::CreatePuzzleAnnouncement { value } => { + self.0.create_puzzle_announcement(value.0.message.clone())? + } + ClvmType::AssertCoinAnnouncement { value } => { + self.0.assert_coin_announcement(value.0.announcement_id)? + } + ClvmType::AssertPuzzleAnnouncement { value } => { + self.0.assert_puzzle_announcement(value.0.announcement_id)? + } + ClvmType::AssertConcurrentSpend { value } => { + self.0.assert_concurrent_spend(value.0.coin_id)? + } + ClvmType::AssertConcurrentPuzzle { value } => { + self.0.assert_concurrent_puzzle(value.0.puzzle_hash)? + } + ClvmType::AssertSecondsRelative { value } => { + self.0.assert_seconds_relative(value.0.seconds)? + } + ClvmType::AssertSecondsAbsolute { value } => { + self.0.assert_seconds_absolute(value.0.seconds)? + } + ClvmType::AssertHeightRelative { value } => { + self.0.assert_height_relative(value.0.height)? + } + ClvmType::AssertHeightAbsolute { value } => { + self.0.assert_height_absolute(value.0.height)? + } + ClvmType::AssertBeforeSecondsRelative { value } => { + self.0.assert_before_seconds_relative(value.0.seconds)? + } + ClvmType::AssertBeforeSecondsAbsolute { value } => { + self.0.assert_before_seconds_absolute(value.0.seconds)? + } + ClvmType::AssertBeforeHeightRelative { value } => { + self.0.assert_before_height_relative(value.0.height)? + } + ClvmType::AssertBeforeHeightAbsolute { value } => { + self.0.assert_before_height_absolute(value.0.height)? + } + ClvmType::AssertMyCoinId { value } => self.0.assert_my_coin_id(value.0.coin_id)?, + ClvmType::AssertMyParentId { value } => { + self.0.assert_my_parent_id(value.0.parent_id)? + } + ClvmType::AssertMyPuzzleHash { value } => { + self.0.assert_my_puzzle_hash(value.0.puzzle_hash)? + } + ClvmType::AssertMyAmount { value } => self.0.assert_my_amount(value.0.amount)?, + ClvmType::AssertMyBirthSeconds { value } => { + self.0.assert_my_birth_seconds(value.0.seconds)? + } + ClvmType::AssertMyBirthHeight { value } => { + self.0.assert_my_birth_height(value.0.height)? + } + ClvmType::AssertEphemeral { .. } => self.0.assert_ephemeral()?, + ClvmType::SendMessage { value } => { + self.0 + .send_message(value.0.mode, value.0.message.clone(), value.0.data.clone())? + } + ClvmType::ReceiveMessage { value } => self.0.receive_message( + value.0.mode, + value.0.message.clone(), + value.0.data.clone(), + )?, + ClvmType::Softfork { value } => self.0.softfork(value.0.cost, value.0.rest.clone())?, + ClvmType::MeltSingleton { .. } => self.0.melt_singleton()?, + ClvmType::TransferNft { value } => self.0.transfer_nft( + value.0.launcher_id, + value.0.trade_prices.clone(), + value.0.singleton_inner_puzzle_hash, + )?, + ClvmType::RunCatTail { value } => self + .0 + .run_cat_tail(value.0.program.clone(), value.0.solution.clone())?, + ClvmType::UpdateNftMetadata { value } => self.0.update_nft_metadata( + value.0.updater_puzzle_reveal.clone(), + value.0.updater_solution.clone(), + )?, + ClvmType::UpdateDataStoreMerkleRoot { value } => self + .0 + .update_data_store_merkle_root(value.0.new_merkle_root, value.0.memos.clone())?, + ClvmType::NftMetadata { value } => self.0.nft_metadata(value.0.clone())?, + ClvmType::MipsMemo { value } => self.0.mips_memo(value.0.clone())?, + ClvmType::InnerPuzzleMemo { value } => self.0.inner_puzzle_memo(value.0.clone())?, + ClvmType::RestrictionMemo { value } => self.0.restriction_memo(value.0.clone())?, + ClvmType::WrapperMemo { value } => self.0.wrapper_memo(value.0.clone())?, + ClvmType::Force1of2RestrictedVariableMemo { value } => self + .0 + .force_1_of_2_restricted_variable_memo(value.0.clone())?, + ClvmType::MemoKind { value } => self.0.memo_kind(value.0.clone())?, + ClvmType::MemberMemo { value } => self.0.member_memo(value.0.clone())?, + ClvmType::MofNMemo { value } => self.0.m_of_n_memo(value.0.clone())?, + ClvmType::OptionMetadata { value } => self.0.option_metadata(value.0)?, + ClvmType::NotarizedPayment { value } => self.0.notarized_payment(value.0.clone())?, + ClvmType::Payment { value } => self.0.payment(value.0.clone())?, + }; + Ok(Arc::new(Program(result))) + } +} diff --git a/go/tests/async_test.go b/go/tests/async_test.go new file mode 100644 index 000000000..072e8b1b1 --- /dev/null +++ b/go/tests/async_test.go @@ -0,0 +1,104 @@ +//go:build integration + +package tests + +import ( + "strings" + "testing" + + chia "github.com/xch-dev/chia-wallet-sdk/go/chia_wallet_sdk" +) + +func TestAsyncGetBlockchainState(t *testing.T) { + rpc, err := chia.RpcClientMainnet() + if err != nil { + if isNetworkError(err) { + t.Skipf("network unavailable: %v", err) + } + t.Fatal(err) + } + defer rpc.Destroy() + + response, err := rpc.GetBlockchainState() + if err != nil { + if isNetworkError(err) { + t.Skipf("network unavailable: %v", err) + } + t.Fatal(err) + } + defer response.Destroy() + + success, err := response.GetSuccess() + if err != nil { + t.Fatal(err) + } + if !success { + t.Error("GetBlockchainState returned success=false") + } + + state, err := response.GetBlockchainState() + if err != nil { + t.Fatal(err) + } + if state == nil || *state == nil { + t.Error("GetBlockchainState returned nil blockchain_state") + } +} + +func TestAsyncGetNetworkInfo(t *testing.T) { + rpc, err := chia.RpcClientMainnet() + if err != nil { + if isNetworkError(err) { + t.Skipf("network unavailable: %v", err) + } + t.Fatal(err) + } + defer rpc.Destroy() + + // Bound the whole request so a hung endpoint surfaces as a timeout (treated as + // a network skip below) instead of blocking the test forever. + requestTimeoutMs := uint32(30_000) + options, err := chia.NewRpcClientOptions(&requestTimeoutMs, nil) + if err != nil { + t.Fatal(err) + } + defer options.Destroy() + + rpcWithOptions, err := rpc.WithOptions(options) + if err != nil { + t.Fatal(err) + } + defer rpcWithOptions.Destroy() + + response, err := rpcWithOptions.GetNetworkInfo() + if err != nil { + if isNetworkError(err) { + t.Skipf("network unavailable: %v", err) + } + t.Fatal(err) + } + defer response.Destroy() + + success, err := response.GetSuccess() + if err != nil { + t.Fatal(err) + } + if !success { + t.Error("GetNetworkInfo returned success=false") + } + + name, err := response.GetNetworkName() + if err != nil { + t.Fatal(err) + } + if name == nil || *name == "" { + t.Error("GetNetworkInfo returned empty network_name") + } +} + +func isNetworkError(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "connect") || strings.Contains(msg, "timeout") || + strings.Contains(msg, "network") || strings.Contains(msg, "dns") || + strings.Contains(msg, "request") || strings.Contains(msg, "host") +} diff --git a/go/tests/bindings_test.go b/go/tests/bindings_test.go new file mode 100644 index 000000000..c5d9edd42 --- /dev/null +++ b/go/tests/bindings_test.go @@ -0,0 +1,576 @@ +package tests + +// Test suite mirrors dotnet/tests/BasicTests.cs (the canonical suite) so the Go, C#, +// C++, and Python bindings exercise equivalent behavior. + +import ( + "encoding/hex" + "strconv" + "testing" + + chia "github.com/xch-dev/chia-wallet-sdk/go/chia_wallet_sdk" +) + +func TestToHexFromHexRoundtrip(t *testing.T) { + bytes, err := chia.FromHex("ff") + if err != nil { + t.Fatal(err) + } + got, err := chia.ToHex(bytes) + if err != nil { + t.Fatal(err) + } + if got != "ff" { + t.Errorf("ToHex(FromHex) = %q, want %q", got, "ff") + } +} + +func TestBytesEqual(t *testing.T) { + eq, err := chia.BytesEqual([]byte{1, 2, 3}, []byte{1, 2, 3}) + if err != nil { + t.Fatal(err) + } + if !eq { + t.Error("BytesEqual([1 2 3], [1 2 3]) = false, want true") + } + + neq, err := chia.BytesEqual([]byte{1, 2, 3}, []byte{1, 2, 4}) + if err != nil { + t.Fatal(err) + } + if neq { + t.Error("BytesEqual([1 2 3], [1 2 4]) = true, want false") + } +} + +func TestCoinIdKnownValue(t *testing.T) { + parent, err := chia.FromHex("4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a") + if err != nil { + t.Fatal(err) + } + puzzleHash, err := chia.FromHex("dbc1b4c900ffe48d575b5da5c638040125f65db0fe3e24494b76ea986457d986") + if err != nil { + t.Fatal(err) + } + coin, err := chia.NewCoin(parent, puzzleHash, "100") + if err != nil { + t.Fatal(err) + } + defer coin.Destroy() + + coinId, err := coin.CoinId() + if err != nil { + t.Fatal(err) + } + const expected = "fd3e669c27be9d634fe79f1f7d7d8aaacc3597b855cffea1d708f4642f1d542a" + if got := hex.EncodeToString(coinId); got != expected { + t.Errorf("CoinId = %s, want %s", got, expected) + } +} + +func TestAtomRoundtrip(t *testing.T) { + clvm, err := chia.NewClvm() + if err != nil { + t.Fatal(err) + } + defer clvm.Destroy() + + expected := []byte{1, 2, 3} + program, err := clvm.Atom(expected) + if err != nil { + t.Fatal(err) + } + defer program.Destroy() + + atom, err := program.ToAtom() + if err != nil { + t.Fatal(err) + } + if atom == nil { + t.Fatal("ToAtom returned nil") + } + if hex.EncodeToString(*atom) != hex.EncodeToString(expected) { + t.Errorf("ToAtom = %v, want %v", *atom, expected) + } +} + +func TestStringRoundtrip(t *testing.T) { + clvm, err := chia.NewClvm() + if err != nil { + t.Fatal(err) + } + defer clvm.Destroy() + + const expected = "hello world" + program, err := clvm.Atom([]byte(expected)) + if err != nil { + t.Fatal(err) + } + defer program.Destroy() + + str, err := program.ToString() + if err != nil { + t.Fatal(err) + } + if str == nil { + t.Fatal("ToString returned nil") + } + if *str != expected { + t.Errorf("ToString = %q, want %q", *str, expected) + } +} + +func TestIntRoundtrip(t *testing.T) { + clvm, err := chia.NewClvm() + if err != nil { + t.Fatal(err) + } + defer clvm.Destroy() + + for _, value := range []string{"0", "1", "420", "-1", "-100", "67108863"} { + program, err := clvm.Int(value) + if err != nil { + t.Fatal(err) + } + got, err := program.ToInt() + program.Destroy() + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatalf("ToInt(%q) returned nil", value) + } + if *got != value { + t.Errorf("ToInt = %q, want %q", *got, value) + } + } +} + +func TestPairRoundtrip(t *testing.T) { + clvm, err := chia.NewClvm() + if err != nil { + t.Fatal(err) + } + defer clvm.Destroy() + + first, err := clvm.Int("1") + if err != nil { + t.Fatal(err) + } + defer first.Destroy() + rest, err := clvm.Int("100") + if err != nil { + t.Fatal(err) + } + defer rest.Destroy() + + pair, err := clvm.Pair(first, rest) + if err != nil { + t.Fatal(err) + } + defer pair.Destroy() + + result, err := pair.ToPair() + if err != nil { + t.Fatal(err) + } + if result == nil || *result == nil { + t.Fatal("ToPair returned nil") + } + p := *result + defer p.Destroy() + + firstProg, err := p.GetFirst() + if err != nil { + t.Fatal(err) + } + defer firstProg.Destroy() + firstInt, err := firstProg.ToInt() + if err != nil { + t.Fatal(err) + } + if firstInt == nil || *firstInt != "1" { + t.Errorf("pair first = %v, want 1", firstInt) + } + + restProg, err := p.GetRest() + if err != nil { + t.Fatal(err) + } + defer restProg.Destroy() + restInt, err := restProg.ToInt() + if err != nil { + t.Fatal(err) + } + if restInt == nil || *restInt != "100" { + t.Errorf("pair rest = %v, want 100", restInt) + } +} + +func TestPublicKeyRoundtrip(t *testing.T) { + original, err := chia.PublicKeyInfinity() + if err != nil { + t.Fatal(err) + } + defer original.Destroy() + + bytes, err := original.ToBytes() + if err != nil { + t.Fatal(err) + } + restored, err := chia.PublicKeyFromBytes(bytes) + if err != nil { + t.Fatal(err) + } + defer restored.Destroy() + + restoredBytes, err := restored.ToBytes() + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(bytes) != hex.EncodeToString(restoredBytes) { + t.Error("public key roundtrip mismatch") + } +} + +func TestClvmSerialization(t *testing.T) { + clvm, err := chia.NewClvm() + if err != nil { + t.Fatal(err) + } + defer clvm.Destroy() + + atom123, err := clvm.Atom([]byte{1, 2, 3}) + if err != nil { + t.Fatal(err) + } + defer atom123.Destroy() + int420, err := clvm.Int("420") + if err != nil { + t.Fatal(err) + } + defer int420.Destroy() + int100, err := clvm.Int("100") + if err != nil { + t.Fatal(err) + } + defer int100.Destroy() + pairAtomInt, err := func() (*chia.Program, error) { + a, err := clvm.Atom([]byte{1, 2, 3}) + if err != nil { + return nil, err + } + defer a.Destroy() + b, err := clvm.Int("100") + if err != nil { + return nil, err + } + defer b.Destroy() + return clvm.Pair(a, b) + }() + if err != nil { + t.Fatal(err) + } + defer pairAtomInt.Destroy() + + cases := []struct { + program *chia.Program + hex string + }{ + {atom123, "83010203"}, + {int420, "8201a4"}, + {int100, "64"}, + {pairAtomInt, "ff8301020364"}, + } + + for _, c := range cases { + serialized, err := c.program.Serialize() + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(serialized); got != c.hex { + t.Errorf("Serialize = %s, want %s", got, c.hex) + } + deserialized, err := clvm.Deserialize(serialized) + if err != nil { + t.Fatal(err) + } + origHash, err := c.program.TreeHash() + if err != nil { + t.Fatal(err) + } + deserHash, err := deserialized.TreeHash() + deserialized.Destroy() + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(origHash) != hex.EncodeToString(deserHash) { + t.Error("tree hash mismatch after roundtrip") + } + } +} + +func TestCurryRoundtrip(t *testing.T) { + clvm, err := chia.NewClvm() + if err != nil { + t.Fatal(err) + } + defer clvm.Destroy() + + items := make([]*chia.Program, 0, 10) + for i := 0; i < 10; i++ { + item, err := clvm.Int(strconv.Itoa(i)) + if err != nil { + t.Fatal(err) + } + defer item.Destroy() + items = append(items, item) + } + + nilProg, err := clvm.Nil() + if err != nil { + t.Fatal(err) + } + defer nilProg.Destroy() + + curried, err := nilProg.Curry(items) + if err != nil { + t.Fatal(err) + } + defer curried.Destroy() + + uncurried, err := curried.Uncurry() + if err != nil { + t.Fatal(err) + } + if uncurried == nil || *uncurried == nil { + t.Fatal("Uncurry returned nil") + } + uc := *uncurried + defer uc.Destroy() + + ucProgram, err := uc.GetProgram() + if err != nil { + t.Fatal(err) + } + defer ucProgram.Destroy() + nilHash, err := nilProg.TreeHash() + if err != nil { + t.Fatal(err) + } + ucHash, err := ucProgram.TreeHash() + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(nilHash) != hex.EncodeToString(ucHash) { + t.Error("uncurried program hash mismatch") + } + + args, err := uc.GetArgs() + if err != nil { + t.Fatal(err) + } + if len(args) != 10 { + t.Fatalf("uncurried args = %d, want 10", len(args)) + } + for i, arg := range args { + v, err := arg.ToInt() + arg.Destroy() + if err != nil { + t.Fatal(err) + } + if v == nil || *v != strconv.Itoa(i) { + t.Errorf("uncurried arg %d = %v, want %s", i, v, strconv.Itoa(i)) + } + } +} + +func TestAlloc(t *testing.T) { + clvm, err := chia.NewClvm() + if err != nil { + t.Fatal(err) + } + defer clvm.Destroy() + + nilProg, err := clvm.Nil() + if err != nil { + t.Fatal(err) + } + defer nilProg.Destroy() + + pk, err := chia.PublicKeyInfinity() + if err != nil { + t.Fatal(err) + } + pkProg, err := clvm.Alloc(chia.ClvmTypePublicKey{Value: pk}) + if err != nil { + t.Fatal(err) + } + defer pkProg.Destroy() + + helloProg, err := clvm.Atom([]byte("Hello, world!")) + if err != nil { + t.Fatal(err) + } + defer helloProg.Destroy() + + fortyTwo, err := clvm.Int("42") + if err != nil { + t.Fatal(err) + } + defer fortyTwo.Destroy() + + hundred, err := clvm.Int("100") + if err != nil { + t.Fatal(err) + } + defer hundred.Destroy() + + trueProg, err := clvm.Bool(true) + if err != nil { + t.Fatal(err) + } + defer trueProg.Destroy() + + atomProg, err := clvm.Atom([]byte{1, 2, 3}) + if err != nil { + t.Fatal(err) + } + defer atomProg.Destroy() + + zeroesProg, err := clvm.Atom(make([]byte, 32)) + if err != nil { + t.Fatal(err) + } + defer zeroesProg.Destroy() + + nil2, err := clvm.Nil() + if err != nil { + t.Fatal(err) + } + defer nil2.Destroy() + + nil3, err := clvm.Nil() + if err != nil { + t.Fatal(err) + } + defer nil3.Destroy() + + rcNil1, err := clvm.Nil() + if err != nil { + t.Fatal(err) + } + defer rcNil1.Destroy() + rcNil2, err := clvm.Nil() + if err != nil { + t.Fatal(err) + } + defer rcNil2.Destroy() + runCatTail, err := chia.NewRunCatTail(rcNil1, rcNil2) + if err != nil { + t.Fatal(err) + } + defer runCatTail.Destroy() + runCatTailProg, err := clvm.Alloc(chia.ClvmTypeRunCatTail{Value: runCatTail}) + if err != nil { + t.Fatal(err) + } + defer runCatTailProg.Destroy() + + program, err := clvm.List([]*chia.Program{ + nilProg, + pkProg, + helloProg, + fortyTwo, + hundred, + trueProg, + atomProg, + zeroesProg, + nil2, + nil3, + runCatTailProg, + }) + if err != nil { + t.Fatal(err) + } + defer program.Destroy() + + serialized, err := program.Serialize() + if err != nil { + t.Fatal(err) + } + + const expected = "ff80ffb0c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ff8d48656c6c6f2c20776f726c6421ff2aff64ff01ff83010203ffa00000000000000000000000000000000000000000000000000000000000000000ff80ff80ffff33ff80ff818fff80ff808080" + got := hex.EncodeToString(serialized) + if got != expected { + t.Errorf("serialization mismatch\ngot: %s\nwant: %s", got, expected) + } +} + +func TestCreateAndParseCondition(t *testing.T) { + clvm, err := chia.NewClvm() + if err != nil { + t.Fatal(err) + } + defer clvm.Destroy() + + puzzleHash := make([]byte, 32) + for i := range puzzleHash { + puzzleHash[i] = 0xff + } + + atom, err := clvm.Atom(puzzleHash) + if err != nil { + t.Fatal(err) + } + defer atom.Destroy() + memos, err := clvm.List([]*chia.Program{atom}) + if err != nil { + t.Fatal(err) + } + defer memos.Destroy() + + condition, err := clvm.CreateCoin(puzzleHash, "1", &memos) + if err != nil { + t.Fatal(err) + } + defer condition.Destroy() + + parsed, err := condition.ParseCreateCoin() + if err != nil { + t.Fatal(err) + } + if parsed == nil || *parsed == nil { + t.Fatal("ParseCreateCoin returned nil") + } + cc := *parsed + defer cc.Destroy() + + parsedPh, err := cc.GetPuzzleHash() + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(parsedPh) != hex.EncodeToString(puzzleHash) { + t.Error("parsed puzzle hash mismatch") + } + amount, err := cc.GetAmount() + if err != nil { + t.Fatal(err) + } + if amount != "1" { + t.Errorf("parsed amount = %q, want 1", amount) + } +} + +// itoa is a tiny helper to avoid importing strconv for a single use. +func itoa(i int) string { + if i == 0 { + return "0" + } + digits := "" + for i > 0 { + digits = string(rune('0'+i%10)) + digits + i /= 10 + } + return digits +} diff --git a/pyo3/chia_wallet_sdk.pyi b/pyo3/chia_wallet_sdk.pyi index f74eb2b3a..35dba7437 100644 --- a/pyo3/chia_wallet_sdk.pyi +++ b/pyo3/chia_wallet_sdk.pyi @@ -1220,6 +1220,8 @@ class Connector: class PeerOptions: def clone(self) -> PeerOptions: ... rate_limit_factor: float + connect_timeout_ms: Optional[int] + request_timeout_ms: Optional[int] def __init__(self) -> PeerOptions: ... class Peer: def clone(self) -> Peer: ... @@ -1881,6 +1883,11 @@ class RewardDistributor: def unstake(self, entrySlot: EntrySlot, lockedNft: Nft) -> RewardDistributorUnstakeResult: ... @staticmethod def locked_nft_hint(distributorLauncherId: bytes, custodyPuzzleHash: bytes) -> bytes: ... +class RpcClientOptions: + def clone(self) -> RpcClientOptions: ... + def __init__(self, requestTimeoutMs: Optional[int] = None, connectTimeoutMs: Optional[int] = None) -> None: ... + request_timeout_ms: Optional[int] + connect_timeout_ms: Optional[int] class RpcClient: def clone(self) -> RpcClient: ... def __init__(self, coinsetUrl: str) -> None: ... @@ -1892,6 +1899,7 @@ class RpcClient: def local(certBytes: bytes, keyBytes: bytes) -> RpcClient: ... @staticmethod def local_with_url(baseUrl: str, certBytes: bytes, keyBytes: bytes) -> RpcClient: ... + def with_options(self, options: RpcClientOptions) -> RpcClient: ... async def get_blockchain_state(self) -> Awaitable[BlockchainStateResponse]: ... async def get_additions_and_removals(self, headerHash: bytes) -> Awaitable[AdditionsAndRemovalsResponse]: ... async def get_block(self, headerHash: bytes) -> Awaitable[GetBlockResponse]: ... diff --git a/uniffi/build-and-test-all.sh b/uniffi/build-and-test-all.sh new file mode 100755 index 000000000..4b575d3c1 --- /dev/null +++ b/uniffi/build-and-test-all.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Builds and tests all uniffi language bindings on macOS arm64. +# Each binding is built via its local-build.sh, then its test suite is run. +set -euo pipefail + +cd .. + +REPO="$(cd "$(dirname "$0")" && pwd)" +TARGET="aarch64-apple-darwin" +PASS=0 +FAIL=0 + +header() { echo; echo "══════════════════════════════════════════"; echo " $*"; echo "══════════════════════════════════════════"; } +ok() { echo " ✓ $*"; PASS=$((PASS + 1)); } +fail() { echo " ✗ $*"; FAIL=$((FAIL + 1)); } + +# ── C# / dotnet ──────────────────────────────────────────────────────────────── +header "C# (dotnet)" + +echo "→ Building..." +if (cd "$REPO/dotnet" && bash local-build.sh -t "$TARGET"); then + ok "dotnet build" +else + fail "dotnet build" +fi + +echo "→ Running tests..." +if dotnet test "$REPO/dotnet/tests/ChiaWalletSdkTests.csproj" --nologo -v minimal --filter "Category!=Integration"; then + ok "dotnet tests" +else + fail "dotnet tests" +fi + +# ── Go ───────────────────────────────────────────────────────────────────────── +header "Go" + +echo "→ Building..." +if (cd "$REPO/go" && bash local-build.sh -t "$TARGET"); then + ok "go build" +else + fail "go build" +fi + +echo "→ Running tests..." +BINDINGS_DIR="$REPO/go/chia_wallet_sdk" +if (cd "$REPO/go" && \ + CGO_LDFLAGS="-L${BINDINGS_DIR} -lchia_wallet_sdk" \ + DYLD_LIBRARY_PATH="${BINDINGS_DIR}" \ + go test -v ./tests/...); then + ok "go tests" +else + fail "go tests" +fi + +# ── C++ ──────────────────────────────────────────────────────────────────────── +header "C++" + +echo "→ Building..." +if (cd "$REPO/cpp" && bash local-build.sh -t "$TARGET"); then + ok "cpp build" +else + fail "cpp build" +fi + +echo "→ Configuring CMake..." +CPP_BUILD="$REPO/cpp/tests/build" +rm -rf "$CPP_BUILD" +CMAKE_OK=0 +if cmake -S "$REPO/cpp/tests" -B "$CPP_BUILD" -DCMAKE_BUILD_TYPE=Release; then + ok "cmake configure" + CMAKE_OK=1 +else + fail "cmake configure" +fi + +if [[ $CMAKE_OK -eq 1 ]]; then + echo "→ Compiling tests..." + CMAKE_BUILD_OK=0 + if cmake --build "$CPP_BUILD"; then + ok "cmake build" + CMAKE_BUILD_OK=1 + else + fail "cmake build" + fi + + if [[ $CMAKE_BUILD_OK -eq 1 ]]; then + echo "→ Running tests..." + if ctest --test-dir "$CPP_BUILD" --output-on-failure --no-tests=error --label-exclude integration; then + ok "cpp tests" + else + fail "cpp tests" + fi + fi +fi + +# ── Summary ──────────────────────────────────────────────────────────────────── +header "Results" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo + +if [[ $FAIL -gt 0 ]]; then + echo "Some steps FAILED." + exit 1 +else + echo "All steps passed." +fi diff --git a/uniffi/build-and-test-async.sh b/uniffi/build-and-test-async.sh new file mode 100755 index 000000000..b2393bbea --- /dev/null +++ b/uniffi/build-and-test-async.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Builds and tests async tests for all uniffi language bindings on macOS arm64. +# Each binding is built via its local-build.sh, then only its async test suite is run. +set -euo pipefail + +cd .. + +REPO="$(cd "$(dirname "$0")" && pwd)" +TARGET="aarch64-apple-darwin" +PASS=0 +FAIL=0 + +header() { echo; echo "══════════════════════════════════════════"; echo " $*"; echo "══════════════════════════════════════════"; } +ok() { echo " ✓ $*"; PASS=$((PASS + 1)); } +fail() { echo " ✗ $*"; FAIL=$((FAIL + 1)); } + +# ── C# / dotnet ──────────────────────────────────────────────────────────────── +header "C# (dotnet)" + +echo "→ Building..." +if (cd "$REPO/dotnet" && bash local-build.sh -t "$TARGET"); then + ok "dotnet build" +else + fail "dotnet build" +fi + +echo "→ Running async tests..." +if dotnet test "$REPO/dotnet/tests/ChiaWalletSdkTests.csproj" --nologo -v minimal --filter "FullyQualifiedName~AsyncTests"; then + ok "dotnet async tests" +else + fail "dotnet async tests" +fi + +# ── Go ───────────────────────────────────────────────────────────────────────── +header "Go" + +echo "→ Building..." +if (cd "$REPO/go" && bash local-build.sh -t "$TARGET"); then + ok "go build" +else + fail "go build" +fi + +echo "→ Running async tests..." +BINDINGS_DIR="$REPO/go/chia_wallet_sdk" +if (cd "$REPO/go" && \ + CGO_LDFLAGS="-L${BINDINGS_DIR} -lchia_wallet_sdk" \ + DYLD_LIBRARY_PATH="${BINDINGS_DIR}" \ + go test -v -tags integration ./tests/...); then + ok "go async tests" +else + fail "go async tests" +fi + +# ── C++ ──────────────────────────────────────────────────────────────────────── +header "C++" + +echo "→ Building..." +if (cd "$REPO/cpp" && bash local-build.sh -t "$TARGET"); then + ok "cpp build" +else + fail "cpp build" +fi + +echo "→ Configuring CMake..." +CPP_BUILD="$REPO/cpp/tests/build" +rm -rf "$CPP_BUILD" +CMAKE_OK=0 +if cmake -S "$REPO/cpp/tests" -B "$CPP_BUILD" -DCMAKE_BUILD_TYPE=Release; then + ok "cmake configure" + CMAKE_OK=1 +else + fail "cmake configure" +fi + +if [[ $CMAKE_OK -eq 1 ]]; then + echo "→ Compiling tests..." + CMAKE_BUILD_OK=0 + if cmake --build "$CPP_BUILD"; then + ok "cmake build" + CMAKE_BUILD_OK=1 + else + fail "cmake build" + fi + + if [[ $CMAKE_BUILD_OK -eq 1 ]]; then + echo "→ Running async tests..." + if ctest --test-dir "$CPP_BUILD" --label-regex integration --output-on-failure --no-tests=error; then + ok "cpp async tests" + else + fail "cpp async tests" + fi + fi +fi + +# ── Summary ──────────────────────────────────────────────────────────────────── +header "Results" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo + +if [[ $FAIL -gt 0 ]]; then + echo "Some steps FAILED." + exit 1 +else + echo "All steps passed." +fi