From f5f8b8fd16f951ea9d3110de3d65299ab5b3d45a Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 18 Nov 2025 18:26:17 -0500 Subject: [PATCH 01/11] Ignore package-lock.json in spell checker --- .codespellrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From d5d1129c6a91bf8bdb81b9df8a57479b55a3857a Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 13 Nov 2025 14:43:31 -0500 Subject: [PATCH 02/11] Add javascript FFI scaffolding This includes: - package.json with uniffi-bindgen-react-native and other relevant dependencies. It also includes several npm commands to generate the bindings. - A minimal ubrn.config.yaml to generate bindings for node.js. - tsconfig.json instructions for the `tsc` compiler. - wasm-manifest-patch.toml ensures that the generated WASM crate uses the local payjoin crates for development. - fix-imports.js is a sad workaround for node.js-compatible imports. - generate_bindings.sh is a helper following the pattern established in python/ and dart/ bindings. For MacOS it's necessary to use llvm's clang because Apple's clang doesn't have a WASM backend. --- payjoin-ffi/javascript/.gitignore | 7 + payjoin-ffi/javascript/.prettierrc | 3 + payjoin-ffi/javascript/package-lock.json | 600 ++++++++++++++++++ payjoin-ffi/javascript/package.json | 38 ++ payjoin-ffi/javascript/scripts/fix-imports.js | 9 + .../javascript/scripts/generate_bindings.sh | 19 + payjoin-ffi/javascript/src/index.ts | 32 + payjoin-ffi/javascript/tsconfig.json | 20 + payjoin-ffi/javascript/ubrn.config.yaml | 9 + .../javascript/wasm-manifest-patch.toml | 9 + 10 files changed, 746 insertions(+) create mode 100644 payjoin-ffi/javascript/.gitignore create mode 100644 payjoin-ffi/javascript/.prettierrc create mode 100644 payjoin-ffi/javascript/package-lock.json create mode 100644 payjoin-ffi/javascript/package.json create mode 100644 payjoin-ffi/javascript/scripts/fix-imports.js create mode 100755 payjoin-ffi/javascript/scripts/generate_bindings.sh create mode 100644 payjoin-ffi/javascript/src/index.ts create mode 100644 payjoin-ffi/javascript/tsconfig.json create mode 100644 payjoin-ffi/javascript/ubrn.config.yaml create mode 100644 payjoin-ffi/javascript/wasm-manifest-patch.toml diff --git a/payjoin-ffi/javascript/.gitignore b/payjoin-ffi/javascript/.gitignore new file mode 100644 index 000000000..aac1da949 --- /dev/null +++ b/payjoin-ffi/javascript/.gitignore @@ -0,0 +1,7 @@ +# Build outputs +dist/ +node_modules/ + +# Generated by uniffi-bindgen-react-native +rust_modules/ +src/generated/ 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/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..2e260b27f --- /dev/null +++ b/payjoin-ffi/javascript/package.json @@ -0,0 +1,38 @@ +{ + "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", + "clean": "rm -rf rust_modules dist src/generated", + "test": "node test.js" + }, + "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..e6904044f --- /dev/null +++ b/payjoin-ffi/javascript/scripts/generate_bindings.sh @@ -0,0 +1,19 @@ +#!/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 + +npm run build + +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/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" } From 98b7697d0e5aabc9d299e277101f91b47e90eea8 Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 13 Nov 2025 14:51:39 -0500 Subject: [PATCH 03/11] Use web-time for wasm targets instead of std::time This is a drop-in replacement for compatibility with WASM targets wherever core components use std::time. `io` is already incompatible with WASM due to the tokio `net` dependency, so I left that import of `std::time` unchanged. --- Cargo-minimal.lock | 2 ++ Cargo-recent.lock | 2 ++ payjoin/Cargo.toml | 3 +++ payjoin/src/core/receive/v2/mod.rs | 3 +++ payjoin/src/core/time.rs | 5 +++++ 5 files changed, 15 insertions(+) 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/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, From ea1fc17df7972cc15aa98fb37cd4bd57c932d212 Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 13 Nov 2025 14:54:07 -0500 Subject: [PATCH 04/11] Port unit tests for JS bindings Robot ported these from the python tests. --- payjoin-ffi/javascript/package.json | 2 +- payjoin-ffi/javascript/test/unit.test.ts | 171 +++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 payjoin-ffi/javascript/test/unit.test.ts diff --git a/payjoin-ffi/javascript/package.json b/payjoin-ffi/javascript/package.json index 2e260b27f..1b8c14e0a 100644 --- a/payjoin-ffi/javascript/package.json +++ b/payjoin-ffi/javascript/package.json @@ -13,7 +13,7 @@ "fix-imports": "node scripts/fix-imports.js", "build": "npm run ubrn:web && npm run compile && npm run copy-wasm && npm run fix-imports", "clean": "rm -rf rust_modules dist src/generated", - "test": "node test.js" + "test": "tsx --test test/unit.test.ts test/integration.test.ts" }, "files": [ "dist/**/*" 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"); + }); +}); From 85c63a28ba470e2deb6e453d69d9316dc999c39a Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 13 Nov 2025 15:01:55 -0500 Subject: [PATCH 05/11] Feature gate `io` in payjoin-ffi The `io` feature is incompatible with WASM due to the tokio `net` dependency. --- payjoin-ffi/Cargo.toml | 6 ++++-- payjoin-ffi/src/lib.rs | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 8a4e6d5f1..882d7e6c6 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -6,7 +6,9 @@ license = "MIT OR Apache-2.0" exclude = ["tests"] [features] -_test-utils = ["payjoin-test-utils", "tokio"] +default = ["io"] +io = ["payjoin/io"] +_test-utils = ["payjoin-test-utils", "tokio", "io"] _manual-tls = ["payjoin/_manual-tls"] [lib] @@ -27,7 +29,7 @@ bitcoin-ffi = { git = "https://github.com/benalleng/bitcoin-ffi.git", rev = "8e3 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" 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; From 1d7da1a1cc4597bfdaa20643c799d384cc4d97ce Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 13 Nov 2025 15:03:50 -0500 Subject: [PATCH 06/11] Feature gate uniffi-dart in payjoin-ffi uniffi-dart has a built-in tokio dependency which is incompatible with WASM. We should investigate whether that dependency is necessary for uniffi-dart or how it might be feature-gate, but for now just feature gate uniffi-dart here. --- payjoin-ffi/Cargo.toml | 5 +++-- payjoin-ffi/build.rs | 1 + payjoin-ffi/dart/scripts/generate_bindings.sh | 10 ++++----- payjoin-ffi/uniffi-bindgen.rs | 21 ++++++++++--------- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 882d7e6c6..c4186a1d5 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -7,6 +7,7 @@ exclude = ["tests"] [features] default = ["io"] +dart = ["dep:uniffi-dart"] io = ["payjoin/io"] _test-utils = ["payjoin-test-utils", "tokio", "io"] _manual-tls = ["payjoin/_manual-tls"] @@ -21,7 +22,7 @@ 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" @@ -36,7 +37,7 @@ 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/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/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/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, From f7dbefaef9042f01b3a2eed3d6f011448e0c28ca Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 13 Nov 2025 15:07:46 -0500 Subject: [PATCH 07/11] Add `wasm_js` feature flag in payjoin-ffi This allows conditionally enabling the `getrandom/js` feature flag to avoid bloat on other platforms https://docs.rs/getrandom/latest/getrandom/#webassembly-support. getrandom is already an implicit dependency and unfortunately Cargo doesn't support enabling feature flags on transitive dependencies directly, so it must be added to the explicit dependencies. --- payjoin-ffi/Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index c4186a1d5..16b413482 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -11,6 +11,7 @@ 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" @@ -27,6 +28,7 @@ uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", rev = "0 [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" } From 990930b2e3b462e8a2ded091a8aa7fa44d2c6488 Mon Sep 17 00:00:00 2001 From: spacebear Date: Fri, 14 Nov 2025 11:05:46 -0500 Subject: [PATCH 08/11] Add javascript github workflow --- .github/workflows/javascript.yml | 50 +++++++++++++++++++ .../javascript/scripts/generate_bindings.sh | 7 +++ 2 files changed, 57 insertions(+) create mode 100644 .github/workflows/javascript.yml 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/payjoin-ffi/javascript/scripts/generate_bindings.sh b/payjoin-ffi/javascript/scripts/generate_bindings.sh index e6904044f..73f4e7d7d 100755 --- a/payjoin-ffi/javascript/scripts/generate_bindings.sh +++ b/payjoin-ffi/javascript/scripts/generate_bindings.sh @@ -14,6 +14,13 @@ if [[ "$OS" == "Darwin" ]]; then 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 echo "All done!" From 4568bb40eee8b77f03855b76ab883a9a4939b090 Mon Sep 17 00:00:00 2001 From: spacebear Date: Fri, 14 Nov 2025 15:53:32 -0500 Subject: [PATCH 09/11] Update payjoin-ffi READMEs --- payjoin-ffi/README.md | 3 ++- payjoin-ffi/dart/README.md | 4 ++-- payjoin-ffi/javascript/README.md | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 payjoin-ffi/javascript/README.md 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/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/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 +``` From cc5f3280ce9bdb8caea4cfe80e6db8ddff173718 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 18 Nov 2025 17:59:39 -0500 Subject: [PATCH 10/11] Add JS test-utils payjoin-test-utils requires native I/O for networking operations, process spawning, and filesystem operations. WASM cannot support these, so the JS payjoin bindings do not include test utils (or any io for that matter). This is problematic for writing integration tests. This commit introduces a new, thin binding layer exclusively for payjoin-test-utils, that compiles to a native Node.js add-on. The production code stays as WASM (via uniffi-bindgen-react-native) for cross-platform compatibility, and the test infrastructure is available via platform-specific binaries generated by napi-rs. --- payjoin-ffi/javascript/.gitignore | 4 + payjoin-ffi/javascript/package.json | 1 + .../javascript/scripts/generate_bindings.sh | 1 + payjoin-ffi/javascript/test-utils/Cargo.toml | 30 ++++ payjoin-ffi/javascript/test-utils/README.md | 7 + payjoin-ffi/javascript/test-utils/build.rs | 3 + payjoin-ffi/javascript/test-utils/index.js | 87 ++++++++++ .../javascript/test-utils/package-lock.json | 32 ++++ .../javascript/test-utils/package.json | 15 ++ payjoin-ffi/javascript/test-utils/src/lib.rs | 161 ++++++++++++++++++ 10 files changed, 341 insertions(+) create mode 100644 payjoin-ffi/javascript/test-utils/Cargo.toml create mode 100644 payjoin-ffi/javascript/test-utils/README.md create mode 100644 payjoin-ffi/javascript/test-utils/build.rs create mode 100644 payjoin-ffi/javascript/test-utils/index.js create mode 100644 payjoin-ffi/javascript/test-utils/package-lock.json create mode 100644 payjoin-ffi/javascript/test-utils/package.json create mode 100644 payjoin-ffi/javascript/test-utils/src/lib.rs diff --git a/payjoin-ffi/javascript/.gitignore b/payjoin-ffi/javascript/.gitignore index aac1da949..0762b5e3c 100644 --- a/payjoin-ffi/javascript/.gitignore +++ b/payjoin-ffi/javascript/.gitignore @@ -5,3 +5,7 @@ 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/package.json b/payjoin-ffi/javascript/package.json index 1b8c14e0a..235940261 100644 --- a/payjoin-ffi/javascript/package.json +++ b/payjoin-ffi/javascript/package.json @@ -12,6 +12,7 @@ "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" }, diff --git a/payjoin-ffi/javascript/scripts/generate_bindings.sh b/payjoin-ffi/javascript/scripts/generate_bindings.sh index 73f4e7d7d..5f4ff7b5c 100755 --- a/payjoin-ffi/javascript/scripts/generate_bindings.sh +++ b/payjoin-ffi/javascript/scripts/generate_bindings.sh @@ -22,5 +22,6 @@ cd ../.. rustup target add wasm32-unknown-unknown npm run build +npm run build:test-utils echo "All done!" 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) }, + }) +} From cf265f8e8d5964ad0f3cd9d0321802a87e680291 Mon Sep 17 00:00:00 2001 From: spacebear Date: Tue, 18 Nov 2025 18:17:08 -0500 Subject: [PATCH 11/11] Add TS integration test --- .../javascript/test/integration.test.ts | 570 ++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 payjoin-ffi/javascript/test/integration.test.ts 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); +});