diff --git a/.codespellrc b/.codespellrc index 092990774..448b595b0 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,3 +1,3 @@ [codespell] -skip = .git,target,Cargo.toml,Cargo.lock,Cargo-minimal.lock,Cargo-recent.lock +skip = .git,target,Cargo.toml,Cargo.lock,Cargo-minimal.lock,Cargo-recent.lock,package-lock.json ignore-words-list = crate,ser,ot diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml new file mode 100644 index 000000000..fb4f36be4 --- /dev/null +++ b/.github/workflows/javascript.yml @@ -0,0 +1,50 @@ +name: Build and Test JavaScript +on: + pull_request: + paths: + - payjoin-ffi/** +env: + # Override the value from the rust-toolchain file + # This is necessary because even though the correct toolchain + # is explicitly specified for the rust-toolchain action, + # rustup honors the rust-toolchain file over the default + RUSTUP_TOOLCHAIN: 1.85 + +jobs: + build-js-and-test: + name: "Build and test javascript" + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: payjoin-ffi/javascript + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Rust 1.85.0 + uses: dtolnay/rust-toolchain@1.85.0 + + - name: "Use cache" + uses: Swatinem/rust-cache@v2 + + - name: Install Node + uses: actions/setup-node@v6 + + - name: Install llvm + if: matrix.os == 'macos-latest' + run: brew install llvm + + - name: Install wasm-bindgen + run: cargo install wasm-bindgen-cli + + - name: "Install dependencies" + run: npm ci + + - name: Generate bindings and binaries + run: bash ./scripts/generate_bindings.sh + + - name: Run tests + run: npm test diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index 6646ac78f..ea592572f 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -2397,6 +2397,7 @@ dependencies = [ "tokio", "tracing", "url", + "web-time", ] [[package]] @@ -2469,6 +2470,7 @@ dependencies = [ "bdk", "bitcoin-ffi", "bitcoin-ohttp", + "getrandom 0.2.15", "hex", "lazy_static", "payjoin", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index 6646ac78f..ea592572f 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -2397,6 +2397,7 @@ dependencies = [ "tokio", "tracing", "url", + "web-time", ] [[package]] @@ -2469,6 +2470,7 @@ dependencies = [ "bdk", "bitcoin-ffi", "bitcoin-ohttp", + "getrandom 0.2.15", "hex", "lazy_static", "payjoin", diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 8a4e6d5f1..16b413482 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -6,8 +6,12 @@ license = "MIT OR Apache-2.0" exclude = ["tests"] [features] -_test-utils = ["payjoin-test-utils", "tokio"] +default = ["io"] +dart = ["dep:uniffi-dart"] +io = ["payjoin/io"] +_test-utils = ["payjoin-test-utils", "tokio", "io"] _manual-tls = ["payjoin/_manual-tls"] +wasm_js = ["getrandom/js"] [lib] name = "payjoin_ffi" @@ -19,22 +23,23 @@ path = "uniffi-bindgen.rs" [build-dependencies] uniffi = { version = "0.29.4", features = ["build", "cli"] } -uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "04f0007", features = ["build"] } +uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "04f0007", features = ["build"], optional = true } [dependencies] base64 = "0.22.1" bitcoin-ffi = { git = "https://github.com/benalleng/bitcoin-ffi.git", rev = "8e3a23b" } +getrandom = "0.2" hex = "0.4.3" lazy_static = "1.5.0" ohttp = { package = "bitcoin-ohttp", version = "0.6.0" } -payjoin = { version = "1.0.0-rc.1", features = ["v1", "v2", "io"] } +payjoin = { version = "1.0.0-rc.1", features = ["v1", "v2"] } payjoin-test-utils = { version = "0.0.1", optional = true } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.142" thiserror = "2.0.14" tokio = { version = "1.47.1", features = ["full"], optional = true } uniffi = { version = "0.29.4" } -uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "04f0007" } +uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "04f0007", optional = true } url = "2.5.4" [dev-dependencies] diff --git a/payjoin-ffi/README.md b/payjoin-ffi/README.md index 7d6aebeee..93e0aaaff 100644 --- a/payjoin-ffi/README.md +++ b/payjoin-ffi/README.md @@ -14,10 +14,11 @@ The directories below include instructions for using, building, and publishing t |----------|-----------------------|-------------------|------------------------------------| | Python | linux, macOS | [payjoin-ffi/python](python) | [payjoin](https://pypi.org/project/payjoin/) | | Dart | linux, macOS | [payjoin-ffi/dart](dart) | N/A | +| JavaScript | linux, macOS | [payjoin-ffi/javascript](javascript) | N/A | ## Minimum Supported Rust Version (MSRV) -This library should compile with any combination of features with Rust 1.78.0. +This library should compile with any combination of features with Rust 1.85.0. ## References diff --git a/payjoin-ffi/build.rs b/payjoin-ffi/build.rs index b1672ee4b..3b7f8ba06 100644 --- a/payjoin-ffi/build.rs +++ b/payjoin-ffi/build.rs @@ -1,4 +1,5 @@ fn main() { uniffi::generate_scaffolding("src/payjoin_ffi.udl").unwrap(); + #[cfg(feature = "dart")] uniffi_dart::generate_scaffolding("src/payjoin_ffi.udl".into()).unwrap(); } diff --git a/payjoin-ffi/dart/README.md b/payjoin-ffi/dart/README.md index 7b7dc7853..6fa63c37b 100644 --- a/payjoin-ffi/dart/README.md +++ b/payjoin-ffi/dart/README.md @@ -11,8 +11,8 @@ Follow these steps to clone the repository and run the tests. git clone https://github.com/payjoin/rust-payjoin.git cd rust-payjoin/payjoin-ffi/dart -# Generate the bindings (use the script appropriate for your platform) -bash ./scripts/generate_.sh +# Generate the bindings +bash ./scripts/generate_bindings.sh # Run all tests dart test diff --git a/payjoin-ffi/dart/scripts/generate_bindings.sh b/payjoin-ffi/dart/scripts/generate_bindings.sh index d35333463..03003f6fb 100755 --- a/payjoin-ffi/dart/scripts/generate_bindings.sh +++ b/payjoin-ffi/dart/scripts/generate_bindings.sh @@ -19,15 +19,15 @@ fi cd ../ echo "Generating payjoin dart..." -cargo build --features _test-utils --profile dev -cargo run --features _test-utils --profile dev --bin uniffi-bindgen -- --library ../target/debug/$LIBNAME --language dart --out-dir dart/lib/ +cargo build --features dart,_test-utils --profile dev +cargo run --features dart,_test-utils --profile dev --bin uniffi-bindgen -- --library ../target/debug/$LIBNAME --language dart --out-dir dart/lib/ if [[ "$OS" == "Darwin" ]]; then echo "Generating native binaries..." rustup target add aarch64-apple-darwin x86_64-apple-darwin # This is a test script the actual release should not include the test utils feature - cargo build --profile dev --target aarch64-apple-darwin --features _test-utils & - cargo build --profile dev --target x86_64-apple-darwin --features _test-utils & + cargo build --profile dev --target aarch64-apple-darwin --features dart,_test-utils & + cargo build --profile dev --target x86_64-apple-darwin --features dart,_test-utils & wait echo "Building macos fat library" @@ -38,7 +38,7 @@ else echo "Generating native binaries..." rustup target add x86_64-unknown-linux-gnu # This is a test script the actual release should not include the test utils feature - cargo build --profile dev --target x86_64-unknown-linux-gnu --features _test-utils + cargo build --profile dev --target x86_64-unknown-linux-gnu --features dart,_test-utils echo "Copying payjoin_ffi binary" cp ../target/x86_64-unknown-linux-gnu/debug/$LIBNAME dart/$LIBNAME diff --git a/payjoin-ffi/javascript/.gitignore b/payjoin-ffi/javascript/.gitignore new file mode 100644 index 000000000..0762b5e3c --- /dev/null +++ b/payjoin-ffi/javascript/.gitignore @@ -0,0 +1,11 @@ +# Build outputs +dist/ +node_modules/ + +# Generated by uniffi-bindgen-react-native +rust_modules/ +src/generated/ + +# Generated by napi-rs (test-utils) +test-utils/*.node +test-utils/index.d.ts diff --git a/payjoin-ffi/javascript/.prettierrc b/payjoin-ffi/javascript/.prettierrc new file mode 100644 index 000000000..21827e20f --- /dev/null +++ b/payjoin-ffi/javascript/.prettierrc @@ -0,0 +1,3 @@ +{ + tabWidth: 4 +} diff --git a/payjoin-ffi/javascript/README.md b/payjoin-ffi/javascript/README.md new file mode 100644 index 000000000..0a19082a6 --- /dev/null +++ b/payjoin-ffi/javascript/README.md @@ -0,0 +1,26 @@ +# Payjoin JavaScript Bindings + +Welcome to the JavaScript language bindings for the [Payjoin Dev Kit](https://payjoindevkit.org/)! + +## Running Tests + +Follow these steps to clone the repository and run the tests. +This assumes you already have Rust and Node.js installed. + + +```shell +git clone https://github.com/payjoin/rust-payjoin.git +cd rust-payjoin/payjoin-ffi/javascript + +# Install dependencies +cargo install wasm-bindgen-cli +npm install +# (macOS only - secp256k1-sys requires a WASM-capable C compiler) +brew install llvm + +# Generate the bindings +bash ./scripts/generate_bindings.sh + +# Run all tests +npm test +``` diff --git a/payjoin-ffi/javascript/package-lock.json b/payjoin-ffi/javascript/package-lock.json new file mode 100644 index 000000000..858a98043 --- /dev/null +++ b/payjoin-ffi/javascript/package-lock.json @@ -0,0 +1,600 @@ +{ + "name": "payjoin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "payjoin", + "version": "0.1.0", + "devDependencies": { + "prettier": "^3.6.2", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "uniffi-bindgen-react-native": "github:spacebear21/uniffi-bindgen-react-native#tsconfig-module-nodenext" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uniffi-bindgen-react-native": { + "version": "0.29.3-1", + "resolved": "git+ssh://git@github.com/spacebear21/uniffi-bindgen-react-native.git#e7b8b432e8b45d7f40168751b2a84a1c844d6f1a", + "dev": true, + "license": "MPL-2.0", + "bin": { + "ubrn": "bin/cli.cjs", + "uniffi-bindgen-react-native": "bin/cli.cjs" + } + } + } +} diff --git a/payjoin-ffi/javascript/package.json b/payjoin-ffi/javascript/package.json new file mode 100644 index 000000000..235940261 --- /dev/null +++ b/payjoin-ffi/javascript/package.json @@ -0,0 +1,39 @@ +{ + "name": "payjoin", + "version": "0.1.0", + "description": "JavaScript/WASM bindings for rust-payjoin", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "browser": "dist/index.web.js", + "scripts": { + "ubrn:web": "ubrn build web --and-generate", + "compile": "tsc", + "copy-wasm": "mkdir -p dist/generated && cp -r src/generated/wasm-bindgen dist/generated/", + "fix-imports": "node scripts/fix-imports.js", + "build": "npm run ubrn:web && npm run compile && npm run copy-wasm && npm run fix-imports", + "build:test-utils": "cd test-utils && npm install && npx @napi-rs/cli build", + "clean": "rm -rf rust_modules dist src/generated", + "test": "tsx --test test/unit.test.ts test/integration.test.ts" + }, + "files": [ + "dist/**/*" + ], + "keywords": [ + "payjoin", + "bitcoin", + "wasm", + "rust" + ], + "devDependencies": { + "prettier": "^3.6.2", + "tsx": "^4.20.6", + "typescript": "^5.9.3", + "uniffi-bindgen-react-native": "github:spacebear21/uniffi-bindgen-react-native#tsconfig-module-nodenext" + }, + "repository": { + "type": "git", + "url": "https://github.com/payjoin/rust-payjoin.git", + "directory": "payjoin-ffi/javascript" + } +} diff --git a/payjoin-ffi/javascript/scripts/fix-imports.js b/payjoin-ffi/javascript/scripts/fix-imports.js new file mode 100644 index 000000000..5464315b0 --- /dev/null +++ b/payjoin-ffi/javascript/scripts/fix-imports.js @@ -0,0 +1,9 @@ +// This script is a dirty hack for Node.JS ESM imports. +// TypeScript compiles imports without a `.js` extension, which results in ERR_MODULE_NOT_FOUND when running in Node.JS. +// This fixes it by adding the `.js` file extension to the import in the generated files (thankfully there is currently only one such instance). +const fs = require('fs'); + +const file = 'dist/generated/payjoin_ffi.js'; +let content = fs.readFileSync(file, 'utf8'); +content = content.replace('from "./bitcoin"', 'from "./bitcoin.js"'); +fs.writeFileSync(file, content); diff --git a/payjoin-ffi/javascript/scripts/generate_bindings.sh b/payjoin-ffi/javascript/scripts/generate_bindings.sh new file mode 100755 index 000000000..5f4ff7b5c --- /dev/null +++ b/payjoin-ffi/javascript/scripts/generate_bindings.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +OS=$(uname -s) +echo "Running on $OS" + +npm --version + +if [[ "$OS" == "Darwin" ]]; then + # TODO: check if brew & llvm are installed + LLVM_PREFIX=$(brew --prefix llvm) + export AR="$LLVM_PREFIX/bin/llvm-ar" + export CC="$LLVM_PREFIX/bin/clang" + echo "LLVM flags set: AR=$AR, CC=$CC" +fi + +# Heinous hack to pin a transitive dependency to be MSRV compatible on 1.85 +cd node_modules/uniffi-bindgen-react-native +cargo add home@=0.5.11 --package uniffi-bindgen-react-native +cd ../.. + +rustup target add wasm32-unknown-unknown + +npm run build +npm run build:test-utils + +echo "All done!" diff --git a/payjoin-ffi/javascript/src/index.ts b/payjoin-ffi/javascript/src/index.ts new file mode 100644 index 000000000..762084cab --- /dev/null +++ b/payjoin-ffi/javascript/src/index.ts @@ -0,0 +1,32 @@ +// Export the generated bindings to the app. +export * as payjoin from "./generated/payjoin_ffi.js"; +export * as bitcoin from "./generated/bitcoin.js"; + +// Now import the bindings so we can: +// - initialize them +// - export them as namespaced objects as the default export. +import * as bitcoin from "./generated/bitcoin.js"; +import * as payjoin from "./generated/payjoin_ffi.js"; + +let initialized = false; + +export async function uniffiInitAsync() { + if (initialized) return; + + // Await WASM loading + await import("./generated/wasm-bindgen/index.js"); + + // Initialize the generated bindings: mostly checksums, but also callbacks. + // - the boolean flag ensures this loads exactly once, even if the JS code + // is reloaded (e.g. during development with metro). + bitcoin.default.initialize(); + payjoin.default.initialize(); + + initialized = true; +} + +// Export the crates as individually namespaced objects. +export default { + bitcoin, + payjoin, +}; diff --git a/payjoin-ffi/javascript/test-utils/Cargo.toml b/payjoin-ffi/javascript/test-utils/Cargo.toml new file mode 100644 index 000000000..eed7a70a3 --- /dev/null +++ b/payjoin-ffi/javascript/test-utils/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "payjoin-test-utils-napi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi = "2.16.17" +napi-derive = "2.16.13" +serde_json = "1.0.142" +tokio = { version = "1.47.1", features = ["full"] } + +[dependencies.payjoin-test-utils] +path = "../../../payjoin-test-utils" + +[build-dependencies] +napi-build = "=2.2.4" + +[patch.crates-io.payjoin] +path = "../../../payjoin" + +[patch.crates-io.payjoin-directory] +path = "../../../payjoin-directory" + +[patch.crates-io.payjoin-test-utils] +path = "../../../payjoin-test-utils" + +[workspace] diff --git a/payjoin-ffi/javascript/test-utils/README.md b/payjoin-ffi/javascript/test-utils/README.md new file mode 100644 index 000000000..37486b0c8 --- /dev/null +++ b/payjoin-ffi/javascript/test-utils/README.md @@ -0,0 +1,7 @@ +# Native Test Utils (NAPI-RS) + +This directory contains native Node.js bindings for payjoin test utilities using NAPI-RS. + +The JavaScript bindings use WASM (via uniffi-bindgen-react-native) for production code. However, WASM cannot access OS-level functionality like spawning Bitcoin Core processes or creating TCP servers. This NAPI-RS addon provides native test infrastructure while the integration tests run against the actual WASM production code. + +All implementations directly wrap `payjoin-test-utils` Rust crate. diff --git a/payjoin-ffi/javascript/test-utils/build.rs b/payjoin-ffi/javascript/test-utils/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/payjoin-ffi/javascript/test-utils/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/payjoin-ffi/javascript/test-utils/index.js b/payjoin-ffi/javascript/test-utils/index.js new file mode 100644 index 000000000..f10bdb3ed --- /dev/null +++ b/payjoin-ffi/javascript/test-utils/index.js @@ -0,0 +1,87 @@ +import { createRequire } from "module"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { existsSync, readFileSync } from "fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const require = createRequire(import.meta.url); + +const { platform, arch } = process; + +let nativeBinding = null; +let localFileExisted = false; +let loadError = null; + +function isMusl() { + if (!process.report || typeof process.report.getReport !== "function") { + try { + const { execSync } = require("child_process"); + const lddPath = execSync("which ldd").toString().trim(); + return readFileSync(lddPath, "utf8").includes("musl"); + } catch (e) { + return true; + } + } else { + const { glibcVersionRuntime } = process.report.getReport().header; + return !glibcVersionRuntime; + } +} + +switch (platform) { + case "darwin": + switch (arch) { + case "x64": + case "arm64": + localFileExisted = existsSync( + join(__dirname, "payjoin-test-utils-napi.node"), + ); + try { + if (localFileExisted) { + nativeBinding = require("./payjoin-test-utils-napi.node"); + } + } catch (e) { + loadError = e; + } + break; + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`); + } + break; + case "linux": + switch (arch) { + case "x64": + case "arm64": + localFileExisted = existsSync( + join(__dirname, "payjoin-test-utils-napi.node"), + ); + try { + if (localFileExisted) { + nativeBinding = require("./payjoin-test-utils-napi.node"); + } + } catch (e) { + loadError = e; + } + break; + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`); + } + break; + default: + throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`); +} + +if (!nativeBinding) { + if (loadError) { + throw loadError; + } + throw new Error(`Failed to load native binding`); +} + +export const { + BitcoindEnv, + BitcoindInstance, + RpcClient, + TestServices, + initBitcoindSenderReceiver, +} = nativeBinding; diff --git a/payjoin-ffi/javascript/test-utils/package-lock.json b/payjoin-ffi/javascript/test-utils/package-lock.json new file mode 100644 index 000000000..48f803515 --- /dev/null +++ b/payjoin-ffi/javascript/test-utils/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "payjoin-test-utils", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "payjoin-test-utils", + "version": "0.1.0", + "devDependencies": { + "@napi-rs/cli": "^2.18.0" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + } + } +} diff --git a/payjoin-ffi/javascript/test-utils/package.json b/payjoin-ffi/javascript/test-utils/package.json new file mode 100644 index 000000000..46337d3d5 --- /dev/null +++ b/payjoin-ffi/javascript/test-utils/package.json @@ -0,0 +1,15 @@ +{ + "name": "payjoin-test-utils", + "version": "0.1.0", + "description": "Native Node.js bindings for payjoin test utilities", + "type": "module", + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "payjoin-test-utils-napi", + "triples": {} + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.0" + } +} diff --git a/payjoin-ffi/javascript/test-utils/src/lib.rs b/payjoin-ffi/javascript/test-utils/src/lib.rs new file mode 100644 index 000000000..5ef5c9326 --- /dev/null +++ b/payjoin-ffi/javascript/test-utils/src/lib.rs @@ -0,0 +1,161 @@ +#![deny(clippy::all)] + +use std::sync::Arc; + +use napi::bindgen_prelude::*; +use napi_derive::napi; +use payjoin_test_utils::corepc_node::AddressType; +use serde_json::Value; + +#[napi] +pub struct BitcoindEnv { + bitcoind: BitcoindInstance, + receiver: RpcClient, + sender: RpcClient, +} + +#[napi] +impl BitcoindEnv { + #[napi] + pub fn get_bitcoind(&self) -> BitcoindInstance { self.bitcoind.clone() } + + #[napi] + pub fn get_receiver(&self) -> RpcClient { self.receiver.clone() } + + #[napi] + pub fn get_sender(&self) -> RpcClient { self.sender.clone() } +} + +#[napi] +#[derive(Clone)] +pub struct BitcoindInstance { + _inner: Arc, +} + +#[napi] +#[derive(Clone)] +pub struct RpcClient { + inner: Arc, +} + +#[napi] +impl RpcClient { + #[napi] + pub fn call(&self, method: String, params: Vec>) -> Result { + let parsed_params: Vec = params + .into_iter() + .map(|param| match param { + Some(p) => serde_json::from_str(&p).unwrap_or(Value::String(p)), + None => Value::Null, + }) + .collect(); + + let result = self + .inner + .call::(&method, &parsed_params) + .map_err(|e| Error::from_reason(format!("RPC call failed: {}", e)))?; + + serde_json::to_string(&result) + .map_err(|e| Error::from_reason(format!("Serialization error: {}", e))) + } +} + +#[napi] +pub struct TestServices { + inner: Arc>, + _runtime: Arc, +} + +#[napi] +impl TestServices { + #[napi(constructor)] + pub fn new() -> Result { + let runtime = Arc::new( + tokio::runtime::Runtime::new() + .map_err(|e| Error::from_reason(format!("Failed to create runtime: {}", e)))?, + ); + + let inner = runtime.block_on(async { + payjoin_test_utils::TestServices::initialize() + .await + .map_err(|e| Error::from_reason(format!("Initialization failed: {}", e))) + })?; + + Ok(TestServices { inner: Arc::new(tokio::sync::Mutex::new(inner)), _runtime: runtime }) + } + + #[napi] + pub fn cert(&self) -> Buffer { + let runtime = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + let cert = runtime.block_on(async { self.inner.lock().await.cert() }); + Buffer::from(cert) + } + + #[napi] + pub fn directory_url(&self) -> String { + let runtime = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + runtime.block_on(async { self.inner.lock().await.directory_url() }) + } + + #[napi] + pub fn ohttp_relay_url(&self) -> String { + let runtime = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + runtime.block_on(async { self.inner.lock().await.ohttp_relay_url() }) + } + + #[napi] + pub fn ohttp_gateway_url(&self) -> String { + let runtime = tokio::runtime::Runtime::new().expect("Failed to create runtime"); + runtime.block_on(async { self.inner.lock().await.ohttp_gateway_url() }) + } + + #[napi] + pub fn wait_for_services_ready(&self) -> Result<()> { + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| Error::from_reason(format!("Failed to create runtime: {}", e)))?; + + runtime.block_on(async { + self.inner + .lock() + .await + .wait_for_services_ready() + .await + .map_err(|e| Error::from_reason(format!("Services not ready: {}", e))) + }) + } + + #[napi] + pub fn fetch_ohttp_keys(&self) -> Result { + let runtime = tokio::runtime::Runtime::new() + .map_err(|e| Error::from_reason(format!("Failed to create runtime: {}", e)))?; + + runtime.block_on(async { + self.inner + .lock() + .await + .fetch_ohttp_keys() + .await + .map_err(|e| Error::from_reason(format!("Failed to fetch OHTTP keys: {}", e))) + .and_then(|keys| { + keys.encode().map(Buffer::from).map_err(|e| { + Error::from_reason(format!("Failed to encode OHTTP keys: {}", e)) + }) + }) + }) + } +} + +#[napi] +pub fn init_bitcoind_sender_receiver() -> Result { + let (bitcoind, receiver, sender) = payjoin_test_utils::init_bitcoind_sender_receiver( + Some(AddressType::Bech32), + Some(AddressType::Bech32), + ) + .map_err(|e| Error::from_reason(format!("Failed to initialize bitcoind: {}", e)))?; + + Ok(BitcoindEnv { + bitcoind: BitcoindInstance { _inner: Arc::new(bitcoind) }, + receiver: RpcClient { inner: Arc::new(receiver) }, + sender: RpcClient { inner: Arc::new(sender) }, + }) +} diff --git a/payjoin-ffi/javascript/test/integration.test.ts b/payjoin-ffi/javascript/test/integration.test.ts new file mode 100644 index 000000000..5c41d6ef6 --- /dev/null +++ b/payjoin-ffi/javascript/test/integration.test.ts @@ -0,0 +1,570 @@ +import { payjoin, bitcoin, uniffiInitAsync } from "../dist/index.js"; +import * as testUtils from "../test-utils/index.js"; +import assert from "assert"; + +interface Utxo { + txid: string; + vout: number; + amount: number; + scriptPubKey: string; +} + +class InMemoryReceiverPersister + implements payjoin.JsonReceiverSessionPersister +{ + private id: string; + private events: string[] = []; + private closed: boolean = false; + public connection?: testUtils.RpcClient; + + constructor(id: string) { + this.id = id; + } + + save(event: string): void { + this.events.push(event); + } + + load(): string[] { + return this.events; + } + + close(): void { + this.closed = true; + } +} + +class InMemorySenderPersister implements payjoin.JsonSenderSessionPersister { + private id: string; + private events: string[] = []; + private closed: boolean = false; + + constructor(id: string) { + this.id = id; + } + + save(event: string): void { + this.events.push(event); + } + + load(): string[] { + return this.events; + } + + close(): void { + this.closed = true; + } +} + +class MempoolAcceptanceCallback implements payjoin.CanBroadcast { + private connection: testUtils.RpcClient; + + constructor(connection: testUtils.RpcClient) { + this.connection = connection; + } + + callback(tx: ArrayBuffer): boolean { + try { + const hexTx = Buffer.from(tx).toString("hex"); + const resultJson = this.connection.call("testmempoolaccept", [ + JSON.stringify([hexTx]), + ]); + const decoded = JSON.parse(resultJson); + return decoded[0].allowed === true; + } catch { + return false; + } + } +} + +class IsScriptOwnedCallback implements payjoin.IsScriptOwned { + private connection: testUtils.RpcClient; + + constructor(connection: testUtils.RpcClient) { + this.connection = connection; + } + + callback(script: ArrayBuffer): boolean { + try { + const scriptObj = new bitcoin.Script(script); + const address = bitcoin.Address.fromScript( + scriptObj, + bitcoin.Network.Regtest, + ); + const addressStr = address.toQrUri().split(":")[1]; + const result = this.connection.call("getaddressinfo", [addressStr]); + const decoded = JSON.parse(result); + return decoded.ismine === true; + } catch { + return false; + } + } +} + +class CheckInputsNotSeenCallback implements payjoin.IsOutputKnown { + private connection: testUtils.RpcClient; + + constructor(connection: testUtils.RpcClient) { + this.connection = connection; + } + + callback(_outpoint: ArrayBuffer): boolean { + return false; + } +} + +class ProcessPsbtCallback implements payjoin.ProcessPsbt { + private connection: testUtils.RpcClient; + + constructor(connection: testUtils.RpcClient) { + this.connection = connection; + } + + callback(psbt: string): string { + const res = JSON.parse( + this.connection.call("walletprocesspsbt", [psbt]), + ); + return res.psbt; + } +} + +function createReceiverContext( + address: bitcoin.Address, + directory: string, + ohttpKeys: payjoin.OhttpKeys, + persister: InMemoryReceiverPersister, +): payjoin.Initialized { + const receiver = new payjoin.ReceiverBuilder(address, directory, ohttpKeys) + .build() + .save(persister); + return receiver; +} + +function buildSweepPsbt( + sender: testUtils.RpcClient, + pjUri: payjoin.PjUri, +): string { + const outputs: Record = {}; + outputs[pjUri.address()] = 50; + const psbt = JSON.parse( + sender.call("walletcreatefundedpsbt", [ + JSON.stringify([]), + JSON.stringify(outputs), + JSON.stringify(0), + JSON.stringify({ + lockUnspents: true, + fee_rate: 10, + subtractFeeFromOutputs: [0], + }), + ]), + ).psbt; + return JSON.parse( + sender.call("walletprocesspsbt", [ + psbt, + JSON.stringify(true), + JSON.stringify("ALL"), + JSON.stringify(false), + ]), + ).psbt; +} + +function getInputs(rpcConnection: testUtils.RpcClient): payjoin.InputPair[] { + const utxos: Utxo[] = JSON.parse(rpcConnection.call("listunspent", [])); + const inputs: payjoin.InputPair[] = []; + for (const utxo of utxos) { + const txin = bitcoin.TxIn.create({ + previousOutput: bitcoin.OutPoint.create({ + txid: utxo.txid, + vout: utxo.vout, + }), + scriptSig: new bitcoin.Script(new Uint8Array([]).buffer), + sequence: 0, + witness: [], + }); + const txOut = bitcoin.TxOut.create({ + value: bitcoin.Amount.fromBtc(utxo.amount), + scriptPubkey: new bitcoin.Script( + new Uint8Array(Buffer.from(utxo.scriptPubKey, "hex")), + ), + }); + const psbtIn = payjoin.PsbtInput.create({ + witnessUtxo: txOut, + redeemScript: undefined, + witnessScript: undefined, + }); + inputs.push(new payjoin.InputPair(txin, psbtIn, undefined)); + } + return inputs; +} + +async function processProvisionalProposal( + proposal: payjoin.ProvisionalProposal, + receiver: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, +): Promise { + const payjoinProposal = proposal + .finalizeProposal(new ProcessPsbtCallback(receiver)) + .save(recvPersister); + return payjoinProposal; +} + +async function processWantsFeeRange( + proposal: payjoin.WantsFeeRange, + receiver: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, +): Promise { + const wantsFeeRange = proposal.applyFeeRange(1n, 10n).save(recvPersister); + return await processProvisionalProposal( + wantsFeeRange, + receiver, + recvPersister, + ); +} + +async function processWantsInputs( + proposal: payjoin.WantsInputs, + receiver: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, +): Promise { + const provisionalProposal = proposal + .contributeInputs(getInputs(receiver)) + .commitInputs() + .save(recvPersister); + return await processWantsFeeRange( + provisionalProposal, + receiver, + recvPersister, + ); +} + +async function processWantsOutputs( + proposal: payjoin.WantsOutputs, + receiver: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, +): Promise { + const wantsInputs = proposal.commitOutputs().save(recvPersister); + return await processWantsInputs(wantsInputs, receiver, recvPersister); +} + +async function processOutputsUnknown( + proposal: payjoin.OutputsUnknown, + receiver: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, +): Promise { + const wantsOutputs = proposal + .identifyReceiverOutputs(new IsScriptOwnedCallback(receiver)) + .save(recvPersister); + return await processWantsOutputs(wantsOutputs, receiver, recvPersister); +} + +async function processMaybeInputsSeen( + proposal: payjoin.MaybeInputsSeen, + receiver: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, +): Promise { + const outputsUnknown = proposal + .checkNoInputsSeenBefore(new CheckInputsNotSeenCallback(receiver)) + .save(recvPersister); + return await processOutputsUnknown(outputsUnknown, receiver, recvPersister); +} + +async function processMaybeInputsOwned( + proposal: payjoin.MaybeInputsOwned, + receiver: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, +): Promise { + const maybeInputsOwned = proposal + .checkInputsNotOwned(new IsScriptOwnedCallback(receiver)) + .save(recvPersister); + return await processMaybeInputsSeen( + maybeInputsOwned, + receiver, + recvPersister, + ); +} + +async function processUncheckedProposal( + proposal: payjoin.UncheckedOriginalPayload, + receiver: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, +): Promise { + const uncheckedProposal = proposal + .checkBroadcastSuitability( + undefined, + new MempoolAcceptanceCallback(receiver), + ) + .save(recvPersister); + return await processMaybeInputsOwned( + uncheckedProposal, + receiver, + recvPersister, + ); +} + +async function retrieveReceiverProposal( + receiver: payjoin.Initialized, + recvPersister: InMemoryReceiverPersister, + ohttpRelay: string, +): Promise { + const request = receiver.createPollRequest(ohttpRelay); + const response = await fetch(request.request.url, { + method: "POST", + headers: { "Content-Type": request.request.contentType }, + body: request.request.body, + }); + const responseBuffer = await response.arrayBuffer(); + const res = receiver + .processResponse(responseBuffer, request.clientResponse) + .save(recvPersister); + + if (res instanceof payjoin.InitializedTransitionOutcome.Stasis) { + return null; + } else if (res instanceof payjoin.InitializedTransitionOutcome.Progress) { + const proposal = res.inner.inner; + return await processUncheckedProposal( + proposal, + recvPersister.connection!, + recvPersister, + ); + } + + throw new Error(`Unknown initialized transition outcome`); +} + +async function processReceiverProposal( + receiver: + | payjoin.Initialized + | payjoin.UncheckedOriginalPayload + | payjoin.MaybeInputsOwned + | payjoin.MaybeInputsSeen + | payjoin.OutputsUnknown + | payjoin.WantsOutputs + | payjoin.WantsInputs + | payjoin.WantsFeeRange + | payjoin.ProvisionalProposal + | payjoin.PayjoinProposal, + receiverRpc: testUtils.RpcClient, + recvPersister: InMemoryReceiverPersister, + ohttpRelay: string, +): Promise { + if (receiver instanceof payjoin.Initialized) { + const res = await retrieveReceiverProposal( + receiver, + recvPersister, + ohttpRelay, + ); + if (res === null) { + return null; + } + return res; + } + + if (receiver instanceof payjoin.UncheckedOriginalPayload) { + return await processUncheckedProposal( + receiver, + receiverRpc, + recvPersister, + ); + } + if (receiver instanceof payjoin.MaybeInputsOwned) { + return await processMaybeInputsOwned( + receiver, + receiverRpc, + recvPersister, + ); + } + if (receiver instanceof payjoin.MaybeInputsSeen) { + return await processMaybeInputsSeen( + receiver, + receiverRpc, + recvPersister, + ); + } + if (receiver instanceof payjoin.OutputsUnknown) { + return await processOutputsUnknown( + receiver, + receiverRpc, + recvPersister, + ); + } + if (receiver instanceof payjoin.WantsOutputs) { + return await processWantsOutputs(receiver, receiverRpc, recvPersister); + } + if (receiver instanceof payjoin.WantsInputs) { + return await processWantsInputs(receiver, receiverRpc, recvPersister); + } + if (receiver instanceof payjoin.WantsFeeRange) { + return await processWantsFeeRange(receiver, receiverRpc, recvPersister); + } + if (receiver instanceof payjoin.ProvisionalProposal) { + return await processProvisionalProposal( + receiver, + receiverRpc, + recvPersister, + ); + } + if (receiver instanceof payjoin.PayjoinProposal) { + return receiver; + } + + throw new Error(`Unknown receiver state`); +} + +async function testIntegrationV2ToV2(): Promise { + const env = testUtils.initBitcoindSenderReceiver(); + const bitcoind = env.getBitcoind(); + const receiver = env.getReceiver(); + const sender = env.getSender(); + + const receiverAddressJson = receiver.call("getnewaddress", []); + const receiverAddress = new bitcoin.Address( + JSON.parse(receiverAddressJson), + bitcoin.Network.Regtest, + ); + + const services = new testUtils.TestServices(); + const directory = services.directoryUrl(); + const ohttpRelay = services.ohttpRelayUrl(); + services.waitForServicesReady(); + const ohttpKeysBytes = services.fetchOhttpKeys(); + const ohttpKeys = payjoin.OhttpKeys.decode(ohttpKeysBytes.buffer); + + const recvPersister = new InMemoryReceiverPersister("1"); + const senderPersister = new InMemorySenderPersister("1"); + recvPersister.connection = receiver; + + const session = createReceiverContext( + receiverAddress, + directory, + ohttpKeys, + recvPersister, + ); + + let processResponse = await processReceiverProposal( + session, + receiver, + recvPersister, + ohttpRelay, + ); + assert.strictEqual( + processResponse, + null, + "Initial proposal should be null", + ); + + const pjUri = session.pjUri(); + const psbt = buildSweepPsbt(sender, pjUri); + const reqCtx = new payjoin.SenderBuilder(psbt, pjUri) + .buildRecommended(1000n) + .save(senderPersister); + + const request = reqCtx.createV2PostRequest(ohttpRelay); + const response = await fetch(request.request.url, { + method: "POST", + headers: { "Content-Type": request.request.contentType }, + body: request.request.body, + }); + const responseBuffer = await response.arrayBuffer(); + const sendCtx = reqCtx + .processResponse(responseBuffer, request.ohttpCtx) + .save(senderPersister); + + let payjoinProposal = await processReceiverProposal( + session, + receiver, + recvPersister, + ohttpRelay, + ); + assert.notStrictEqual( + payjoinProposal, + null, + "Payjoin proposal should not be null", + ); + assert( + payjoinProposal instanceof payjoin.PayjoinProposal, + "Should be a payjoin proposal", + ); + + const proposal = payjoinProposal; + const requestResponse = proposal.createPostRequest(ohttpRelay); + const fallbackResponse = await fetch(requestResponse.request.url, { + method: "POST", + headers: { "Content-Type": requestResponse.request.contentType }, + body: requestResponse.request.body, + }); + const fallbackResponseBuffer = await fallbackResponse.arrayBuffer(); + proposal.processResponse( + fallbackResponseBuffer, + requestResponse.clientResponse, + ); + + const ohttpContextRequest = sendCtx.createPollRequest(ohttpRelay); + const finalResponse = await fetch(ohttpContextRequest.request.url, { + method: "POST", + headers: { "Content-Type": ohttpContextRequest.request.contentType }, + body: ohttpContextRequest.request.body, + }); + const finalResponseBuffer = await finalResponse.arrayBuffer(); + const checkedPayjoinProposalPsbt = sendCtx + .processResponse(finalResponseBuffer, ohttpContextRequest.ohttpCtx) + .save(senderPersister); + + assert.notStrictEqual( + checkedPayjoinProposalPsbt, + null, + "Checked payjoin proposal should not be null", + ); + assert( + checkedPayjoinProposalPsbt instanceof + payjoin.PollingForProposalTransitionOutcome.Progress, + "Should be progress outcome", + ); + + if ( + !( + checkedPayjoinProposalPsbt instanceof + payjoin.PollingForProposalTransitionOutcome.Progress + ) + ) { + throw new Error("Expected Progress outcome"); + } + const checkedPayjoinProposalPsbtInner: bitcoin.Psbt = + checkedPayjoinProposalPsbt.inner.inner; + const payjoinPsbt = JSON.parse( + sender.call("walletprocesspsbt", [ + checkedPayjoinProposalPsbtInner.serializeBase64(), + ]), + ).psbt; + const finalPsbt = JSON.parse( + sender.call("finalizepsbt", [payjoinPsbt, JSON.stringify(false)]), + ).psbt; + const payjoinTx = bitcoin.Psbt.deserializeBase64(finalPsbt).extractTx(); + sender.call("sendrawtransaction", [ + JSON.stringify(Buffer.from(payjoinTx.serialize()).toString("hex")), + ]); + + const networkFees = bitcoin.Psbt.deserializeBase64(finalPsbt).fee().toBtc(); + assert.strictEqual(payjoinTx.input().length, 2, "Should have 2 inputs"); + assert.strictEqual(payjoinTx.output().length, 1, "Should have 1 output"); + + const receiverBalance = JSON.parse(receiver.call("getbalances", [])).mine + .untrusted_pending; + assert.strictEqual( + receiverBalance, + 100 - networkFees, + "Receiver balance should be 100 - network fees", + ); + + const senderBalance = JSON.parse(sender.call("getbalance", [])); + assert.strictEqual(senderBalance, 0.0, "Sender balance should be 0"); +} + +async function runTests(): Promise { + await uniffiInitAsync(); + await testIntegrationV2ToV2(); +} + +runTests().catch((error: unknown) => { + console.error("\n✗ Integration test failed:", error); + process.exit(1); +}); diff --git a/payjoin-ffi/javascript/test/unit.test.ts b/payjoin-ffi/javascript/test/unit.test.ts new file mode 100644 index 000000000..32a9595b1 --- /dev/null +++ b/payjoin-ffi/javascript/test/unit.test.ts @@ -0,0 +1,171 @@ +import { describe, test, before } from "node:test"; +import assert from "node:assert"; +import { payjoin, bitcoin, uniffiInitAsync } from "../dist/index.js"; + +before(async () => { + await uniffiInitAsync(); +}); + +class InMemoryReceiverPersister { + id: number; + events: any[]; + closed: boolean; + + constructor(id: number) { + this.id = id; + this.events = []; + this.closed = false; + } + + save(event: any) { + this.events.push(event); + } + + load() { + return this.events; + } + + close() { + this.closed = true; + } +} + +class InMemorySenderPersister { + id: number; + events: any[]; + closed: boolean; + + constructor(id: number) { + this.id = id; + this.events = []; + this.closed = false; + } + + save(event: any) { + this.events.push(event); + } + + load() { + return this.events; + } + + close() { + this.closed = true; + } +} + +describe("URI tests", () => { + test("URL encoded payjoin parameter", () => { + const uri = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao"; + const result = payjoin.Url.parse(uri); + assert.ok(result, "pj url should be url encoded"); + }); + + test("valid URL", () => { + const uri = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?amount=1&pj=https://example.com?ciao"; + const result = payjoin.Url.parse(uri); + assert.ok(result, "pj is not a valid url"); + }); + + test("missing amount should be ok", () => { + const uri = + "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX?pj=https://testnet.demo.btcpayserver.org/BTC/pj"; + const result = payjoin.Url.parse(uri); + assert.ok(result, "missing amount should be ok"); + }); + + test("valid URIs with different addresses and endpoints", () => { + const https = "https://example.com"; + const onion = + "http://vjdpwgybvubne5hda6v4c5iaeeevhge6jvo3w2cl6eocbwwvwxp7b7qd.onion"; + + const base58 = "bitcoin:12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX"; + const bech32Upper = "BITCOIN:TB1Q6D3A2W975YNY0ASUVD9A67NER4NKS58FF0Q8G4"; + const bech32Lower = "bitcoin:tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4"; + + const addresses = [base58, bech32Upper, bech32Lower]; + const pjs = [https, onion]; + + for (const address of addresses) { + for (const pj of pjs) { + const uri = `${address}?amount=1&pj=${pj}`; + assert.doesNotThrow( + () => payjoin.Url.parse(uri), + `Failed to create a valid Uri for ${uri}`, + ); + } + } + }); +}); + +describe("Persistence tests", () => { + test("receiver persistence", () => { + const persister = new InMemoryReceiverPersister(1); + const address = new bitcoin.Address( + "tb1q6d3a2w975yny0asuvd9a67ner4nks58ff0q8g4", + bitcoin.Network.Signet, + ); + const ohttpKeys = payjoin.OhttpKeys.decode( + new Uint8Array([ + 0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a, 0x92, + 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1, 0x0c, 0xed, + 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7, 0xad, 0x2c, 0xc9, + 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe, 0xe8, 0xd2, 0x8b, 0xfe, + 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc, 0x4d, 0x31, 0xcd, 0x85, 0x1e, + 0x8a, 0x65, 0x40, 0xe8, 0x6c, 0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, + 0x09, 0x57, 0x00, 0x04, 0x00, 0x01, 0x00, 0x03, + ]).buffer, + ); + + const builder = new payjoin.ReceiverBuilder( + address, + "https://example.com", + ohttpKeys, + ); + builder.build().save(persister); + + const result = payjoin.replayReceiverEventLog(persister); + const state = result.state(); + + assert.strictEqual(state.tag, "Initialized", "State should be Initialized"); + }); + + test("sender persistence", () => { + const persister = new InMemoryReceiverPersister(1); + const address = new bitcoin.Address( + "2MuyMrZHkbHbfjudmKUy45dU4P17pjG2szK", + bitcoin.Network.Testnet, + ); + const ohttpKeys = payjoin.OhttpKeys.decode( + new Uint8Array([ + 0x01, 0x00, 0x16, 0x04, 0xba, 0x48, 0xc4, 0x9c, 0x3d, 0x4a, 0x92, + 0xa3, 0xad, 0x00, 0xec, 0xc6, 0x3a, 0x02, 0x4d, 0xa1, 0x0c, 0xed, + 0x02, 0x18, 0x0c, 0x73, 0xec, 0x12, 0xd8, 0xa7, 0xad, 0x2c, 0xc9, + 0x1b, 0xb4, 0x83, 0x82, 0x4f, 0xe2, 0xbe, 0xe8, 0xd2, 0x8b, 0xfe, + 0x2e, 0xb2, 0xfc, 0x64, 0x53, 0xbc, 0x4d, 0x31, 0xcd, 0x85, 0x1e, + 0x8a, 0x65, 0x40, 0xe8, 0x6c, 0x53, 0x82, 0xaf, 0x58, 0x8d, 0x37, + 0x09, 0x57, 0x00, 0x04, 0x00, 0x01, 0x00, 0x03, + ]).buffer, + ); + + const receiver = new payjoin.ReceiverBuilder( + address, + "https://example.com", + ohttpKeys, + ) + .build() + .save(persister); + const uri = receiver.pjUri(); + + const senderPersister = new InMemorySenderPersister(1); + const psbt = + "cHNidP8BAHMCAAAAAY8nutGgJdyYGXWiBEb45Hoe9lWGbkxh/6bNiOJdCDuDAAAAAAD+////AtyVuAUAAAAAF6kUHehJ8GnSdBUOOv6ujXLrWmsJRDCHgIQeAAAAAAAXqRR3QJbbz0hnQ8IvQ0fptGn+votneofTAAAAAAEBIKgb1wUAAAAAF6kU3k4ekGHKWRNbA1rV5tR5kEVDVNCHAQcXFgAUx4pFclNVgo1WWAdN1SYNX8tphTABCGsCRzBEAiB8Q+A6dep+Rz92vhy26lT0AjZn4PRLi8Bf9qoB/CMk0wIgP/Rj2PWZ3gEjUkTlhDRNAQ0gXwTO7t9n+V14pZ6oljUBIQMVmsAaoNWHVMS02LfTSe0e388LNitPa1UQZyOihY+FFgABABYAFEb2Giu6c4KO5YW0pfw3lGp9jMUUAAA="; + const withReplyKey = new payjoin.SenderBuilder(psbt, uri) + .buildRecommended(BigInt(1000)) + .save(senderPersister); + + assert.ok(withReplyKey, "Sender should be created successfully"); + }); +}); diff --git a/payjoin-ffi/javascript/tsconfig.json b/payjoin-ffi/javascript/tsconfig.json new file mode 100644 index 000000000..3826359fb --- /dev/null +++ b/payjoin-ffi/javascript/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "lib": ["es2022", "dom"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "rust_modules"] +} diff --git a/payjoin-ffi/javascript/ubrn.config.yaml b/payjoin-ffi/javascript/ubrn.config.yaml new file mode 100644 index 000000000..88b8a4b19 --- /dev/null +++ b/payjoin-ffi/javascript/ubrn.config.yaml @@ -0,0 +1,9 @@ +rust: + directory: .. + manifestPath: Cargo.toml + +web: + features: ["wasm_js"] + defaultFeatures: false + target: nodejs + manifestPatchFile: wasm-manifest-patch.toml diff --git a/payjoin-ffi/javascript/wasm-manifest-patch.toml b/payjoin-ffi/javascript/wasm-manifest-patch.toml new file mode 100644 index 000000000..655101c94 --- /dev/null +++ b/payjoin-ffi/javascript/wasm-manifest-patch.toml @@ -0,0 +1,9 @@ +# `ubrn build` generates a Rust wasm crate in rust_modules/wasm, with `wasm-bindgen` annotated functions. +# That crate is used to generate TS bindings which call into the `wasm-bindgen` functions. +# Because that generated crate doesn't belong to the `payjoin` workspace, we need to patch the generated Cargo.toml accordingly for local development. +# The patch is applied via the ubrn.config.yaml `manifestPatchFile` option. +# The paths below are relative to payjoin-ffi/javascript/rust_modules/wasm/Cargo.toml +[patch.crates-io] +payjoin = { path = "../../../../payjoin" } +payjoin-directory = { path = "../../../../payjoin-directory" } +payjoin-test-utils = { path = "../../../../payjoin-test-utils" } diff --git a/payjoin-ffi/src/lib.rs b/payjoin-ffi/src/lib.rs index dc163e0b8..4bf23f72f 100644 --- a/payjoin-ffi/src/lib.rs +++ b/payjoin-ffi/src/lib.rs @@ -2,6 +2,7 @@ pub mod bitcoin_ffi; pub mod error; +#[cfg(feature = "io")] pub mod io; pub mod ohttp; pub mod output_substitution; diff --git a/payjoin-ffi/uniffi-bindgen.rs b/payjoin-ffi/uniffi-bindgen.rs index 3c28dc88c..13853bba7 100644 --- a/payjoin-ffi/uniffi-bindgen.rs +++ b/payjoin-ffi/uniffi-bindgen.rs @@ -6,18 +6,19 @@ fn uniffi_bindgen() { let args: Vec = std::env::args().collect(); let language = args.iter().position(|arg| arg == "--language").and_then(|idx| args.get(idx + 1)); - let library_path = args - .iter() - .position(|arg| arg == "--library") - .and_then(|idx| args.get(idx + 1)) - .expect("specify the library path with --library"); - let output_dir = args - .iter() - .position(|arg| arg == "--out-dir") - .and_then(|idx| args.get(idx + 1)) - .expect("--out-dir is required when using --library"); match language { + #[cfg(feature = "dart")] Some(lang) if lang == "dart" => { + let library_path = args + .iter() + .position(|arg| arg == "--library") + .and_then(|idx| args.get(idx + 1)) + .expect("specify the library path with --library"); + let output_dir = args + .iter() + .position(|arg| arg == "--out-dir") + .and_then(|idx| args.get(idx + 1)) + .expect("--out-dir is required when using --library"); uniffi_dart::gen::generate_dart_bindings( "src/payjoin_ffi.udl".into(), None, diff --git a/payjoin/Cargo.toml b/payjoin/Cargo.toml index 7a71faf77..79b326b87 100644 --- a/payjoin/Cargo.toml +++ b/payjoin/Cargo.toml @@ -41,6 +41,9 @@ url = { version = "2.5.4", optional = true, default-features=false, features = [ serde_json = { version = "1.0.142", optional = true } tracing = "0.1.41" +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-time = "1.1.0" + [dev-dependencies] payjoin-test-utils = { version = "0.0.1" } once_cell = "1.21.3" diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index 0387dfb9b..68896eb63 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -25,6 +25,7 @@ //! but request reuse makes correlation trivial for the relay. use std::str::FromStr; +#[cfg(not(target_arch = "wasm32"))] use std::time::Duration; use bitcoin::hashes::{sha256, Hash}; @@ -36,6 +37,8 @@ use serde::de::Deserializer; use serde::{Deserialize, Serialize}; pub use session::{replay_event_log, SessionEvent, SessionHistory, SessionOutcome, SessionStatus}; use url::Url; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; use super::error::{Error, InputContributionError}; use super::{ diff --git a/payjoin/src/core/time.rs b/payjoin/src/core/time.rs index da0dddb6a..47f221f08 100644 --- a/payjoin/src/core/time.rs +++ b/payjoin/src/core/time.rs @@ -1,8 +1,13 @@ +#[cfg(not(target_arch = "wasm32"))] use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::absolute::Time as BitcoinTime; use bitcoin::consensus::encode::{Decodable, Error as EncodeError}; use bitcoin::consensus::Encodable; +// web_time already does `cfg` switching, but as an additional sanity check it doesn't hurt to do +// our own `cfg` switching when importing it in our code. +#[cfg(target_arch = "wasm32")] +use web_time::{Duration, SystemTime, UNIX_EPOCH}; #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,