eigerco / uniffi-zcash-lib

The UniFFI-translated librustzcash library
MIT License
16 stars 4 forks source link

Generate redistributable binaries for foreign languages #51

Closed zduny closed 1 year ago

zduny commented 1 year ago
eloylp commented 1 year ago

Alright, taking over this. Initially i thought in doing a bash script for generating the bindings for the different languages. But after thinking about it, i think i am more for creating a little CLI in Rust that wraps all the repo workflows, exposing the minimum information to users. Nothing against bash scripts. I think in general they act as good glue code, but when the code starts getting more sophisticated, i consider difficult to do error handling or retries as examples. We are also going to have access to all libraries in the ecosystem.

I see 2 main goals here:

  1. The first one, is to produce the bindings for each target language.
  2. With the outcome of 1 , see how to pack and distirbute the bindings to each language registry.

Lets start with 1 first and see how it goes !

eloylp commented 1 year ago

We have some progress on this. We have a CLI prototype at 2b4f5f3ddbcb1eba1ad54019bfb0ae3d86de48fd which is able to generate the bindings + the shared library putting all of that together in one place. So by executing:

cargo run -p uniffi-zcash-cli generate

We obtain:

image

By the way, i still need to check some of the output paths and create a small library on each language to see errors and more specific needs for each language. Also, check ergonomics and how to improve them.

eloylp commented 1 year ago

More progress on this, lets check for each language how the investigation about using the bindings is going. Before start, just a little remembering that all the outputs listed here can be obtained by making use of our custom CLI:

cargo run -p uniffi-zcash-cli generate 

Python

Our current CLI generates the following files in the bindings/python folder of the project:

|── python
│   ├── libuniffi_zcash.so
│   └── zcash.py

One can simply add the following app example main.py to that folder and execute it:

from zcash import *

def get_amount():
    return ZcashAmount(100)

if __name__ == "__main__":

    assert get_amount().value() == 100
    print("OK!")
$ python main.py
OK!

Ruby

Our current CLI generates the following files in the bindings/ruby folder of the project:

├── ruby
│   ├── libuniffi_zcash.so
│   └── zcash.rb

One can simply add the following app example to that folder and execute it:

require "./zcash.rb"

def get_amount
    Zcash::ZcashAmount.new(100)
end

raise "Error !" unless get_amount().value() == 100

print "OK!\n"
$ ruby main.rb
OK!

For this language, the following packages were needed:

$ sudo apt-get install ruby-devel
$ gem install ffi rubocop

Apart from the above, the following configuration was added to the uniffi.toml file (Mozilla UniFFI bindgen config) of the project, so the ruby bindings can locally find the shared library:

[bindings.ruby]
cdylib_name = "./libuniffi_zcash.so"

Kotlin

Our current CLI generates the following files in the bindings/kotlin folder of the project:

├── kotlin
│   └── uniffi
│       └── zcash
│           ├── jna.jar
│           ├── libuniffi_zcash.so
│           └── zcash.kt

We could make use of the bindings by, as an example, adding the following app example in a main.kt file :

import uniffi.zcash.*

fun main() {
    assert(getAmount().value() == 100.toLong())
    println("OK!")
}

fun getAmount(): ZcashAmount {
    return ZcashAmount(100)
}

Then we can compile and execute it by:

## Compile
$ kotlinc -cp jna.jar test.kt main.kt -include-runtime -d app.jar

## Execute
$ kotlin -classpath ./app.jar:./jna.jar:. MainKt

OK!

Note that we need to include the Java Native Access library in the classpath. Currently we are providing the jar file, but the user could download the original version here. We also include the libuniffi_zcash.so file to the classpath by including the local folder . . Of course, this would not be the most common use case. Other project building tools like gradle are expected to be used.

Swift

Our current CLI generates the following files in the bindings/swift folder of the project:

├── swift
│   ├── libuniffi_zcash.dylib
│   ├── libuniffi_zcash.so
│   ├── zcashFFI.h
│   ├── zcashFFI.modulemap
│   ├── zcash.swift
│   └── zcash.swiftmodule

