Post-Quantum Fund Recovery via ZK-Proofs
Shor Stop is a proof of concept that lets the owner of a legacy elliptic-curve account move funds into a post-quantum account without ever exposing the seed phrase. The user proves, in zero knowledge, that they know a BIP-39 mnemonic whose derived address matches the legacy account, and that the proof is bound to a specific post-quantum public key. A smart contract verifies the proof and authorises a one-time transfer.
When a cryptographically relevant quantum computer arrives, the elliptic-curve signatures that protect every legacy account on Bitcoin, Ethereum, and Solana become forgeable. Chains will almost certainly freeze those accounts to prevent theft, and the value inside them will be unreachable through the normal signing path. Shor Stop demonstrates a salvage route that the chain itself can still trust, by checking a single zero-knowledge proof instead of an elliptic-curve signature.
The proof system is a pure STARK from end to end. A SNARK wrapping would defeat the post-quantum soundness premise the moment a quantum-capable adversary appears, so any prover that relies on pairing-based curves is out of scope.
The goals of the proof of concept are:
- To measure a curated set of post-quantum prover engines on the realistic BIP-39 to address derivation pipeline, on hardware a user might actually own - a laptop and a flagship smartphone - and to report prover time, peak memory, proof size, and on-chain verification cost.
- To quantify the gap between off-chain feasibility and on-chain cost, in order to motivate chains to add precompiles or syscalls that make the salvage flow practical for ordinary users.
The four candidate engines are Binius, Plonky3, Jolt, and SP1. They span the relevant design space: a binary-tower constraint system, an AIR over a small prime field, and two zkVMs. SP1 is included for prover-cost comparison only - its ZK mode relies on a BN254 SNARK wrap, so it is not a secure candidate for the deployment, only an informative one.
I know a BIP-39 mnemonic
Msuch that
- the address derived from
Malong the chain-specific derivation path at indexderivation_indexequalslegacy_addr, and- the commitment
H(pq_pubkey ‖ chain_id ‖ program_id)matches the value committed in the public inputs.
| Field | Description |
|---|---|
legacy_addr |
The address being salvaged. |
pq_pubkey_commit |
H(pq_pubkey ‖ chain_id ‖ program_id) - binds the proof to a specific destination PQ public key on a specific chain. |
chain_id |
Identifier of the target chain (Ethereum only). |
program_id |
On-chain identity of the salvage verifier - Solana program ID or Ethereum contract address. Pins the proof to one deployment so a replay against a fork, clone, or upgraded program on the same chain fails (analogous to EIP-712's verifyingContract). |
derivation_index |
Which account under the mnemonic is being salvaged. |
Binding pq_pubkey into the statement is the core anti-front-running property. A validator or RPC operator who observes the proof in flight cannot redirect the funds to their own post-quantum key, because the proof is only valid for the exact public key the prover committed to.
A salvage registry is not required. Once a legacy address is drained, replaying the same proof against the same address succeeds but moves nothing. A proof cannot be replayed against a different address, because legacy_addr is a public input.
The circuit witnesses three consecutive computations and proves each was executed correctly.
| Stage | Primitive in-circuit | Cost note |
|---|---|---|
| mnemonic → seed | PBKDF2-HMAC-SHA512, 2048 iterations | 2,048 chained SHA-512 pairs. Around 85% of prover memory and time, and the main reason proofs are expensive. |
| seed → chain key | 3–4 hardened HMAC-SHA512 derivations (BIP-32 for Eth/BTC, SLIP-0010 for Solana) | Each step is one SHA-512 pair. |
| chain key → address | Solana: Ed25519 scalar |
One variable-base scalar multiplication and a few hundred field operations. Bounded but non-trivial. |
The verifier never re-executes any of this. It only checks the STARK that attests the computation was carried out correctly.
A few additional cryptographic constraints apply to the pipeline. The mnemonic is normalised under NFKD before PBKDF2, and the PoC does not support a BIP-39 password. The BIP-39 checksum is validated client-side before any proving begins, so a typo is caught in milliseconds rather than minutes. Ed25519 under SLIP-0010 has no unhardened derivation, which means the entire Solana path runs inside the circuit. The Bitcoin and Ethereum paths can lift the unhardened tail outside the circuit, where it is recomputed by the on-chain program - a small saving on circuit size for those chains.
The performance target for the deployment is a 2025-era flagship phone with 8 GB of RAM: median proving time under 4 minutes, 95th percentile under 5 minutes.
The first target chain is Solana, with two verifier variants:
- A mainnet-compatible Anchor program that accumulates the STARK across multiple transactions.
- A forked-validator variant exposing a
sol_stark_verifysyscall, which the Anchor program calls to verify the proof in a single transaction. This variant exists to quantify the speedup a chain-level precompile would unlock.
Bitcoin and Ethereum are in scope for the off-chain benchmarks but not for the on-chain demonstration.
All benchmarks were measured on a 16 GB Apple M2 Pro. Each cell is a single proof execution under /usr/bin/time -l in a fresh process. Single-threaded numbers approximate flagship-smartphone performance for short workloads, since both ARM platforms share NEON optimisations and neither thermal-throttles within tens of seconds.
Binius was the natural starting point. A binary-tower constraint system maps cleanly onto SHA-512's bitwise operations, and the published benchmarks suggested it would be the fastest of the four candidates by a comfortable margin. The first results confirmed that expectation:
| Date | Layer | Backend | Device | Prove ms (range / typical) | Proof bytes |
|---|---|---|---|---|---|
| 2026-04-29 | L0: input==42 | Binius | Apple Silicon sim | ~25 | 28 576 |
| 2026-04-29 | Plonky3 Fib(64) | Plonky3 | Apple Silicon sim | ~46 | 98 948 |
| 2026-04-29 | L1: SHA-512('abc') | Binius | Apple Silicon sim | 77–119, mostly ~100 | 86 240 |
| 2026-04-29 | L2: HMAC-SHA512 | Binius | Apple Silicon sim | 78–130 | 150,624 |
| 2026-04-29 | L3: PBKDF2 c=16 (naive) | Binius | Apple Silicon sim | 354 | 255,744 |
| 2026-04-29 | L3: PBKDF2 c=2048 (naive) | Binius | Apple Silicon sim | 123,843 | 468,416 |
| 2026-04-29 | L3: PBKDF2 c=16 (optimized) | Binius | Apple Silicon sim | 228 | 183,936 |
| 2026-04-29 | L3: PBKDF2 c=2048 (optimized) | Binius | Apple Silicon sim | 31,624 | 439,904 |
| 2026-04-29 | L7: Mnemonic to Address | Binius | Apple Silicon sim | 37.001 | 447,824 |
A careful read of the Binius architecture document revealed that those numbers describe an argument of knowledge, not a zero-knowledge proof. The hiding configuration in Binius is the separately published Iron Spartan wrapper. Switching to it changed the picture entirely:
| Layer | Hot prove+verify (median) | Peak | Notes |
|---|---|---|---|
| L1 SHA-512 | 0.67 s | 228 MB | One compression. |
| L2 HMAC-SHA512 | 1.89 s | 339 MB | Four compressions. |
| Keccak n=1 | 0.20 s | 183 MB | One Keccak-f permutation. |
| L3 PBKDF2 c=1 | 1.91 s | 337 MB | Floor inside the PBKDF2 envelope. |
| L3 PBKDF2 c=2 | 3.82 s | 387 MB | |
| L3 PBKDF2 c=4 | 9.75 s | 396 MB | |
| L3 PBKDF2 c=8 | 30.14 s | 779 MB | Roughly 3× per doubling of c; the c=2048 BIP-39 target is unreachable here. |
| L4 BIP-32 (Ethereum) | 24.20 s | 771 MB | Three hardened HMAC-SHA512 derivations. |
| L5+L6 ETH addr | TIMEOUT | - | secp256k1·G + Keccak; longer than the bench script's 15-minute ceiling. |
| L7 full ETH path | TIMEOUT | - | PBKDF2 c=2048 + BIP-32 + secp256k1 + Keccak; same 15-minute ceiling. |
Binius is the cheapest backend at the smallest layers and remains competitive through HMAC. Above PBKDF2 c=8 it scales superlinearly - roughly a 3× increase per doubling of the iteration count - so the BIP-39 target of c=2048 is far past what a laptop can complete within the 15-minute experiment cap. Higher-c integration tests exist in the adapter as #[ignore] cases and can be invoked manually, but the host runs into memory pressure well before they finish.
Binius has no upstream Ed25519 gadget, so the integrated mnemonic_to_address benchmark exercises only the Ethereum path. Building an Ed25519 gadget from scratch in Binius is more work than the rest of the experiment combined and is out of scope.
The available optimisation levers are LOG_INV_RATE (held at 1, the fastest FRI rate consistent with PoC soundness), OptimalPackedB128 SIMD prover packing, and ParallelCompressionAdaptor for Rayon. There is no streaming or sharding option at present.
Plonky3 was the engine the project invested most heavily in, because it was the only candidate that completed the full BIP-39 to Solana pipeline end to end on the target hardware. The adapter pins p3-* = 0.5.2 and runs over the BabyBear field. Each cell below is one complete pipeline (mnemonic, PBKDF2 with 2048 iterations, SLIP-0010 down to m/44'/501'/0'/0', Ed25519 scalar multiplication, Solana base58 address) measured in a fresh subprocess. The verifier runs in a separate process loading only the persisted proof, so its measured RSS is not polluted by the prover's heap.
Three orthogonal toggles steer the measurement:
- Hash and FRI:
keccak(Keccak-FRI, the natural choice for an on-chain Ethereum verifier) orposeidon2(arithmetisation-friendly, used by recursion). - Security:
classic(log_blowup = 2,num_queries = 56,query_proof_of_work_bits = 16) targets 128 bits against a classical adversary;pq(log_blowup = 3,num_queries = 78,query_proof_of_work_bits = 24) targets 128 bits against a quantum adversary, matching NIST's lowest security level. Both modes commit through a hiding MMCS and run hiding FRI, so the zero-knowledge property is identical between them; what changes is the FRI query-soundness budget. - Execution:
parallelengages a Rayon worker pool across all ten cores;serialkeeps the prover on one thread, as a proxy for a mobile build.
A FRI bit-count is not a single number; it is read off against a chosen pair of assumptions. The first axis is which proximity-gap bound is in force - the conjectured BCIKS bound that lets FRI list-decode all the way to capacity, or the provable Johnson bound that stops at the unique-decoding radius. The second axis is whether the adversary is classical or quantum, since Grover halves the bits spent on search components. The four resulting formulas evaluate as follows, with λ truncated by the Keccak-256 Merkle digest's 128-bit collision cap (classical) or Grover-preimage cap (quantum):
| Mode | BCIKS, classical | Johnson, classical | BCIKS + full Grover (PQ) | Johnson + refined Grover (PQ) |
|---|---|---|---|---|
classic (lb=2, nq=56, pow=16) |
128 | 72 | 64 | 64 |
pq (lb=3, nq=78, pow=24) |
258 → 128 | 141 → 128 | 129 → 128 | 129 → 128 |
The classical formulas are lb · nq + pow (BCIKS) and (lb/2) · nq + pow (Johnson); the PQ counterparts halve the search components — (lb · nq + pow) / 2 (BCIKS + full Grover) and (lb/2) · nq + pow/2 (Johnson + refined Grover). The arrows mark where FRI exceeds the digest cap and the bound binds at 128.
The classic mode reaches 128 bits only under the strongest single assumption (conjectured BCIKS against a classical adversary). Its role is to baseline the cost of dropping the post-quantum target; it is not a recommended deployment configuration.
The pq mode reaches 128 bits across all four cells, which means it stays at 128-PQ even if the BCIKS conjecture is later weakened to the Johnson bound, or if Grover's quadratic speedup is realised in full rather than restricted to the search components. Clearing two soundness regimes at once is the conservative choice, and the cost over classic is roughly 50 to 80 percent wall time and a third of the proof size, captured in the variant table below.
| Variant | Hot prove (median) | Verify | Proof size | Peak RSS |
|---|---|---|---|---|
| keccak / classic / parallel | 4.30 s | 210 ms | 24 MB | 4.32 GB |
| keccak / classic / serial | 13.25 s | 210 ms | 24 MB | 4.32 GB |
| keccak / pq / parallel | 6.80 s | 290 ms | 32 MB | 7.11 GB |
| keccak / pq / serial | 21.59 s | 280 ms | 32 MB | 7.09 GB |
| poseidon2 / classic / parallel | 5.75 s | 670 ms | 23 MB | 4.29 GB |
| poseidon2 / classic / serial | 25.45 s | 650 ms | 23 MB | 4.29 GB |
| poseidon2 / pq / parallel | 10.23 s | 900 ms | 32 MB | 6.91 GB |
| poseidon2 / pq / serial | 50.36 s | 880 ms | 32 MB | 6.90 GB |
The hiding configuration costs between 50 and 80 percent in wall time and roughly 1/3 in proof size. The increase is driven by the salt columns the hiding MMCS commits and by the additional ZK FRI round. Peak memory grows by half from the extra committed columns. The proof size for every pq row is identical because the committed structure is hash-invariant.
The Poseidon2 verifier is roughly three times slower than the Keccak verifier, which is counter-intuitive - Poseidon2 has a much shorter critical path inside an AIR. The reason is that the verifier is an ordinary Rust loop rather than an AIR, and Apple's hardware Keccak instructions dominate Poseidon2's round count and matrix multiplications. For an off-chain verifier the difference between 290 ms and 900 ms is irrelevant; for an on-chain verifier the choice is forced.
Moving from parallel to serial execution costs three to five times on the prover and almost nothing on the verifier. The verifier is single-threaded by design, since at this size parallelism loses to scheduling overhead. The serial number is the relevant one for the mobile budget, and at keccak / pq / serial = 21.6 s the proof generation lands comfortably under the four-minute target. Profiling confirmed that all four performance cores and all six efficiency cores of the M2 are engaged during a parallel prove.
Plonky3 is the only backend that completes the full pipeline on this laptop in a post-quantum, zero-knowledge configuration. Binius times out, Jolt runs out of memory at 23 GB, and SP1's hiding mode exceeds the 16 GB ceiling on the smallest layer.
The 32 MB proof and 280 ms verifier cost above are per-user figures. At chain scale, an aggregator that recursively combines many salvage proofs into one STARK is the natural path to per-block feasibility, so the experiment also wired the Plonky3-recursion crate into the BIP-39 to Solana pipeline. The integration surfaced two small issues, both reported upstream with reproducers and proposed fixes and since accepted: #440, clarifying that HidingFriPcs already provides honest-verifier zero-knowledge without MerkleTreeHidingMmcs, and #441, a one-line fix to the MulAddFusion optimiser. The crate is young and moving quickly; landing the recursion path required hands-on engagement with it, which the team was happy to contribute back.
The Jolt adapter pins jolt-sdk at commit 3c3b5e4b with features = ["host", "zk"]. The host runs under jemalloc and a 64 MB minimum Rayon stack, which is what stops the worker threads from overflowing at PBKDF2 c≥8. The inline precompiles jolt-inlines-keccak256 and jolt-inlines-secp256k1 are wired in for the elliptic-curve operations.
| Layer | Hot prove (median) | Peak | Notes |
|---|---|---|---|
| L1 SHA-512 | 2.20 s | 183 MB | Software path - no SHA-512 precompile exists upstream. |
| L1.5 Keccak n=1 | 1.95 s | 159 MB | One opcode-0x0B dispatch plus Dory open. |
| L2 HMAC-SHA512 | 3.28 s | 224 MB | Two software SHA-512 calls. |
| L3 PBKDF2 c=1 | 4.13 s | 272 MB | max_trace_length shrunk to 2¹⁹ for the c ≤ 8 envelope. |
| L3 PBKDF2 c=2 | 4.59 s | 338 MB | |
| L3 PBKDF2 c=4 | 5.65 s | 542 MB | |
| L3 PBKDF2 c=8 | 6.98 s | 879 MB | Roughly 0.4 s per added iteration above a 3.5 s envelope. |
| L4 BIP-32 (3 hardened) | 5.65 s | 577 MB | |
| L5+L6 ETH addr | 6.36 s | 600 MB | 128-bit GLV multi-scalar loop in guest RISC-V; only the inner Fq operations precompiled. |
| L7 full pipeline | FAIL (OOM) | 23 GB peak | 80.76 million cycles. The prover dies in stage 1 (witness commit). |
Jolt's prover memory grows linearly with trace length. Upstream's streaming roadmap quotes "under 2 GB per million cycles", and at 80 million cycles for the full pipeline that quote lands directly on the 16 GB ceiling and crosses it. Switching the allocator from the system default to jemalloc lowered the peak from 25.2 to 23.1 GB, but did not change the order of magnitude. Unlike SP1 and Risc0, Jolt does not expose a continuation or shard parameter; the team is betting on a streaming prover that has not yet shipped.
The optimisation levers exercised were max_trace_length, heap_size, stack_size, the host zk feature, JOLT_GUEST_OPT (kept at the default 3, since z increases cycles), tikv-jemallocator, RUST_MIN_STACK for the Rayon pool, and thin LTO. Fat LTO miscompiles per the upstream troubleshooting notes.
The SP1 adapter pins sp1-sdk = "6.2.0" with the blocking feature and SP1_PROVER=cpu. The SHA-512 precompile is enabled in the guest crate via [patch.crates-io] against sp1-patches/RustCrypto-hashes (tag patch-sha2-0.10.9-sp1-6.2.0). PBKDF2 itself runs as a software loop on top of the precompile - no PBKDF2 precompile exists upstream.
| Layer | Hot prove | Verify | Peak RSS | Notes |
|---|---|---|---|---|
| L1 SHA-512('abc') | 15.5 s | 73 ms | 3.8 GB | Dominated by per-shard fixed cost, not by the one SHA-512 block. |
| L2 HMAC-SHA512 | 17.1 s | 118 ms | 7.1 GB | Four compressions × precompile dispatch. |
| L3 PBKDF2 c=1 | 16.9 s | 77 ms | 5.6 GB | Inside the same floor as L1 and L2. |
| L3 PBKDF2 c=8 | 22.8 s | 82 ms | 6.2 GB | Crosses Jolt in prove time at this size. |
| L3 PBKDF2 c=64 | 27.1 s | 76 ms | 10.1 GB | Last data point that fits; roughly 0.07 s per added iteration. |
| L3 PBKDF2 c=256 | OOM | - | - | 17.4 GB peak footprint before SIGKILL on a 16 GB laptop. |
| L1 SHA-512 (Plonk wrap) | OOM | - | - | 17.7 GB peak after roughly four minutes - even the smallest layer does not fit in ZK mode. |
SP1 does not have a post-quantum ZK mode. Its own security model states that the individual STARK proofs are not zero-knowledge; hiding is added only by the Groth16 or Plonk wrapper, and both wrappers rely on BN254 pairings, which are quantum-vulnerable. To produce a true ZK proof under SP1 today, the Groth16 wrapper would need a replacement built from scratch. Since SP1 is also optimised for CUDA rather than CPU, and the CPU numbers were too slow for the target deployment, the initiative was abandoned after the core measurements were captured.
SP1 has a fifteen-second floor per proof from per-shard setup and commit, then scales with shard count. Jolt scales near-linearly with trace cycles from a one-second floor. The two cross around PBKDF2 c=8: below that, Jolt is two to ten times faster; above it, SP1 amortises shard overhead more efficiently - but SP1 hits the 16 GB ceiling at c=256 before that crossover pays off. Verification times stayed between 73 and 118 ms across all SP1 core runs, against a few hundred milliseconds for Jolt's zk-Dory verifier.
The optimisation levers that exist are SP1_PROVER (cpu, cuda, network, mock), SP1_PROOF_MODE (core, compressed, plonk, groth16, all wired up in the adapter), [patch.crates-io] for precompiles, and SP1CoreOpts for shard sizing.
Holding the project's post-quantum and zero-knowledge requirement, and treating the BIP-39 to Solana pipeline as the only deployment target that matters:
- Plonky3 is the single build candidate that satisfies both axes and fits on the target hardware. It is the only backend that completes the full pipeline in either
keccakorposeidon2, and in eitherclassicorpqconfigurations. The best number benchmarked on laptop to approximate the realistic mobile profile (keccak / pq / serial) is 21.6 s prove, 7 GB RSS, 32 MB proof, 280 ms verify, single-threaded. - Binius, Jolt, and SP1 are all too heavy in their current shape. The experiment hit timeouts on Binius and out-of-memory failures on Jolt and SP1 as the pipeline length grew. In every case the bottleneck was the 2048 rounds of HMAC-SHA-512 inside PBKDF2.
While prover-side feasibility is acceptable on laptops and flagship phones, an on-chain verifier still has to consume 32 MB of proof and 280 ms of verification work, which is prohibitive on every mainstream chain today. Closing that gap, in declining order of likelihood, calls for:
- More capable prover engines, especially streaming provers and STARK recursion.
- Circuit-specific precompiles and syscalls on the verifier chain.
- More powerful mobile hardware, with the wider memory and bandwidth of laptops.
- Better OS-level cryptographic acceleration. Apple Silicon already supports Keccak; Android is expected to add Keccak-256 acceleration in Android 17. Poseidon2 is not accelerated anywhere yet.
- An off-chain proof aggregator that recursively combines submitted proofs into a single STARK. In that arrangement, validators only verify the aggregate, and per-proof verification of all salvage requests under 300 ms becomes feasible.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
The mobile client is a React Native app written in Expo SDK 55 and shipped for both iOS and Android. It carries the entire salvage workflow - onboarding, legacy account import, proof generation, and post-quantum signing - and it does so under a deliberately strict on-device threat model: a phone the user trusts but an operating system that does not, with assumed adversaries ranging from a curious roommate with momentary physical access to a malicious application running in the same user session.
The wallet is protected by a single 32-byte Master Encryption Key generated once during onboarding. The key never appears in plaintext on disk and never lives inside a JavaScript string. Two independent paths can unwrap it, and the user can use either.
The PIN path derives a key-encryption key from a 6-digit PIN using PBKDF2-HMAC-SHA-256 across 600 000 iterations, and unwraps the Master Encryption Key with AES-256-GCM. A separate PIN verifier blob, salted independently, distinguishes a wrong PIN from a corrupted ciphertext. Conflating those two failure modes is a common mistake and a real privacy leak, so the verifier exists specifically to keep them apart.
The biometric path stores the same Master Encryption Key inside the operating system's hardware-backed keychain - the Secure Enclave on iOS, StrongBox or the Trusted Execution Environment on Android - gated by kSecAccessControlBiometryCurrentSet and setUserAuthenticationRequired(true) respectively. Disabling biometrics on the device, or enrolling a new fingerprint or face, invalidates the entry and forces the user back to the PIN path on the next unlock. No custom native module is required; the operating system performs the wrapping internally.
The user's BIP-39 mnemonic enters the application as a SecureString (described below), is encrypted under the Master Encryption Key with AES-256-GCM, and is persisted to expo-secure-store. The data field of the GCM ciphertext in the store binds the blob to a scheme version and a purpose string. Thus a swap between the mnemonic blob, the PIN-wrap blob, and the biometric-wrap blob fails the authentication tag rather than silently decrypting into a wrong context. Loading the mnemonic again hands the caller a fresh SecureString that must be cleared once the operation completes.
JavaScript strings are immutable and live until garbage collection. Once secret material lands inside an ordinary string, there is no way to overwrite it; it can only be hoped for. The SecureString wrapper exists to prevent this entirely. It owns a Uint8Array, exposes the bytes only through a synchronous borrowBytes callback, refuses any attempt at string coercion (template literals, + concatenation, JSON.stringify), and zeros its buffer on clear(). The Master Encryption Key uses the same discipline through MekHandle. Across the codebase, every secret enters as a SecureString or MekHandle, is borrowed for one synchronous operation, and is cleared inside the same finally block. There is no path that lets a mnemonic, a PIN, or the Master Encryption Key end up in a plain string.
The app prevents screenshots and screen recordings on Android by setting FLAG_SECURE on the main activity through a custom Expo config plugin. On iOS, an Expo config plugin installs an additional UIVisualEffectView blur cover inside applicationDidEnterBackground, which is the only delegate hook that fires before the system's app-switcher snapshot is captured. The cover is removed when the application becomes active again. The blur is installed at the window level rather than inside the React tree, so any in-flight proof generation continues uninterrupted while the user briefly checks another application.
The authentication gate re-locks the wallet only on a real background transition, not on the transient inactive state. The inactive state fires for Face ID prompts, system permission dialogs, and document pickers, and re-locking there would render those prompts unusable.
ML-DSA-65 - the FIPS 204 post-quantum signature scheme - is implemented as a custom Expo Module backed by the audited pq-code-package/mldsa-native C library, compiled for both iOS (Swift bridge) and Android (Kotlin bridge with CMake). FN-DSA accounts were not implemented, because the libraries still haven't matured yet.
The plaintext post-quantum private key never crosses the JSI boundary in either direction. The native side generates the keypair, wraps the secret key under the Master Encryption Key with AES-256-GCM, and hands the JavaScript side only the public key and the wrapped blob. Signing follows the same pattern: the wrapped blob and the message bytes are passed to the native side, which unwraps in place, signs, and zeros the plaintext key in a single synchronous call. The signature comes back to JavaScript; the secret never does.
Mopro v2 drives the Rust prover code from React Native through a UniFFI binding. Proof generation runs on a dedicated native background thread bound through JSI, so the JavaScript thread is never blocked and never holds the derived bytes. The target acceleration path is Apple's Metal API on iOS and Vulkan on Android. The same binding exposes every backend the project benchmarked, so switching engines is a build-flag change rather than a code change.
Importing a legacy account requires scanning a window of derivation indices and matching each candidate address against the address the user typed. This scan runs entirely in pure JavaScript via @noble/curves and @scure/bip39, with no native dependencies, no Buffer polyfills, and no patched packages. Pure JavaScript is acceptable here because the scan is short, the input mnemonic already lives in user memory, and the operation does not need the constant-time guarantees of the prover path. Once the matching index is identified, it becomes the witness for the ZK prover, which then re-derives the address in-circuit.
ShorStop/
├── apps/
│ └── mobile/shor-stop/ Expo / React Native app (Android + iOS)
│ ├── src/{app, components, context, hooks, data, constants, security}
│ └── modules/pq-mldsa/ Expo Module wrapping pq-code-package/mldsa-native
├── libs/
│ └── mopro/ Rust workspace driving the Mopro v2 ZK bindings
│ ├── binius-adapter/ Binius64 backend (SHA-512 / HMAC / PBKDF2 / Keccak / BIP-32 / secp256k1 / mnemonic → ETH)
│ ├── plonky3-adapter/ Plonky3 STARK backend (BabyBear + Keccak-FRI, hiding/ZK config)
│ ├── jolt-adapter/ Jolt zkVM backend (standalone workspace - pinned Rust toolchain, [patch.crates-io] for arkworks fork)
│ ├── sp1-adapter/ SP1 zkVM backend (standalone workspace - succinct toolchain for the guest, out-of-tree program/)
│ └── mopro-bindings/ UniFFI surface that exposes the adapters to Swift / Kotlin / TS
├── benchmarks/ Time and memory measurements per backend × workload
└── scripts/
└── rebuild-mopro-bindings.sh Rebuilds the Mopro bindings that run the adapters on mobile.
Each adapter exposes the same prove_* and verify_* shape per workload, so the mobile app swaps backends behind a single Mopro UniFFI binding. The four adapters live in physically separate workspaces because their toolchains and dependencies cannot coexist inside one Cargo workspace:
| Crate | Workspace | Reason for separation |
|---|---|---|
binius-adapter |
shared | Pinned to binius64 git HEAD (branch = "main"); always uses the Spartan-wrapped ZK configuration (ZKProver + ZKVerifier); edition 2024 |
plonky3-adapter |
shared | Pinned to p3-* = "0.5.2"; uses MerkleTreeHidingMmcs + HidingFriPcs for ZK over Keccak-FRI |
jolt-adapter |
standalone | Requires [patch.crates-io] for the arkworks fork and pins Rust 1.94 in rust-toolchain.toml - incompatible with the shared workspace |
sp1-adapter |
standalone | The guest is built via sp1-build against the succinct Rust toolchain; the program/ crate is deliberately excluded from any host workspace |
mopro-bindings |
shared | Aggregates the shared-workspace adapters and exposes them via mopro_ffi::app!() |
The shared workspace lives at libs/mopro/Cargo.toml.
cargo check --workspacefromlibs/mopro/builds Binius, Plonky3, and the UniFFI bindings together.cargo checkfrom insidelibs/mopro/jolt-adapter/andlibs/mopro/sp1-adapter/covers the two standalone backends.
Three layers of tests sit on top of each adapter. Run them in order; an issue surfaced at a higher layer is usually a symptom of something already broken at a lower one.
| Layer | What it verifies | Where | How to run |
|---|---|---|---|
| Rust unit tests | Adapter prove_* / verify_* roundtrip per layer; reference (out-of-circuit) implementations match the in-circuit ones byte-for-byte; bad inputs rejected |
libs/mopro/<backend>-adapter/src/<layer>.rs under #[cfg(test)] mod tests |
cargo test -p binius-adapter and -p plonky3-adapter from libs/mopro/; cargo run --release with a subcommand from inside libs/mopro/jolt-adapter/ |
| Benchmark sweeps | Cold and hot wall time, peak RSS, and peak footprint per layer at increasing input size | Same files, marked #[ignore] so they do not slow cargo test |
python3 scripts/bench.py [--prover binius|jolt|plonky3] [--challenge L7] - each trial runs in its own subprocess and writes a row to MoproSetup.md immediately, so a crash mid-sweep loses one trial only |
| Mobile migrate flow | Whole BIP-39 → Solana prove and verify path through the UniFFI shim on simulator or physical device, driven by the production salvage screen | apps/mobile/shor-stop/src/app/(app)/(tabs)/migrate/proving.tsx (reached from the Migrate tab) |
./scripts/rebuild-mopro-bindings.sh after any Rust change, then npx expo run:ios --device (or run:android), then open the Migrate tab and run a salvage against a known mnemonic |
mopro-bindings/MoproReactNativeBindings/ is the React Native package that ships the bindings. The hand-authored sources are tracked in git; build outputs (ios/, android/, cpp/, lib/, *.xcframework/, node_modules/) are reproducible from mopro build --platform react-native and are deliberately gitignored. The scripts/rebuild-mopro-bindings.sh helper rebuilds them in one command.








