● 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 # Pre-built Rust .so
add_library(salvium_crypto SHARED IMPORTED) add_library(salvium_crypto SHARED IMPORTED)
set_target_properties(salvium_crypto PROPERTIES 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 # JSI bridge module
add_library(${PROJECT_NAME} SHARED add_library(${PROJECT_NAME} SHARED
../../cpp/SalviumCryptoModule.cpp ../cpp/SalviumCryptoModule.cpp
../cpp/OnLoad.cpp
) )
target_include_directories(${PROJECT_NAME} PRIVATE target_include_directories(${PROJECT_NAME} PRIVATE
../../cpp ../cpp
../../../../crates/salvium-crypto/include ../crates/salvium-crypto/include
) )
target_link_libraries(${PROJECT_NAME} target_link_libraries(${PROJECT_NAME}
@@ -39,10 +39,9 @@ android {
} }
} }
// Pre-built Rust .so files
sourceSets { sourceSets {
main { 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 "SalviumCryptoModule.h"
#include "../../crates/salvium-crypto/include/salvium_crypto.h" #include "salvium_crypto.h"
#include <jsi/jsi.h> #include <jsi/jsi.h>
#include <vector> #include <vector>
@@ -1,5 +1,3 @@
require 'json'
Pod::Spec.new do |s| Pod::Spec.new do |s|
s.name = "ExpoSalviumCrypto" s.name = "ExpoSalviumCrypto"
s.version = "0.1.0" s.version = "0.1.0"
@@ -11,17 +9,17 @@ Pod::Spec.new do |s|
s.platforms = { :ios => "13.0" } s.platforms = { :ios => "13.0" }
s.source = { :git => "https://github.com/salvium/salvium-js.git", :tag => s.version.to_s } 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.source_files = "../cpp/SalviumCryptoModule.{h,cpp}"
s.header_dir = "SalviumCrypto" s.header_dir = "SalviumCrypto"
# Rust static library # Rust static library (prebuilt universal binary)
s.vendored_libraries = "../lib/ios/libsalvium_crypto.a" s.vendored_libraries = "../prebuilt/ios/libsalvium_crypto.a"
# C header search path for salvium_crypto.h # 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 = { 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", "CLANG_CXX_LANGUAGE_STANDARD" => "c++17",
"OTHER_LDFLAGS" => "-lsalvium_crypto" "OTHER_LDFLAGS" => "-lsalvium_crypto"
} }
@@ -6,15 +6,16 @@
# Set ANDROID_NDK_HOME to your NDK path (e.g. ~/Android/Sdk/ndk/26.1.10909125) # Set ANDROID_NDK_HOME to your NDK path (e.g. ~/Android/Sdk/ndk/26.1.10909125)
# #
# Produces: # Produces:
# native/lib/android/arm64-v8a/libsalvium_crypto.so # prebuilt/android/arm64-v8a/libsalvium_crypto.so
# native/lib/android/armeabi-v7a/libsalvium_crypto.so # prebuilt/android/armeabi-v7a/libsalvium_crypto.so
# native/lib/android/x86_64/libsalvium_crypto.so # prebuilt/android/x86_64/libsalvium_crypto.so
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CRATE_DIR="$SCRIPT_DIR/../crates/salvium-crypto" ROOT_DIR="$SCRIPT_DIR/.."
OUT_DIR="$SCRIPT_DIR/lib/android" CRATE_DIR="$ROOT_DIR/crates/salvium-crypto"
OUT_DIR="$ROOT_DIR/prebuilt/android"
if [ -z "${ANDROID_NDK_HOME:-}" ]; then if [ -z "${ANDROID_NDK_HOME:-}" ]; then
echo "Error: ANDROID_NDK_HOME is not set." 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" cargo build --release --target "$target" --manifest-path "$CRATE_DIR/Cargo.toml"
mkdir -p "$OUT_DIR/$abi" 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" "$OUT_DIR/$abi/libsalvium_crypto.so"
done done
+5 -4
View File
@@ -4,13 +4,14 @@
# Prerequisites: # Prerequisites:
# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios # 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 set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CRATE_DIR="$SCRIPT_DIR/../crates/salvium-crypto" ROOT_DIR="$SCRIPT_DIR/.."
OUT_DIR="$SCRIPT_DIR/lib/ios" CRATE_DIR="$ROOT_DIR/crates/salvium-crypto"
OUT_DIR="$ROOT_DIR/prebuilt/ios"
TARGETS=( TARGETS=(
aarch64-apple-ios # Device (arm64) aarch64-apple-ios # Device (arm64)
@@ -31,7 +32,7 @@ mkdir -p "$OUT_DIR"
# Collect all .a paths # Collect all .a paths
LIBS=() LIBS=()
for target in "${TARGETS[@]}"; do for target in "${TARGETS[@]}"; do
LIBS+=("$CRATE_DIR/../../target/$target/release/libsalvium_crypto.a") LIBS+=("$CRATE_DIR/target/$target/release/libsalvium_crypto.a")
done done
lipo -create "${LIBS[@]}" -output "$OUT_DIR/libsalvium_crypto.a" 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; const { V, A, A1, B, r1, s1, d1, L, R } = proof;
// Monero/Salvium binary format for BulletproofPlus: // Monero/Salvium binary format for BulletproofPlus:
// varint(V.length), V[0..n] (32 bytes each)
// A (32), A1 (32), B (32) // A (32), A1 (32), B (32)
// r1 (32), s1 (32), d1 (32) // r1 (32), s1 (32), d1 (32)
// varint(L.length), L[0..n] (32 bytes each) // varint(L.length), L[0..n] (32 bytes each)
// varint(R.length), R[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 = []; const chunks = [];
// V (commitments)
chunks.push(_encodeVarint(V.length));
for (const v of V) chunks.push(v.toBytes());
// A, A1, B (points) // A, A1, B (points)
chunks.push(A.toBytes()); chunks.push(A.toBytes());
chunks.push(A1.toBytes()); chunks.push(A1.toBytes());
+3 -3
View File
@@ -9,7 +9,7 @@ import {
scalarToBytes, scalarToBytes,
bytesToPoint, bytesToPoint,
hashToScalar, hashToScalar,
hashToPoint, hashToPointMonero,
initGenerators, initGenerators,
initTranscript, initTranscript,
parseProof, parseProof,
@@ -152,9 +152,9 @@ test('hashToScalar produces different output for different input', () => {
assertTrue(scalar1 !== scalar2, 'Different inputs should produce different outputs'); 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 data = new TextEncoder().encode('test data');
const point = hashToPoint(data); const point = hashToPointMonero(data);
assertExists(point); assertExists(point);
// Point should not be identity // Point should not be identity
assertTrue(!point.equals(Point.ZERO), 'Should not be identity point'); 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 fee
}); });
// No change when amounts match exactly // Change output is always added for privacy (even with 0 amount)
assertEqual(tx.prefix.vout.length, 1, 'Should have only 1 output when no change'); assertEqual(tx.prefix.vout.length, 2, 'Should have 2 outputs (destination + zero-change)');
assertEqual(tx._meta.changeIndex, -1, 'Change index should be -1'); assertEqual(tx._meta.changeIndex, -1, 'Change index should be -1 for zero change');
}); });
test('buildTransaction with multiple inputs', () => { test('buildTransaction with multiple inputs', () => {
+2 -2
View File
@@ -158,8 +158,8 @@ test('SYNC_STATUS has correct values', () => {
assertEqual(SYNC_STATUS.ERROR, 'error'); assertEqual(SYNC_STATUS.ERROR, 'error');
}); });
test('DEFAULT_BATCH_SIZE is 10', () => { test('DEFAULT_BATCH_SIZE is 100', () => {
assertEqual(DEFAULT_BATCH_SIZE, 10); assertEqual(DEFAULT_BATCH_SIZE, 100);
}); });
test('SYNC_UNLOCK_BLOCKS is 10', () => { test('SYNC_UNLOCK_BLOCKS is 10', () => {