Swift needs a swift module (zcash.swiftmodule ) in order to be able to import our bindings in swift applications. In order to generate such file, we automated the official docs steps in our CLI. Now we are trying to discover how to import that swift module in a sample application. A work in progress.

Anyway, there is more official documentation regarding swift we could use:

Other considerations, questions and next steps

Lets keep in mind that the libuniffi_zcash.so file currently weights around 60 MB. This is due to the Sapling crypto trusted setup params, which are currently included in the shared lib for facilitating life to the user. The alternative would be to look in local paths for files that contains such parameters. I think until this supposes a problem, we can live with it.

As next steps, i think good ones would be:

eloylp commented 1 year ago

Alright ! we have a CLI tool that automatically generates all bindings for us by invoking the generate subcommand. Now we are in a good position to try uploading such outcome to the relevant package registries. I think a good strategy is to try things manually first, for all the languages and then, add a new publish subcommand to the CLI.

So lets start streaming here the investigation per each language ... :fast_forward:

eloylp commented 1 year ago

Artifacts upload with Python

For python, we need to transform first the current bindings folder structure in something like this:

.
├── README.md
├── setup.py
└── zcash
    ├── __init__.py
    ├── libuniffi_zcash.so
    └── zcash.py

Lets do a walk-through through each file :point_up:

First, lets note we created a subfolder called zcash that will represent our zcash module, moving there all the previously generated source files libuniffi_zcash.so and zcash.py.

In Python, the module discovery system works by placing an __init__.py file inside each module. Such file also works as initialisation step. Here is the current content of such file:

from .zcash import *

Then, we also need a setup.py file, that will instruct the python tooling how to deal with this package. It will also allow us to specify that we have a shared library we also want to upload (libuniffi_zcash.so) along with the source code. Here is the content of the file, left some comments on each field:

import setuptools

with open("README.md", "r") as fh:
    long_description = fh.read()

setuptools.setup(
    name="test-uniffi-zcash",  ## We are just testing right now in TestPy package registry
    version="0.0.1",   ## This probably needs to be parameterised in the CLI 
    author="test",
    description="Zcash test Package",
    long_description=long_description,
    long_description_content_type="text/markdown",
    packages=["zcash"],
    classifiers=[
        "Intended Audience :: Developers",
        "Topic :: Software Development :: Libraries",
        "Programming Language :: Python",
        "Programming Language :: C"
    ],                                     
    package_data={"zcash": ["libuniffi_zcash.so"]}  ## Include the shared library !! 50MB actually :D
)

Before uploading the package, lets ensure we have all the tools up to date:

$ python -m pip install –-user –-upgrade setuptools wheel

And also install twine, a tool that helps with the upload :

$ python -m pip install --user --upgrade twine

Now we are ready to publish our package. Lets do it indicating we want to use the python test package index this time and the previously generated (by python tools) dist folder:

