Post quantum client core for React Native apps on the Quantova network. Keys are derived and transactions are signed with ML-DSA-65 on the device through the Rust core QCore.rs, linked as native code on Android and iOS with no wasm and no JavaScript cryptography.
npm install @quantovainc/react-native-qcore
cd ios && pod installAndroid needs minSdk 24 or later and iOS needs 13.0 or later. The prebuilt native libraries ship with the package and their hashes are listed in SHA256SUMS, which npm run verify:binaries checks.
Apps that hold user funds should sign through secureQCore, which is also the default export. The seed is kept in the Android Keystore or the iOS Keychain, bound to this device and never backed up. Once stored it is never handed to JavaScript again, apart from the recovery phrase that revealPhrase returns after a fresh unlock. A phrase or seed given to the vault and the phrase it reveals cross the bridge as a list of byte values that both sides clear after use, never as a JavaScript string.
import { secureQCore, SigningSession } from '@quantovainc/react-native-qcore';
await secureQCore.createSeed('main');
const from = secureQCore.address('main', 0n);
const session = SigningSession.open([knownPayee]);
session.checkTarget(to);
const nonce = await session.slot(chainId, genesisHash, from, reportedNonce, null, false);
const signed = await secureQCore.signTransfer('main', session, 0n, to, '5', nonce, '1', chainId, validUntil, head, genesisHash);- On Android every seed is sealed under its own Keystore key, held in StrongBox where the device has it. On Android 11 and later, and on Android 9 and 10 when a biometric is enrolled, that key opens only through a system biometric prompt bound to the one operation, with the device credential allowed on Android 11 and later. On Android 7.0 to 8.1, and on Android 9 and 10 with only a PIN, pattern or password, the key opens for ten seconds after the system screen that confirms the device credential, which the vault shows right before every use. A seed sealed by an earlier release keeps its earlier key and is opened only after the same prompt. On iOS the seed is a Keychain item that requires user presence and is readable only while a passcode is set.
secureQCore.storageLevel()tells the app where the key is kept,strongbox,trusted-environment,secure-hardwareorsoftwareon Android andkeychainon iOS. On iOS a seed left in the Keychain by an earlier install of the app is removed on the first launch after a reinstall, so restore it from the recovery phrase. - No seed is kept in memory between calls. Every signature, reveal and address derivation opens the sealed seed after its own prompt and wipes it before the call returns, and on iOS every Keychain read uses a new authentication context that reuses no earlier unlock. The vault refuses to load or use a seed while the app is not in the foreground on both platforms. Only a
Uint8Arrayis accepted as a seed or phrase, and a seed made of fewer than 13 different byte values is refused. revealPhraseasks the user to authenticate again every time and returns the 25 word recovery phrase as bytes, which the app should clear withwipeBytesonce shown.createSeed,importPhrase,importSeedandforgetSeedreturn promises. On Android storing a seed asks the user to authenticate, and on both platformsforgetSeedasks the user to authenticate in the same way asrevealPhrasebefore it deletes anything. On Android it then deletes the sealed seed together with its key, so a seed whose key a new fingerprint or face invalidated can be forgotten and restored from its phrase.addressanswers from the addresses the vault derived while the seed was open, the first eight when the seed is stored and every index that signs, andlearnAddressesderives more after the user authenticates.- Before every vault signature the user sees natively what is signed, the payee or contract, the amount, the fee and the network, in the Android biometric prompt and in an iOS alert shown ahead of the Keychain prompt. The title of every prompt is fixed by the native code for its action, and any text the app passes is shown only as a second line. The vault signs for the mainnet chain only for an account acknowledged with
acknowledgeMainnetafter the user authenticates, and it refuses a payee or contract that shares its leading and trailing characters with an address the session knows but is a different address. It also bounds every deadline itself, it refuses a head beyond what the device clock allows at the genesis time and block rate this core knows for the network, or at the fastest rate it assumes since the start of 2025 for a network it does not know, and a head below the one its last signature for the account used, which it keeps in the saved record. The mainnet chain has no such clock yet, so until it does the vault refuses mainnet even after the acknowledgement. - The vault signs only at a nonce slot reserved from a
SigningSessionopened by this module. A nonce the gateway reports below the one the session already reached is refused unless the caller passes it as the expected nonce, so a gateway that goes back never gets two transactions signed at one nonce. Every session in the app shares one nonce ledger, and so does every process of the app, so two open sessions never hand out the same nonce even when they run in two processes. Each process holds an operating system file lock while it reads the saved state of an account, changes it and writes it back, and it reads that saved state again before every slot, signature, outcome and release. On Android the lock is a file in the nonce folder and on iOS it is a file in Application Support. A lock file that is a symbolic link is refused. After it takes the lock a process checks that the file it locked is still the file at that path, and it checks this again before every write, so a lock file that is removed or replaced while it is held stops the write and nothing is signed. The ledger keeps each account under its chain id, the genesis hash of its network and its address, so a nonce used on one network neither blocks nor clears the same nonce on another, even when two networks share a chain id. Every call that reads or writes the nonce state, every session call and every vault signer, returns a promise and does its work on a background thread, never on the JavaScript thread. - The nonce ledger is kept on the device with one record per account and a sealed manifest that names the exact write of every record by its generation and by the random id of the process that wrote it, together with the write before it. The manifest is written before the records it names. A nonce that was signed once is never handed out again after a crash or a restart. When the app stops after the manifest is written and before a record is, the record left on the device is the write before, and the vault accepts it only together with a settling wait as in item 8, so the account is not blocked and no transaction signed before it can land twice. A record that matches neither of the two writes the manifest names, is damaged, or is not listed in the manifest at all blocks only its own account until that account is reset. A manifest that is missing, damaged, in an earlier format or can no longer be opened blocks every account until the nonce state of every account is reset. The one exception is a first save cut short before any record was written. Once the first save completes the vault keeps a marker in the Android Keystore or the Secure Enclave, and while that marker is absent and no manifest and no record exist nothing can have been signed, so the device key left behind is removed and the ledger starts empty. Nonce state saved by an earlier release is in such an earlier format.
- A write that records a signature seals the manifest with a new device key and then deletes every key before it, so a manifest put back from an older copy can no longer be opened and blocks every account. Writes that only reserve, release or record an outcome keep the current key, since putting one of them back only makes the ledger more careful. Every device key carries a counter, and before the new key is made the current manifest names it as the next key. When the manifest names one key and any other manifest key exists, the vault deletes the older keys and the named next key before it reads further, and it refuses to sign while one of them cannot be deleted. Any other newer key means an older manifest was put back after a newer one was written, and the ledger is then blocked until every account is reset. A failed deletion is never ignored, it stops the write and nothing is signed. On Android the keys are held in the Android Keystore and the nonce files live in the folder that is left out of backups. On iOS the manifest and every record carry an HMAC under a key that only a Secure Enclave private key derives, so neither can be made or changed by anyone who holds no such private key, and Secure Enclave keys never go into a backup. On a device without a Secure Enclave the vault refuses to keep nonce state and does not sign. Only the iOS simulator uses a Keychain key in its place.
- When the vault first meets an account that the ledger does not list and the gateway reports a nonce above zero, or when the gateway later reports a nonce above every nonce this device signed for the account, the account has history that this device did not sign. That happens after a recovery phrase is imported, after the app data is cleared and when another device signs for the same account. Such an account settles before the vault signs for it, and every vault signer refuses for it with an error that starts with
SETTLING. The wait is started and ended only by finality certificates thatsettleHistoryverifies against the genesis hash and the validator roster this core pins for the network. For a network with no pinned rostersettleHistoryrefuses, since a roster passed in by the app is not trusted, and the history is acknowledged withacknowledgeImportedHistoryinstead. The first certificate starts the wait at its height and must carry a block time no older than five minutes on the device clock. The wait ends once a later certificate is higher by at leastmaxValidityBlocks()plus one blocks plus the blocks the chain can make in the five minutes the first certificate may be old, counted at the fastest block rate this core assumes, and at least 1020 seconds have passed on the clock of the device that counts time since it started, which is the time the validity window takes at five blocks per second plus those five minutes. That count is kept across app restarts and starts again after a reboot. A later certificate whose block time runs ahead of the device time counted since the start by more than two minutes shows that the start was old, and the wait starts again from it. A head reported by the gateway and the wall clock of the device never end the wait. By then no transaction signed earlier can land. A report of a nonce higher still during the wait starts it again. An account whose first reported nonce is zero has no history and signs at once, as every account of a newly created seed does. The settling state is kept in the record and in the manifest, so a missing or damaged record blocks the account and after its reset it settles again.acknowledgeImportedHistoryends the wait for one account after the user authenticates again in the same way as forresetNonceState, for the case where the user knows nothing signed earlier is still pending. Resetting an account puts it back into settling on its next first use. A signature or an outcome is recorded only for an account that a slot created. - An account keeps at most 16384 nonces that are signed or reserved and not yet accepted, and the ledger keeps at most 4096 accounts across every process of the app. A nonce key is one to 128 printable ASCII characters from
!to~, so it holds no space and no control character. A call that would go past any of these limits or names another kind of key is refused before anything is signed. resetNonceStateclears the saved nonce state of one account, named by its chain id, the genesis hash of its network and its key, or of every account when no account is named. It asks the user to authenticate again in the same way asrevealPhrase. Reset an account only once you know which of its signed nonces have landed. Saved nonce state from an earlier format is refused and needs this reset.- On Android 11 and later, and on Android 9 and 10 when a biometric is enrolled,
revealPhrase,resetNonceState,acknowledgeImportedHistory,acknowledgeMainnetandforgetSeedshow the system biometric prompt, with the device credential allowed on Android 11 and later. On Android 7.0 to 8.1, and on Android 9 and 10 with only a PIN, pattern or password, they show the system screen that confirms the device PIN, pattern or password, and they go ahead only when a Keystore key that needs an authentication within the last five seconds then works. There each confirmation screen is started with its own request code, a second request is refused while one is waiting for the user, and each result answers only the request it belongs to and only once. Both need a device lock. - Every signer takes the current chain
headand refuses avalidUntilthat has already passed or lies more thanmaxValidityBlocks()blocks past it.boundedValidUntilgives a deadline inside that window. - Signing for a retired network name is refused.
The module protects the seed of every vault account, the recovery phrase, and the nonce ledger that keeps a signed nonce from being handed out twice.
It trusts the Android Keystore or the iOS Secure Enclave and Keychain of the device, the lock screen of the device, the genesis hash and validator roster the app pins, and the native libraries listed in SHA256SUMS. It does not trust the gateway, any head or nonce a gateway reports, the device clock, or the JavaScript code of the app beyond the calls it makes.
A seed never leaves the secure storage except through revealPhrase after a fresh authentication, and it is wiped from memory as soon as the call that opened it returns. Every signature binds the chain id and the genesis hash, so a transaction signed for one network never lands on another. A nonce once signed is never handed out again, also across processes, restarts and crashes, and a ledger restored from an older copy blocks signing until it is reset. An account with history from another device signs only after verified finality certificates show that anything signed earlier can no longer land.
Two limits remain that no code on the device can close. An attacker who controls both the network the app uses and the device clock can make the wait after importing a recovery phrase end early, so a transaction signed on the other device may be refused by the chain, and no funds can be taken that way. An attacker who can already write inside the private storage of the app can race the nonce lock in a window of microseconds, which again can only make the chain refuse one of two transactions. Left to the app are protecting the device and its lock screen and pinning the genesis of the network.
nativeQCore is a named import only. It exposes the same derivation and signing functions over a seed given as hexadecimal, together with the proof checks verifyFinality, verifyEvent, verifyTransaction, confirmTransaction and decodeEvent. The seed functions take a raw nonce and keep no session, so they are meant for tooling and tests and not for wallets.
Every signer takes the genesis hash of the network as its last argument, after the head, as 64 hex characters, and signs every transaction over that genesis hash together with its body, so a transaction signed for one network is never valid on another that reuses the chain id. verifyFinality, confirmTransaction and confirmInclusion take the genesis hash right after the chain id, and after the roster the epoch that roster is trusted for, zero by default, and the epoch length of the network, which this core fixes for the testnet and which must be given for a network it does not know. A header that lies in another epoch than the roster is refused, so an old roster never certifies a later block. confirmTransaction confirms a transfer only and refuses a contract call, since the chain includes and charges a call that reverted, and confirmInclusion reports inclusion alone together with the kind of the transaction and an outcome that stays unproven for a call. Every attestation signs that genesis hash, so a finality certificate from another network that reuses the chain id and the validator keys never verifies. Take the genesis hash from the network your app pins and never from anything a node or gateway reports, such as node_info. A genesis hash that is not 64 hex characters, or that is all zeros, is refused. nativeQCore also offers boundedValidUntil, which gives a deadline inside the window that item 12 describes.
Each release is built from a tag on main by the release workflow, and the attestation of every published file names that tag and its commit.
Licensed under either of Apache License 2.0 or MIT license at your option. See LICENSE-APACHE, LICENSE-MIT and NOTICE.