@avalabs/crypto-nitro
v0.3.2
Published
React Native Nitro Modules wrapper around @avalabs/crypto-core. Exposes pubkey-only address derivation (xpub batch + per-chain encoders for EVM / BTC / SVM / Avalanche) plus the secp256k1/Schnorr/Ed25519 primitives to iOS and Android via a JS-thread-free
Readme
@avalabs/crypto-nitro
React Native Nitro Modules wrapper around @avalabs/crypto-core. Exposes
the pubkey-only batched address derivation surface (xpub batch + per-chain
encoders for EVM / BTC / SVM / Avalanche) and the secp256k1 / Schnorr /
Ed25519 primitives to iOS and Android via a JS-thread-free C++ bridge.
Most consumers should depend on @avalabs/crypto-sdk instead — it
routes Metro to this package via the "react-native" exports condition
and to @avalabs/crypto-wasm for web / extension / Node via
"browser" / "import". The SDK-mirrored exports here carry
@deprecated JSDoc tags to nudge consumers toward the unified entry
point.
Layout
src/
specs/crypto.nitro.ts Nitro spec — consumed by `nitrogen` codegen
index.ts Public TypeScript API (init + xpub batch +
4 per-chain encoders + 7 primitives +
MAX_BATCH_SIZE)
cpp/
CryptoHybrid.{hpp,cpp} C++ bridge — extends generated HybridCryptoSpec
and delegates to crypto-core's api/ headers
nitrogen/generated/ CODEGEN OUTPUT — committed to git and shipped
in the npm tarball. Regenerated from
src/specs/crypto.nitro.ts on spec edits; the
diff is committed alongside the spec change.
Local regen: `pnpm --filter @avalabs/crypto-nitro
codegen`. Output includes:
- shared/c++/HybridCryptoSpec.{hpp,cpp}
- shared/c++/DerivedSecp256k1Addresses.hpp
- shared/c++/DerivedAvalancheAddresses.hpp
- shared/c++/ExtendedPublicKey.hpp
- ios/* (autolinking, Swift bridge)
- android/* (autolinking, Kotlin bridge)
android/
build.gradle RN autolinking entry point
CMakeLists.txt NDK build — adds crypto-core as subdirectory
src/main/ Android-side Kotlin (generated)
ios/ iOS-side Swift / Obj-C++ (generated)
AvalabsCryptoNitro.podspec iOS Pod spec — rsyncs crypto-core sources
nitro.json Nitro module configurationSetup
One-time
# 1. Vendor libsecp256k1 into ../../packages-internal/crypto-core/deps/secp256k1
pnpm --filter @avalabs/crypto-nitro run setup:secp256k1
# 2. Install workspace deps (pulls in react, react-native, react-native-nitro-modules)
pnpm installRegenerate codegen
The nitrogen/generated/ tree is committed to git and shipped in the
published tarball, matching the convention used by upstream Nitro
libraries (e.g. react-native-nitro-sqlite). Consumers do not run
nitrogen — they install the package and CocoaPods / Gradle autolinking
picks up the pre-generated files directly.
Regenerate after every edit to src/specs/crypto.nitro.ts and commit
the diff alongside the spec change:
pnpm --filter @avalabs/crypto-nitro codegen
git add packages/crypto-nitro/nitrogen/generatedThe codegen script is also wired into the turbo pipeline (typecheck,
lint, test, build all dependsOn: ["codegen"]), so running any of
those tasks transparently refreshes the tree if the spec is ahead.
Consumer setup
A downstream RN app (e.g. core-mobile) adds @avalabs/crypto-nitro and
react-native-nitro-modules to its package.json and builds normally:
pnpm install
cd ios && pod install
pnpm ios # or react-native run-iosNo nitrogen devDependency, no postinstall hook — the published
tarball already contains the generated C++ / Swift / Kotlin / Gradle /
Ruby files under nitrogen/generated/, and the podspec + Android
CMakeLists.txt reference them at that path.
How a downstream RN app consumes this
- App adds
@avalabs/crypto-sdk(the canonical entry point) plus@avalabs/crypto-nitroandreact-native-nitro-modulesto itspackage.json. The latter two are needed so React Native's autolinker finds the native module — even though application code imports from@avalabs/crypto-sdk, the bundler still has to be able to resolve@avalabs/crypto-nitrofor the codegen + podspec. - React Native's autolinking discovers the package by scanning
node_modulesforreact-native.config.jsfiles. Ourreact-native.config.jsdeclares the iOS + Android module locations. - iOS:
cd ios && pod install— CocoaPods picks upAvalabsCryptoNitro.podspec. Itsprepare_commandrsyncspackages-internal/crypto-core/intovendor/crypto-core/(real files, not a symlink — CocoaPods dropssource_fileswhose realpath escapes the pod root). The Pod then compiles all of crypto-core + libsecp256k1- our bridge into a single static lib.
- Android:
./gradlew :app:assembleDebug— Gradle's RN autolinking adds this module to the build graph.android/build.gradledeclares CMake as the native build system;android/CMakeLists.txtdoesadd_subdirectory(${CRYPTO_CORE_DIR})to pull in crypto-core's CMake tree, then links the resultinglibavalabs-crypto-core.ainto ourlibAvalabsCryptoNitro.so. - At runtime,
react-native-nitro-modulesfinds the auto-linked module andNitroModules.createHybridObject<Crypto>('Crypto')returns the C++-backed implementation. The hybrid handle is created lazily on first use;init()forces resolution so wiring problems surface early.
TypeScript API
ℹ️ Cross-platform consumers should import these from
@avalabs/crypto-sdk— same signatures for the address-derivation surface, but the SDK picks the right backend at bundle time. The direct exports below carry@deprecatedJSDoc tags for everything the SDK re-exports. The primitive surface (sign,verify,signSchnorr,verifySchnorr,getPublicKey,getExtendedPublicKey,pointAddScalar) is not re-exported by the SDK — direct import from this package is the supported path for those.
import {
// Address derivation — SDK has equivalents (prefer those)
init,
deriveAddressesFromXpubs,
deriveAddressesForEvm,
deriveAddressesForSvm,
deriveAddressesForBtc,
deriveAddressesForAvalanche,
MAX_BATCH_SIZE,
// Primitives — nitro-only, no SDK equivalent
sign,
verify,
signSchnorr,
verifySchnorr,
getPublicKey,
getExtendedPublicKey,
pointAddScalar,
// Types
type DerivedSecp256k1Addresses,
type DerivedAvalancheAddresses,
type ExtendedPublicKeyResult,
} from '@avalabs/crypto-nitro';
// 1. Force native-module resolution. Surfaces a clear error early if
// autolinking isn't wired up. Idempotent after first call.
await init();
// 2. Xpub-driven batch derivation (Ledger / watch-only). BIP-32 walk +
// address encoding runs on a native background thread so the JS
// thread stays free. `avalancheXpubs[i]` and `accountIndices[i]`
// pair up.
const rows: DerivedSecp256k1Addresses[] = await deriveAddressesFromXpubs(
'xpub...', // EVM xpub at m/44'/60'/0'
['xpub...', 'xpub...'], // Avalanche xpubs (one per account)
false, // isTestnet
[0, 1], // account indices
);
// rows[i] === { accountIndex, evm, btc, avm, pvm, coreEth }
// 3. Per-chain encoders for batches of already-derived pubkeys. Each
// hops to a native background thread; 1024-pubkey batches don't
// block the UI. Inputs accept Uint8Array | ArrayBuffer per element.
const evmAddrs = await deriveAddressesForEvm(compressedSecp256k1Pubkeys33);
const btcAddrs = await deriveAddressesForBtc(compressedSecp256k1Pubkeys33, false);
const svmAddrs = await deriveAddressesForSvm(ed25519Pubkeys32);
const avaxBundles: DerivedAvalancheAddresses[] = await deriveAddressesForAvalanche(
compressedAvaxPubkeys33, // drives X- / P-
compressedEvmPubkeys33, // drives C-
false,
);
// avaxBundles[i] === { x, p, coreEth }
// 4. Low-level primitives — nitro-only.
// getPublicKey accepts hex / Uint8Array / ArrayBuffer / bigint.
const pubkey = getPublicKey(secretKey, /* isCompressed = */ true);
const ecdsaSig = sign(secretKey, msg32); // returns compact(64)
const okEcdsa = verify(pubkey, msg32, ecdsaSig); // accepts DER or compact
// BIP-340 Schnorr. auxRand defaults to 32 bytes from
// globalThis.crypto.getRandomValues; THROWS if no CSPRNG is available
// (silently falling back to zeros would weaken §3.3 side-channel
// protection). On RN, `import 'react-native-get-random-values'` first.
const schnorrSig = signSchnorr(msg32, secretKey);
const okSchnorr = verifySchnorr(pubkey, msg32, schnorrSig);
// Ed25519 extended public key (RFC 8032 §5.1.5). The scalar is
// reconstructed in JS from `head` as a bigint to keep secret-derived
// bytes out of the engine's interned string table.
const xpk: ExtendedPublicKeyResult = getExtendedPublicKey(secretKey);
// BIP-32 public-child tweak: P' = P + tweak·G
const child = pointAddScalar(pubkey, tweak32);
// Hard upper bound on items per batch (1024). Mirrors MAX_BATCH_SIZE
// in CryptoHybrid.cpp.
console.log(MAX_BATCH_SIZE);What this package does NOT do
- Mnemonic / private-key ingestion via the bridge. The bridge
surface is deliberately pubkey-only. PBKDF2-based seed derivation and
BIP-32 hardened walks run in caller code (
@scure/bip39+@scure/bip32) or on a Ledger device; only pubkeys and xpubs cross the JS↔C++ boundary. This matches CP-14228 § Security Architecture's handle-based contract. - OpenSSL backend on iOS/Android. Currently links the portable
hash/HMAC backend. OpenSSL is already linked on mobile for TLS;
switching to it would match the PR #3799 perf characteristics.
Tracked as a follow-up — swap the
add_subdirectory(crypto-core)line to setCRYPTO_BACKEND=opensslonce that backend exists in crypto-core.
Verification
This package currently has no in-package automated runtime tests — it requires an iOS or Android build environment with the Nitro Modules runtime to load. Verification flows:
- TypeScript:
pnpm --filter @avalabs/crypto-nitro typecheck(runs in CI on every push). - C++ correctness:
pnpm --filter @avalabs/crypto-core testruns crypto-core's host CTest suite (32 cases covering libsecp256k1 integration, the portable hash backend, BIP-32 derivation against the canonical BIP-39 abandon × 11 about vector, ECDSA / Schnorr / Ed25519 RFC test vectors, and parse_xpub against BIP-32 test vector 1). Any algorithmic regression in crypto-core surfaces here before the Nitro layer ever sees it. - Mobile end-to-end (manual / local): Install this package into a
fresh RN app (or use the
playground.local/crypto-nitro-playgroundworkspace), run a known xpub throughderiveAddressesFromXpubs, and confirm the EVM address matches an external reference (MetaMask, ethers.js, the canonical0x9858EfFD232B4033E47d90003D41EC34EcaEda94for the abandon mnemonic atm/44'/60'/0'/0/0).