$ python -m twine upload --repository testpypi dist/*

Enter your username: eloylp_eiger
Enter your password: 
Uploading test_uniffi_zcash-0.0.1-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 54.3/54.3 MB • 00:15 • 3.9 MB/s
WARNING  Received "503: Service Unavailable"                                                                              
         Package upload appears to have failed. Retry 1 of 5.                                                             
Uploading test_uniffi_zcash-0.0.1-py3-none-any.whl
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 54.3/54.3 MB • 00:08 • 6.2 MB/s
Uploading test-uniffi-zcash-0.0.1.tar.gz
100% ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 54.3/54.3 MB • 00:08 • 6.3 MB/s

View at:
https://test.pypi.org/project/test-uniffi-zcash/0.0.1/

Whoa ! we can observe that twine also manages retries, which is nice :heart_eyes: . Our package is already published: image

Lets just make an observation regarding upload process. We probably need to use API keys instead on interactive security challenge user/pass .

Lets now install the package from pypi:

$ pip install -i https://test.pypi.org/simple/ test-uniffi-zcash==0.0.1

We can observe how all the stuff, shared library plus bindings code are downloaded (~50MB): image

Now, the final test, lets execute some code on the REPL ! :snake:

$ python
>>> from zcash import *
>>> amount = ZcashAmount(100)
>>> amount.value()
100

Yay ! :partying_face: we have our first, python librustzcash version. Lets continue with the rest of languages.

eloylp commented 1 year ago

Artifacts upload with Ruby

In ruby we need to re-organise our previous outcome to accomplish the following dir structure:

├── lib
│   ├── libuniffi_zcash.so
│   └── zcash.rb
├── README.md
└── zcash.gemspec

The lib folder will hold the previous output, which is the shared lib plus the bindings. Its worth mentioning that we needed to change the uniffi.toml configuration to specify the path in which the shared lib should be looked up:

[bindings.ruby]
cdylib_name = "lib/libuniffi_zcash.so"

Then we have the zcash.gemspec , that defines our gem and looks like this:

Gem::Specification.new do |s|
  s.name        = "zcash"
  s.version     = "0.0.1"
  s.summary     = "The librustzcash ruby FFI binding"
  s.description = "A library for interacting with the librustzcash lib, a privacy oriented cryptocurrency"
  s.authors     = ["test"]
  s.email       = "test@test.com"
  s.add_runtime_dependency 'ffi', '1.15.5'                        # Here we require the ffi gem.
  s.files       = ["lib/zcash.rb", "lib/libuniffi_zcash.so"]       # Adding the shared library and the bindings.
  s.homepage    = "https://github.com/eigerco/uniffi-zcash-lib"
  s.license       = "MIT"
end

Its really great the way gems work. Once we have defined the gemspec, we can just build our gem with:

$ gem build zcash.gemspec

Successfully built RubyGem
  Name: zcash
  Version: 0.0.1
  File: zcash-0.0.1.gem

The above will generate the zcash-0.0.1.gem gem file we can just install by:

$ gem install ./zcash-0.0.1.gem 

Time to test in the REPL ! :diamonds:

$ irb
irb(main):001:0> require "zcash"
=> true
irb(main):002:0> amount = Zcash::ZcashAmount.new(100)
=> 
#<Zcash::ZcashAmount:0x00007f37c8016bf0                        
...                                                            
irb(main):003:0> amount.value()
=> 100

Yay ! it works. Lets now check to upload the gem to a local gem server. The docs specify a great way. First we need to install the software:

gem install geminabox
gem install puma
mkdir data  ## a directory for storing gems by the server.

Then create a config file config.ru :

require "rubygems"
require "geminabox"

Geminabox.data = "./data"
run Geminabox::Server

Lets now bring up this guy by:

$ rackup
Puma starting in single mode...
* Puma version: 6.2.2 (ruby 3.1.4-p223) ("Speaking of Now")
*  Min threads: 0
*  Max threads: 5
*  Environment: development
*          PID: 525969
* Listening on http://127.0.0.1:9292
* Listening on http://[::1]:9292
Use Ctrl-C to stop

Great we have it running. Lets now just add it to our gem sources by:

$ gem sources -a http://localhost:9292/

Now we can install a fresh zcash gem from our gem server by:

$ gem install zcash -v "0.0.1"

Fetching ffi-1.15.5.gem
Building native extensions. This could take a while...
Successfully installed ffi-1.15.5
Successfully installed zcash-0.0.1
Parsing documentation for ffi-1.15.5
Installing ri documentation for ffi-1.15.5
Parsing documentation for zcash-0.0.1
Installing ri documentation for zcash-0.0.1
Done installing documentation for ffi, zcash after 4 seconds
2 gems installed

Awesome, we can also observe how the ffi gem is also installed. Lets repeat the REPL exercise once again for doing the last check:

$ irb
irb(main):001:0> require "zcash"
=> true
irb(main):002:0> amount = Zcash::ZcashAmount.new(100)
=> 
#<Zcash::ZcashAmount:0x00007f37c8016bf0                        
...                                                            
irb(main):003:0> amount.value()
=> 100

Alright ! we would be ready to go with ruby. Lets just take into account, we are going to need some kind of API authentication when dealing with the rubygems.org gem server.

Thats it, i would like to mention the great work this docs are. Practically i did not need to go to other sources to accomplish this investigation ! Fantastic.

eloylp commented 1 year ago

A random thought: Even if some of the artifact upload tools (as we observed) provides retries, i think we should implement our owns. I do not think all of them will implement retries.

eloylp commented 1 year ago

Artifacts upload with Kotlin

We used the gradle init command to create a Kotlin library project scaffolding. We adapted it, and the final result is the following one:

.
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── lib
│   ├── build.gradle.kts
│   ├── libs
│   │   └── libuniffi_zcash.so
│   └── src
│       └── main
│           └── kotlin
│               └── zcash
│                   └── zcash.kt
└── settings.gradle.kts

Most of the files there are generated by the gradlew init command, except:

Lets now see the relevant modifications done in settings.gradle.kts:

rootProject.name = "zcash"
include("lib")

And also, the most important part, the build.gradle.kts, left some comments on the relevant parts:

plugins {
    id("org.jetbrains.kotlin.jvm") version "1.8.10"

    `java-library`
    // Added the maven-publish plugin
    `maven-publish`
}

repositories {
   // Use Maven Central for resolving dependencies.
   mavenCentral()   
   // Use local repository for testing
   mavenLocal()
}

dependencies {
  // Add the JNA. No need to manually include the jar: https://github.com/java-native-access/jna
  implementation("net.java.dev.jna:jna:5.8.0")
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(17))
    }
}

// Inlude the .so files in the jar. Our shared library is there.
val libsDir = File("libs")
tasks.withType<Jar> {
    from(libsDir) { include("**/*.so") }
}

