Generate Groth16 proofs on mobile, verify on Solana.
Goal: Prove the E2E flow works — mobile app generates a ZK proof, Solana program verifies it.
- Circuit:
multiplier2(Circom/Groth16, BN254) - Mobile: React Native + mopro
- Chain: Solana (local validator → devnet)
- Verifier: groth16-solana (by Light Protocol)
mobile-solana-e2e/
├── src/ # mopro Rust lib (proof generation)
├── react-native/ # Mobile app
│ └── src/
│ ├── App.tsx # Main app with "Verify on Solana" button
│ ├── solana.ts # Solana client & proof conversion
│ └── polyfills.ts # Buffer/crypto polyfills for RN
├── test-vectors/ # ZK keys (zkey, wasm)
├── solana-verifier/ # Anchor program (proof verification)
│ ├── programs/
│ │ └── solana-verifier/
│ │ └── src/
│ │ ├── lib.rs # Main program with verify_proof
│ │ └── verifying_key.rs # Embedded VK (generated)
│ ├── scripts/
│ │ └── parse-vk.js # VK converter script
│ ├── tests/
│ │ └── solana-verifier.ts # Test with proof conversion
│ ├── vk.json # Verification key (from snarkjs)
│ └── Anchor.toml
├── Config.toml # mopro config
└── Cargo.toml
- mopro prerequisites — Rust, cmake, Xcode/Android Studio
- mopro getting started
- Solana CLI:
sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)" - Anchor CLI:
cargo install --git https://github.com/coral-xyz/anchor avm --force && avm install latest && avm use latest - snarkjs:
npm install -g snarkjs
# Build native bindings
mopro build
# Run React Native app
cd react-native
npm install
npm install @react-native-clipboard/clipboard # For copy functionality
cd ios && pod install && cd ..
npm run ios # Runs on iPhone 16 Pro simulatorcd solana-verifier
# 1. Export verification key from zkey (if not done)
snarkjs zkey export verificationkey ../test-vectors/circom/multiplier2_final.zkey vk.json
# 2. Generate Rust verifying key from vk.json
node scripts/parse-vk.js vk.json > programs/solana-verifier/src/verifying_key.rs
# 3. Start local validator (separate terminal)
solana-test-validator --ledger .ledger
# 4. Create keypair (first time only)
solana-keygen new --outfile ~/.config/solana/id.json
# 5. Build and deploy
anchor build
anchor deploy
# 6. Test
anchor test --skip-local-validatorThe key insight from debugging this spike: groth16-solana requires negating proof.A's y-coordinate.
The Groth16 verification equation uses -A rather than A. The groth16-solana library expects the proof with this negation already applied.
{
"a": {"x": "123...", "y": "456...", "z": "1"},
"b": {"x": ["...", "..."], "y": ["...", "..."], "z": ["1", "0"]},
"c": {"x": "789...", "y": "012...", "z": "1"},
"protocol": "groth16",
"curve": "bn128"
}// BN254 field prime
const FIELD_PRIME = BigInt("21888242871839275222246405745257275088696311157297823662689037894645226208583");
// Convert decimal string to 32-byte big-endian array
function toBE32(decimal: string): number[] {
const hex = BigInt(decimal).toString(16).padStart(64, '0');
return hex.match(/.{2}/g)!.map(b => parseInt(b, 16));
}
// Negate field element: -y mod p
function negateField(y: string): string {
return ((FIELD_PRIME - BigInt(y)) % FIELD_PRIME).toString();
}
// Convert G1 point to 64 bytes, with optional negation
function g1ToBytes(point: {x: string, y: string}, negate = false): number[] {
const y = negate ? negateField(point.y) : point.y;
return [...toBE32(point.x), ...toBE32(y)];
}
// Convert G2 point to 128 bytes (c1, c0, c1, c0 ordering)
function g2ToBytes(point: {x: string[], y: string[]}): number[] {
return [
...toBE32(point.x[1]), // x.c1
...toBE32(point.x[0]), // x.c0
...toBE32(point.y[1]), // y.c1
...toBE32(point.y[0]), // y.c0
];
}
// Usage:
const proofA = g1ToBytes(proof.a, true); // ⚠️ NEGATE proof_a!
const proofB = g2ToBytes(proof.b);
const proofC = g1ToBytes(proof.c, false); // Don't negate C
const publicInputs = inputs.map(i => toBE32(i));verify_proof(
proof_a: [u8; 64], // G1: x || (-y) (NEGATED!)
proof_b: [u8; 128], // G2: x.c1 || x.c0 || y.c1 || y.c0
proof_c: [u8; 64], // G1: x || y
public_inputs: [[u8; 32]; 2]
)The parse-vk.js script converts snarkjs verification key format to Rust:
node scripts/parse-vk.js vk.json > programs/solana-verifier/src/verifying_key.rsNotes:
- G2 point ordering:
c1, c0, c1, c0(notc0, c1) - groth16-solana has a typo: field is
vk_gamme_g2(notvk_gamma_g2)
- Mobile app generates proof:
(a=3, b=4) → proof + public_inputs["12", "3"] - Tap "Verify on Solana" button
- App converts proof (negates A, formats bytes) via
solana.ts - App sends transaction to Solana validator
- Program verifies using groth16-solana and embedded VK
- App displays result ✅ or ❌
- Generate proof in app → Copy to clipboard
- Paste into
solana-verifier/tests/solana-verifier.ts - Run
anchor test --skip-local-validator
For multiplier2 circuit:
public_inputs[0]=12(output: a × b)public_inputs[1]=3(public input: a)b = 4remains private
- Program ID:
9wsdJR6iVCN6gMVEsQbN9hUxp6SG43T86VGgeTm4w6L1 - Cluster: localhost (solana-test-validator)
Terminal 1: Start Solana Validator
cd solana-verifier
solana-test-validator --ledger .ledgerTerminal 2: Deploy Contract (wait for validator to start)
cd solana-verifier
anchor deployTerminal 3: Run Mobile App
cd react-native
npm install # Installs @solana/web3.js and polyfills
cd ios && pod install && cd ..
npm run ios- Enter values for
aandb(e.g., 3 and 4) - Tap "Generate Circom Proof"
- Wait for proof to appear
- Tap "🔗 Verify on Solana"
- See result: ✅ Verified or ❌ Failed
Success:
✅ Verified on Solana!
Tx: 2Ft4hMJU7MB9RDuX...
Validator not running:
❌ Cannot connect to Solana (is validator running?)
- Mobile proof generation (mopro/Circom)
- Solana verifier program scaffolding
- Verification key export & conversion
- Build & deploy verifier
- Manual verification test ✅
- E2E integration (mobile → Solana directly) ✅
This spike involved significant debugging to get the proof format correct.
When Solana verification kept failing, we needed to determine if the issue was:
- The proof itself (invalid), or
- The byte format conversion (valid proof, wrong encoding)
Solution: Export the proof in snarkjs format and verify it outside Solana:
# The test exports proof to snarkjs format
anchor test --skip-local-validator
# Then verify with snarkjs (same vk.json, same zkey)
snarkjs groth16 verify vk.json test_public.json test_proof.json
# Output: [INFO] snarkJS: OK!Why this works:
- snarkjs uses the same VK and proof format as the mobile app
- If snarkjs says "OK", the proof is mathematically valid
- Any Solana failure must be a format/encoding issue, not a proof issue
This narrowed the problem to byte conversion, leading us to discover the proof_a negation requirement.
- Initial failures: Verification kept failing despite snarkjs saying "OK"
- Insight: The proof is valid, so the issue is format conversion
- Discovery: groth16-solana docs.rs example shows
proof_a.neg() - Root cause: groth16-solana requires
proof_ato be negated (y-coordinate) - Solution: Negate y-coordinate by computing
FIELD_PRIME - y
The G2 ordering (c1, c0 vs c0, c1) was also important to match between VK and proof.
"@solana/web3.js": "^1.98.0",
"buffer": "^6.0.3",
"react-native-get-random-values": "^1.11.0"src/polyfills.ts— Must be imported first in App.tsx for Buffer/crypto supportsrc/solana.ts— Contains all conversion logic and Solana client code
- App generates keypair on-the-fly (ephemeral, for testing)
- Requests airdrop from local validator (1 SOL for fees)
- Builds transaction with proof data using Anchor IDL discriminator
- Sends to
http://127.0.0.1:8899(iOS simulator can access localhost)
- Replace ephemeral keypair with wallet adapter (Phantom, Solflare)
- Use devnet/mainnet RPC URL
- Handle network errors gracefully
- Consider transaction simulation before sending
- mopro docs
- groth16-solana (Light Protocol)
- groth16-solana docs.rs — check the usage example!
- Anchor docs
- snarkjs
- Solana alt_bn128 syscalls
- @solana/web3.js