● Restructure native module for Expo auto-discovery, fix 3 test failures

Move native code from native/ to Expo-compatible layout with android/
  and ios/ at package root. Add Android JNI bridge (OnLoad.cpp,
  ExpoSalviumCryptoModule.java, ExpoSalviumCryptoPackage.java).
  Prebuilt binaries now go in prebuilt/ instead of native/lib/.

  Test fixes:
  - wallet-sync: update stale DEFAULT_BATCH_SIZE assertion (10 -> 100)
  - bulletproofs+: fix hashToPoint -> hashToPointMonero rename in test,
    fix serializeProof to include V array matching parseProof format
  - transaction-builder: expect 2 outputs for exact-amount tx (zero-change
    output is always added for privacy)
This commit is contained in:
Matt Hess
2026-02-09 22:50:15 +00:00
parent 23afd269e1
commit 01bcb742ae
15 changed files with 139 additions and 33 deletions
@@ -10,17 +10,18 @@ find_package(ReactAndroid REQUIRED CONFIG)
# Pre-built Rust .so
add_library(salvium_crypto SHARED IMPORTED)
set_target_properties(salvium_crypto PROPERTIES
IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/../../lib/android/${ANDROID_ABI}/libsalvium_crypto.so"
IMPORTED_LOCATION "${CMAKE_SOURCE_DIR}/../prebuilt/android/${ANDROID_ABI}/libsalvium_crypto.so"
)
# JSI bridge module
add_library(${PROJECT_NAME} SHARED
../../cpp/SalviumCryptoModule.cpp
../cpp/SalviumCryptoModule.cpp
../cpp/OnLoad.cpp
)
target_include_directories(${PROJECT_NAME} PRIVATE
../../cpp
../../../../crates/salvium-crypto/include
../cpp
../crates/salvium-crypto/include
)
target_link_libraries(${PROJECT_NAME}
@@ -39,10 +39,9 @@ android {
}
}
// Pre-built Rust .so files
sourceSets {
main {
jniLibs.srcDirs = ["../../lib/android"]
jniLibs.srcDirs = ["../prebuilt/android"]
}
}
}
+3
View File
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.salvium.crypto">
</manifest>
@@ -0,0 +1,53 @@
package com.salvium.crypto;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.module.annotations.ReactModule;
/**
* React Native module that installs the JSI crypto bindings.
*
* The actual crypto functions are exposed via JSI (C++ -> Rust FFI),
* not through the React Native bridge. This Java module only handles
* loading the native library and calling the C++ install function.
*/
@ReactModule(name = ExpoSalviumCryptoModule.NAME)
public class ExpoSalviumCryptoModule extends ReactContextBaseJavaModule {
public static final String NAME = "ExpoSalviumCrypto";
static {
System.loadLibrary("ExpoSalviumCrypto");
System.loadLibrary("salvium_crypto");
}
public ExpoSalviumCryptoModule(ReactApplicationContext reactContext) {
super(reactContext);
}
@Override
public String getName() {
return NAME;
}
/**
* Called from JS to install the JSI bindings.
* Must be called after the JSI runtime is available.
*/
@ReactMethod(isBlockingSynchronousMethod = true)
public boolean install() {
try {
ReactApplicationContext context = getReactApplicationContext();
long jsiRuntimePtr = context.getJavaScriptContextHolder().get();
if (jsiRuntimePtr == 0) {
return false;
}
nativeInstall(jsiRuntimePtr);
return true;
} catch (Exception e) {
return false;
}
}
private native void nativeInstall(long jsiRuntimePtr);
}
@@ -0,0 +1,24 @@
package com.salvium.crypto;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ExpoSalviumCryptoPackage implements ReactPackage {
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new ExpoSalviumCryptoModule(reactContext));
return modules;
}
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* OnLoad.cpp — Android JNI entry point
*
* Registers the JSI install function so Java can call it
* during React Native bridge initialization.
*/
#include <fbjni/fbjni.h>
#include <jsi/jsi.h>
#include <ReactCommon/CallInvokerHolder.h>
#include "SalviumCryptoModule.h"
extern "C" JNIEXPORT void JNICALL
Java_com_salvium_crypto_ExpoSalviumCryptoModule_nativeInstall(
JNIEnv *env, jobject thiz, jlong jsiRuntimePtr) {
auto *rt = reinterpret_cast<facebook::jsi::Runtime *>(jsiRuntimePtr);
if (rt) {
salvium::install(*rt);
}
}
@@ -8,7 +8,7 @@
*/
#include "SalviumCryptoModule.h"
#include "../../crates/salvium-crypto/include/salvium_crypto.h"
#include "salvium_crypto.h"
#include <jsi/jsi.h>
#include <vector>
@@ -1,5 +1,3 @@
require 'json'
Pod::Spec.new do |s|
s.name = "ExpoSalviumCrypto"
s.version = "0.1.0"
@@ -11,17 +9,17 @@ Pod::Spec.new do |s|
s.platforms = { :ios => "13.0" }
s.source = { :git => "https://github.com/salvium/salvium-js.git", :tag => s.version.to_s }
# C++ JSI source
# C++ JSI source (shared between iOS and Android)
s.source_files = "../cpp/SalviumCryptoModule.{h,cpp}"
s.header_dir = "SalviumCrypto"
# Rust static library
s.vendored_libraries = "../lib/ios/libsalvium_crypto.a"
# Rust static library (prebuilt universal binary)
s.vendored_libraries = "../prebuilt/ios/libsalvium_crypto.a"
# C header search path for salvium_crypto.h
s.preserve_paths = "../../crates/salvium-crypto/include/**"
s.preserve_paths = "../crates/salvium-crypto/include/**"
s.pod_target_xcconfig = {
"HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/../../crates/salvium-crypto/include\"",
"HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/../crates/salvium-crypto/include\"",
"CLANG_CXX_LANGUAGE_STANDARD" => "c++17",
"OTHER_LDFLAGS" => "-lsalvium_crypto"
}
@@ -6,15 +6,16 @@
# Set ANDROID_NDK_HOME to your NDK path (e.g. ~/Android/Sdk/ndk/26.1.10909125)
#
# Produces:
# native/lib/android/arm64-v8a/libsalvium_crypto.so
# native/lib/android/armeabi-v7a/libsalvium_crypto.so
# native/lib/android/x86_64/libsalvium_crypto.so
# prebuilt/android/arm64-v8a/libsalvium_crypto.so
# prebuilt/android/armeabi-v7a/libsalvium_crypto.so
# prebuilt/android/x86_64/libsalvium_crypto.so
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CRATE_DIR="$SCRIPT_DIR/../crates/salvium-crypto"
OUT_DIR="$SCRIPT_DIR/lib/android"
ROOT_DIR="$SCRIPT_DIR/.."
CRATE_DIR="$ROOT_DIR/crates/salvium-crypto"
OUT_DIR="$ROOT_DIR/prebuilt/android"
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
echo "Error: ANDROID_NDK_HOME is not set."
@@ -65,7 +66,7 @@ for target in "${!TARGET_ABI[@]}"; do
cargo build --release --target "$target" --manifest-path "$CRATE_DIR/Cargo.toml"
mkdir -p "$OUT_DIR/$abi"
cp "$CRATE_DIR/../../target/$target/release/libsalvium_crypto.so" \
cp "$CRATE_DIR/target/$target/release/libsalvium_crypto.so" \
"$OUT_DIR/$abi/libsalvium_crypto.so"
done
+5 -4
View File
@@ -4,13 +4,14 @@
# Prerequisites:
# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
#
# Produces: native/lib/ios/libsalvium_crypto.a (universal fat binary via lipo)
# Produces: prebuilt/ios/libsalvium_crypto.a (universal fat binary via lipo)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CRATE_DIR="$SCRIPT_DIR/../crates/salvium-crypto"
OUT_DIR="$SCRIPT_DIR/lib/ios"
ROOT_DIR="$SCRIPT_DIR/.."
CRATE_DIR="$ROOT_DIR/crates/salvium-crypto"
OUT_DIR="$ROOT_DIR/prebuilt/ios"
TARGETS=(
aarch64-apple-ios # Device (arm64)
@@ -31,7 +32,7 @@ mkdir -p "$OUT_DIR"
# Collect all .a paths
LIBS=()
for target in "${TARGETS[@]}"; do
LIBS+=("$CRATE_DIR/../../target/$target/release/libsalvium_crypto.a")
LIBS+=("$CRATE_DIR/target/$target/release/libsalvium_crypto.a")
done
lipo -create "${LIBS[@]}" -output "$OUT_DIR/libsalvium_crypto.a"
+6 -1
View File
@@ -1082,14 +1082,19 @@ export function serializeProof(proof) {
const { V, A, A1, B, r1, s1, d1, L, R } = proof;
// Monero/Salvium binary format for BulletproofPlus:
// varint(V.length), V[0..n] (32 bytes each)
// A (32), A1 (32), B (32)
// r1 (32), s1 (32), d1 (32)
// varint(L.length), L[0..n] (32 bytes each)
// varint(R.length), R[0..n] (32 bytes each)
// NOTE: V (commitments) are NOT serialized — they're restored via outPk
// Must match parseProof format for roundtrip compatibility.
const chunks = [];
// V (commitments)
chunks.push(_encodeVarint(V.length));
for (const v of V) chunks.push(v.toBytes());
// A, A1, B (points)
chunks.push(A.toBytes());
chunks.push(A1.toBytes());
+3 -3
View File
@@ -9,7 +9,7 @@ import {
scalarToBytes,
bytesToPoint,
hashToScalar,
hashToPoint,
hashToPointMonero,
initGenerators,
initTranscript,
parseProof,
@@ -152,9 +152,9 @@ test('hashToScalar produces different output for different input', () => {
assertTrue(scalar1 !== scalar2, 'Different inputs should produce different outputs');
});
test('hashToPoint produces valid point', () => {
test('hashToPointMonero produces valid point', () => {
const data = new TextEncoder().encode('test data');
const point = hashToPoint(data);
const point = hashToPointMonero(data);
assertExists(point);
// Point should not be identity
assertTrue(!point.equals(Point.ZERO), 'Should not be identity point');
+3 -3
View File
@@ -352,9 +352,9 @@ test('buildTransaction with no change (exact amount)', () => {
fee
});
// No change when amounts match exactly
assertEqual(tx.prefix.vout.length, 1, 'Should have only 1 output when no change');
assertEqual(tx._meta.changeIndex, -1, 'Change index should be -1');
// Change output is always added for privacy (even with 0 amount)
assertEqual(tx.prefix.vout.length, 2, 'Should have 2 outputs (destination + zero-change)');
assertEqual(tx._meta.changeIndex, -1, 'Change index should be -1 for zero change');
});
test('buildTransaction with multiple inputs', () => {
+2 -2
View File
@@ -158,8 +158,8 @@ test('SYNC_STATUS has correct values', () => {
assertEqual(SYNC_STATUS.ERROR, 'error');
});
test('DEFAULT_BATCH_SIZE is 10', () => {
assertEqual(DEFAULT_BATCH_SIZE, 10);
test('DEFAULT_BATCH_SIZE is 100', () => {
assertEqual(DEFAULT_BATCH_SIZE, 100);
});
test('SYNC_UNLOCK_BLOCKS is 10', () => {