publishing {
   publications {
    create<MavenPublication>("mavenJava") {
            from(components["java"])
            pom {
        // Artifact coordinates and info. To be setup for production.
                artifactId = "zcash"
        groupId = "uniffi"
        version = "0.0.1"
                description.set("The librustzcash Kotlin FFI binding")
                url.set("https://github.com/eigerco/uniffi-zcash-lib")
                licenses {
                    license {
                        name.set("The MIT License")
                        url.set("https://github.com/eigerco/uniffi-zcash-lib/blob/main/LICENSE")
                    }
                }
           }
    }
   }
   repositories {
     // Below auth config should be adjusted/used for production publishing.
     maven {
        url = uri("https://example.com/repository/maven")
        credentials {
            username = "token" // Use "token" as the username for API token authentication
            password = System.getenv("MAVEN_REPO_API_TOKEN")
        }
     }
  }
}

With the above configuration, we managed to include the shared library along with all the code in the same jar file.

Maven works with a local repository (under the path $HOME/.m2) to which we can publish our library for testing purposes. From the root of the library working dir:

$ gradle publishToMavenLocal

BUILD SUCCESSFUL in 7s
6 actionable tasks: 6 executed

Now , we could create another project with gradle init (this time, application type) and try to import and use our library. After creation, we need to include the dependencies in the application build.gradle.kts:

plugins {
    id("org.jetbrains.kotlin.jvm") version "1.8.10"

    application
}

repositories {
    // Use Maven Central for resolving dependencies.
    mavenCentral()
    // Use Maven local in order to find our previously built library.  <-----------------------------------------
    mavenLocal() 
}

dependencies {
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")

    testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.1")

    implementation("com.google.guava:guava:31.1-jre")

    implementation("uniffi:zcash:0.0.1")  // <-------------------------- Here, requiring our zcash library.
}

// Apply a specific Java toolchain to ease working on different environments.
java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(17))
    }
}

application {
    mainClass.set("test.kotlin.AppKt")
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}

Lets now modify the main function in App.kt to use our zcash code:

package test.kotlin
import uniffi.zcash.*

fun main() {
    val amount = ZcashAmount(100)
    println(amount.value())
}

Now we can just run it y going to the root of the application project and executing:

$ gradle run

> Task :app:run
100

BUILD SUCCESSFUL in 776ms
4 actionable tasks: 1 executed, 3 up-to-date

So that's it. We have tested our application. I think we can assume this will work the same way if pushed to a remote maven repo. The only difference would be the network. I wasn't able to find information about maximum upload size in maven, we will see.

Again, all the package metadata should be properly configured before shipping this on production. Like licenses, package names, initial version, auth data etc ...

eloylp commented 1 year ago

Artifacts upload with Swift

Swift comes with another different perspective regarding packages. Each package can have any number of modules. The folder structure is used in a meaningful way by the Swift Package Manager (SPM).

Our strategy will be to create a Zcash package, that will hold 2 modules:

The formal command to start creating library packages in swift is:

swift package init --type library

Then just follow the instructions for creating a Zcash package. The final directory structure must be the following one:

.
├── Package.swift ## Created by us.
├── Sources
│   ├── Zcash
│   │   └── zcash.swift ## part of UniFFI output
│   └── zcashFFI
│       ├── libuniffi_zcash.so ## part of UniFFI output.
│       ├── module.modulemap   ## part of UniFFI output, but adapted to our needs.
│       └── uniffi_zcash.h     ## part of UniFFI output.
└── Tests
    └── ZcashTests
        └── ZcashTests.swift ## Created by swift, adapted by us.

The Package.swift file defines the package itself and all the inter-dependencies among the internal modules:

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "Zcash",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "Zcash",
            type: .static,
            targets: ["Zcash"]
        )
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .systemLibrary(name: "zcashFFI"),
        .target(name: "Zcash", dependencies: ["zcashFFI"]),
        .testTarget(
            name: "ZcashTests",
            dependencies: ["Zcash"]
        )
    ]
)

The next interesting file is the module.modulemap file. This file comes from LLVM clang frontend. It helps us to map our libuniffi_zcash.so shared library to a swift module. That's it ! swift makes this very easy. It will automatically map all symbols to swift native code that could be called by an application. But of course, we are not going to use such raw nomenclature, as we will wrap such thing with a higher level layer, that is, our zcash.swift UniFFI generated file.

Here is the content of the module.modulemap file:

module zcashFFI [system] {
    header "uniffi_zcash.h"
    link "uniffi_zcash"
    export *
}

We are basically saying "Use this local uniffi_zcash.h header file and link the libuniffi_zcash.so shared library, exporting all the symbols at the scope of this zcashFFI module".

Then, in order to test this library, we added the ZcashTests.swift file:

import XCTest
@testable import Zcash

final class ZcashTests: XCTestCase {
    func testAmount() throws {
        let amount = try! ZcashAmount(amount: 200)
        assert(200 == amount.value())
    }
}

Executing the above test will properly exercise all the elements and give us the confidence that the entire generation chain is working properly (this probably should be done in all languages, and make it part of the CI). In order to execute this test, lets move to our library root, and execute:

$ LD_LIBRARY_PATH=$(pwd)/Sources/zcashFFI swift test -Xlinker -L$(pwd)/Sources/zcashFFI

That's good. We have our library. But how to use it ? Swift is very friendly with Git. That means we can import other Git hosted libraries as direct dependencies in our projects. So lets init a git repo in the root of our package:

$ git init
$ git add .
$ git commit -m "initial commit"
$ git tag 0.0.1

This Package could be easily pushed to a git server and later imported in any application. For demonstration, we are going to create an example application that will import our package by following a filesystem URL to our git repo. Lets start creating our App executable package by:

swift package init --type executable

Now we have the following structure:

.
├── Package.swift
└── Sources
    └── main.swift

The Package.swift should look like this one:

// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "App",
    dependencies: [
        .package(url: "../Zcash", from: "0.0.1") // Specify our local Git depedency.
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .executableTarget(
            name: "App",
            dependencies: ["Zcash"], // Import our Zcash module.
            path: "Sources"
        )
    ]
)

An of course, our main.swift file:

// The Swift Programming Language
// https://docs.swift.org/swift-book

import Zcash

let amount = try! ZcashAmount(amount: 100)

print(amount.value())

Now, lets run our application by executing this command from the root of our App package:

$ LD_LIBRARY_PATH=$(pwd)/../Zcash/Sources/zcashFFI swift run -Xlinker -L$(pwd)/../Zcash/Sources/zcashFFI
Building for debugging...
Build complete! (0.16s)
100

For building the production binary, the following command can be executed:

$ LD_LIBRARY_PATH=$(pwd)/../Zcash/Sources/zcashFFI swift build -c release -Xlinker -L$(pwd)/../Zcash/Sources/zcashFFI

Note we are passing the -c release, that will optimize our application. The above will leave the binary in the relative path .build/release/App. Then we can execute it by:

$ cd .build/release
$ LD_LIBRARY_PATH=$(pwd)/../../../../Zcash/Sources/zcashFFI ./App
100

If want to avoid external runtime dependencies and have a statically linked binary, we could try to follow this steps. I am currently getting linker errors, but probably due to my setup. Will edit this comment once i fix it.

Regarding publishing to a real package registry, here we can find all the information here.

It was a bit hard to research the swift part. So leaving here the relevant links that helped me during the entire process:

eloylp commented 1 year ago

We gained a lot of knowledge during this investigation about packaging solutions for:

Such knowledge should serve us to continue with the next step: Implement such steps in our CLI for an automated release on the relevant package registries. Later on, we will use this CLI from our CI. Some considerations regarding implementation:

Happy to hear feedback from you for any of the points ! Lets continue with the plan in the meanwhile.

eloylp commented 1 year ago

More progress on this. An intensive work is being done on this PR regarding the CLI. Most of it is ready at this moment. Lets do a walk-through:

Current CLI architecture

The CLI was splitted in the following sub-commands, which need to respect the following order of execution:

  1. bindgen - This command calls all the needed UniFFI machinery for generating the language bindings. It invokes the UniFFI tools under the hood, passing our desired values by default. The outcome of this command is the lib/bindings git ignored folder, which contains the bindings.

  2. release - It accepts a version argument among others. This command does not push the artifacts yet. It only prepares them by using a little, in house project template system. This system has predefined projects structures for the different languages, which later are parametrized with a text template engine. It also copies the needed files from the previous command outcome at lib/bindings. The outcome of this command is placed at the lib/packages git ignored folder. It contains the packages ready to be published. This packages are also automatically tested against little sample applications. This sample applications just import the artifact as an user would do, checking the entire import chain/dynamic library loading is not broken.

  3. publish - This is the last step and where the development/testing is completely focused right now. The aim is to just execute a single publish command per each language, for finally pushing the previous generated packages at lib/packages. It will implement a little retry logic in order to reduce friction with intermittent problems in the CI. We want to avoid partial releases. i.e One language package registry fails, but others are OK.

Why this CLI architecture ?

Its a layered, multi-stage architecture that will bring the following benefits:

Why so much insistence on including the so files in packages ?

Considerations, rough edges during development

Some hardening was done in the CLI on last commits. There are still some "uncomfortable" pieces:

Current, next final steps

eloylp commented 1 year ago

Okay, we have some final conclusions for this issue:

I think all the goals listed on this issue have been achieved. Moving to its final review ! :fast_forward:

@MeerKatDev

MeerKatDev commented 1 year ago

I think this is done @eloylp we can close this

eloylp commented 1 year ago

I think this is done @eloylp we can close this

@MeerKatDev thanks ! just need approvals for this one first: https://github.com/eigerco/uniffi-zcash-lib/pull/124