Proof-of-Private-Stake (PoPS)¶
PoPS is Navio's BLSCT consensus mechanism on mainnet and testnet (and on blsctregtest for local development). Every PoPS block after the bootstrap PoW window is produced by a validator that proves three things in zero-knowledge:
- It controls an output in the current staked commitment set — without revealing which one.
- That output's hidden amount is large enough to satisfy the Kernel-Hash eligibility inequality at this block height.
- The proof is bound to this specific block (messages + prev-block state), so a valid proof cannot be replayed for a different block or grinded across stakers.
Unlike classical PoS, a PoPS block does not reveal:
- Which UTXO was used to stake.
- The staked amount.
- Any link between successive blocks produced by the same staker.
Design paper (initial reference, shape differs in implementation):
Navio: A Privacy-enhanced UTXO Blockchain (Dec 2025 draft). The implementation in nav-io/navio-core is the authoritative spec; this page documents what the code actually does.
1. Notation¶
| Symbol | Meaning |
|---|---|
| \(\mathbb{G}\) | Prime-order group derived from BLS12-381's \(G_1\) subgroup (via Mcl arithmetic backend). |
| \(p\) | Group order (255-bit prime). |
| \(\mathbb{F}_p\) | Scalar field. |
| \(G\), \(H\) | Two independent base generators. \(G\) = Point::GetBasePoint(); \(H\) = deriver("proof-of-stake").Derive(G, 0, TokenId()) — nothing-up-my-sleeve, discrete log unknown. |
| \(\{H_i\}_{i<N}\) | \(N = 2^{10}\) generator vector for the inner-product argument. |
| \(N\) | Maximum commitment-set size the setup supports (SetMemProofSetup::N = 1024). |
| \(\mathbf{Y}^n = (Y_1, \dots, Y_n)\) | Staked commitment set at the tip of the parent block. |
| \(\sigma = H^m G^f\) | Prover's own staked commitment in the original Pedersen basis, being shown to lie in \(\mathbf{Y}^n\). |
| \((m, f)\) | Committed value (stake amount) and blinding factor. |
| \(\varphi = g_2^m \cdot h_3^f\) | Set element image — same opening \((m, f)\) as \(\sigma\), but under fresh generators \((g_2, h_3)\). |
| \(\eta_{\text{FS}}\) | Fiat-Shamir entropy. Binds the proof to the parent block and to this block's transaction list, so the proof is a signature over the block body (§5). |
| \(\eta_\varphi\) | Generator-rebase seed for the set-membership / range proof. Derived only from fixed parent state, so the rebased generators — and hence \(\varphi\) — cannot be ground (§5). |
| \(\text{KH}\) | 32-byte kernel hash — per-commitment lottery input, see §4. |
| \(\text{stakeModifier}\) | 64-bit beacon accumulated from 64 historical blocks; changes once per nModifierInterval (§5). |
| \(T_{\text{pos}}\) | Compact PoS difficulty target (nBits of the new block). |
| \(v_{\min}\) | Per-block minimum committed amount that satisfies the kernel-hash inequality (§4). |
BLS12-381 generators for the Bulletproofs range proof are derived independently under the TokenId() path in range_proof::GeneratorsFactory. |
2. Staked commitment set¶
Validators lock stake by publishing a BLSCT transaction whose output is tagged as a staked commitment. Consensus maintains a set of all currently-unspent staked commitments in the UTXO view cache (CCoinsViewCache::GetStakedCommitments, src/coins.cpp).
- An unspent staked commitment is flagged
STAKED_COMMITMENT_UNSPENT = 1. - A spent / unlocked one is flagged
STAKED_COMMITMENT_SPENT = 0and purged from the set. - The set is keyed by the raw Pedersen commitment point \(Y_i = v_i H + \gamma_i G \in \mathbb{G}\). The set has no owner labels — observers only see a list of 48-byte group elements.
- Consensus requires \(|\mathbf{Y}^n| \ge 2\) to validate any PoPS block (
ProofOfStakeLogic::Verifyinsrc/blsct/pos/proof_logic.cpp). A single-commitment set would de-anonymise the staker. - Set size is padded to the next power of two with "dummy" points \(H_5(\text{"SET\_MEMBERSHIP\_DUMMY"} \mathbin\Vert i)\) before the proof — dummies lie in \(\mathbb{G}\) with unknown openings, so extension adds no malleability surface (see
SetMemProofProver::ExtendYs).
Ring sampling¶
When the full staked set is larger than nStakedCommitmentLimit (16), the prover and verifier first sample a ring of at most 16 commitments to bound proof size and verification cost (ring size drives the inner-product argument's \(\log_2 n\) rounds). The sample is a deterministic Fisher–Yates shuffle of the full set keyed by a ring seed, truncated to the limit (OrderedElements::GetElements(seed, max_size) in src/blsct/arith/elements.cpp). Both sides recompute the same seed, so they agree on the ring.
The ring seed is a non-grindable beacon (blsct::CalculateStakeRingSeed):
where \(B_{n-128}\) is the hash of the ancestor POPS_RING_SEED_LOOKBACK = 128 blocks back (the deepest available ancestor on shorter chains). The three inputs serve distinct roles:
- stakeModifier and \(B_{n-128}\) are fixed long before the current slot. The producer of the parent block therefore cannot choose the next ring: biasing it requires long-range control of an entire modifier interval and the deep anchor. This prevents a staker from grinding which competitors are included or excluded.
- \(\text{bucket}_{16}(\text{time})\) advances with the wall clock, so the ring re-samples every 16 s. This is a liveness guarantee: if every currently-sampled commitment is offline, the passage of real time rotates the ring until an online staker's commitment is included — the chain self-heals rather than stalling. The grind this admits is bounded to the few future buckets allowed by the future-time cap (§5), the same bound the kernel lives under.
Sampling is skipped entirely (the whole set is the ring) when the set fits within the limit.
Minimum stake¶
| Network | nPePoSMinStakeAmount |
|---|---|
| mainnet | 10,000 NAV (10000 * COIN) |
| testnet | 10,000 NAV |
blsctregtest |
100 NAV |
Source: kernel/chainparams.cpp. Plain signet and plain regtest are not PoPS chains in the current implementation.
Block-production parameters¶
| Network | nPosTargetSpacing |
nPosTargetTimespan |
posLimit |
nBLSCTBlockReward |
|---|---|---|---|---|
| mainnet | 120 s | 3600 s | 0x0000ffffffffffff… |
8 NAV |
| testnet | 60 s | 1800 s | 0x0000ffffffffffff… |
4 NAV |
blsctregtest |
60 s | 1800 s | 0x0000ffffffffffff… |
4 NAV |
3. Set-membership proof (modified RingCT 3.0)¶
The set-membership sub-proof shows \(\exists\, i^* \in [1, n]\) such that \(Y_{i^*} = \sigma\), i.e. the prover's commitment lies in the published set, without revealing \(i^*\). It is a custom modification of the RingCT 3.0 proof adapted for PoPS — the contributions are the set-element image \(\varphi\) and the use of per-block \(H_k\) hash families so the discrete log of the output point is not leaked.
Prover procedure¶
From SetMemProofProver::Prove in src/blsct/set_mem_proof/set_mem_proof_prover.cpp.
Let \(\mathbf{b_L} = (b_1, \dots, b_n)\) with \(b_{i^*} = 1\) and \(b_i = 0\) otherwise; \(\mathbf{b_R} = \mathbf{b_L} - \mathbf{1}^n\). These satisfy
Commit 1. Compute
The comment in source explicitly notes: "generators are swapped vs. paper — here \(h_3 = G\) and \(g_2 = H\)". Pick \(\alpha, \beta, \rho, r_\alpha, r_\tau, r_\beta \leftarrow \mathbb{F}_p\), vectors \(\mathbf{s_L}, \mathbf{s_R} \leftarrow \mathbb{F}_p^n\) and compute
Set element image.
Because \((g_2, h_3)\) are rebased from \(\eta_\varphi\) (derived from the parent block, so distinct at every height — see §5), \(\varphi\) is unlinkable to \(\sigma\): two blocks staked by the same UTXO produce different \(\varphi\), yet both validate against the same \(\sigma \in \mathbf{Y}\).
Challenge 1. Fiat-Shamir transcript:
Derive \(y, z, \omega \leftarrow H(\text{str})\).
Commit 2. Build degree-1 vector polynomials
and the scalar polynomial \(t(X) = \langle \mathbf{l}(X), \mathbf{r}(X) \rangle = t_0 + t_1 X + t_2 X^2\) where
Pick \(\tau_1, \tau_2 \leftarrow \mathbb{F}_p\) and commit:
Challenge 2. \(x = H_1(\omega, y, z, T_1, T_2)\).
Response.
Improved inner-product argument. Instead of transmitting \(\mathbf{l}, \mathbf{r} \in \mathbb{F}_p^n\) (size \(n\)), the prover runs the ImpInnerProdArg::Run protocol producing \(\log_2 n\) left/right points \((L_i, R_i)\) and final scalars \(a, b\). Challenge \(c_{\text{factor}}\) is sampled before recursion to bind everything to the outer Fiat-Shamir stream.
The final proof:
Serialisation order is fixed in SetMemProof.
Verifier equations¶
The verifier recomputes \(y, z, \omega, x, c_{\text{factor}}\) from the transcript and rebuilds the four checks (see SetMemProofProver::Verify):
(18) — polynomial identity
(19) — inner-product binding. Encoded as a single multi-exponent over \(\{A_1, A_2, S_2, h_2, \mathbf{L}, \mathbf{R}, G, \{G_i\}, \{H_i\}\}\); each \(G_i, H_i\) exponent is derived via ImpInnerProdArg::GenGeneratorExponents. The check re-expresses
(20) — opening of \(\sigma\) at \(A_1\)
(21) — opening of \(\varphi\) at \(S_3\) (the Navio-specific addition over RingCT 3.0)
Equation (20) attests that \(A_1\) opens to \((\alpha, f, m)\) over generators \((h_2, h, g)\). Equation (21) attests that \(\varphi\) opens to the same \((m, f)\) over generators \((g_2, h_3)\). Combined, they prove that \(\varphi\) commits to the same value and mask as the \(\sigma\) hidden inside the set — without revealing which \(Y_i\) it is.
Why the rebase matters¶
\((g_2, h_3)\) are derived per block from \(\eta_\varphi\). Two separate blocks staked by the same output produce two \(\varphi\)'s under different generator pairs; by the Decisional Diffie-Hellman assumption in \(\mathbb{G}\), those two \(\varphi\)'s are indistinguishable from random and cannot be correlated. This is the source of the coin unlinkability property.
Formally (paper Definition 3): for random \(x, y, z \in \mathbb{F}_p\),
4. Kernel-hash eligibility (range-proof leg)¶
Proving membership is necessary but not sufficient. A valid PoPS block must also satisfy the eligibility inequality parametrised by the kernel hash.
Kernel hash¶
From src/blsct/pos/helpers.cpp. The kernel hash binds the staker's set-element image \(\varphi\), so every staked commitment draws an independent lottery value:
// Bucket the staker-chosen time into POPS_TIME_GRANULARITY_SECONDS
// intervals so the per-second grinding surface is quantised.
static uint32_t BucketTime(const uint32_t& time) {
return time - (time % POPS_TIME_GRANULARITY_SECONDS); // 16 s
}
uint256 CalculateKernelHashWithChainWork(
uint32_t prevTime, uint64_t stakeModifier,
const arith_uint256& prevChainWork, uint32_t time,
const MclG1Point& phi)
{
HashWriter ss{};
ss << prevTime
<< stakeModifier
<< ArithToUint256(prevChainWork)
<< BucketTime(time)
<< phi; // per-commitment image -> independent draw
return ss.GetHash();
}
So at the block level
Every field a staker might grind is committed here and is fixed by prior chain state, except the bucketed time:
- \(\text{prevTime}\), \(\text{stakeModifier}\), \(\text{prevChainWork}\) — fixed by the parent block.
- \(\varphi\) — fixed per (commitment, parent block): its generators are rebased from \(\eta_\varphi\), which depends only on parent state (§5), so a staker cannot vary \(\varphi\) for a given coin by changing the block body.
- \(\text{bucket}_{16}(\text{time})\) — advances with the wall clock. The staker may only choose among the few future buckets the future-time cap admits (§5), so per-slot grinding is bounded to a small constant, not the open-ended window of an unbucketed/uncapped time field.
- The chain-work binding ensures grinding effort on one fork does not carry to a parallel private branch rooted at the same ancestor — each fork has a distinct \(\text{prevChainWork}\).
Minimum-value target¶
Let \(T_{\text{pos}}\) be the current compact PoS target (nBits on the new block, derived by GetNextTargetRequired via the same Bitcoin-style retarget math, timespan 30 min / spacing 60 s). Then
ProofOfStake::CalculateMinValue (in src/blsct/pos/proof.cpp) returns the exact 256-bit quotient. ProofOfStake::SaturateToU64 clamps to UINT64_MAX on overflow instead of silently truncating to the low 64 bits — closing a pathological-difficulty exploit where the truncated threshold would be easier than the true one. ProofOfStakeLogic::Verify additionally rejects blocks whose saturating v_{\min} exceeds the representable CAmount range, so the condition surfaces explicitly rather than falling through to a range-proof failure.
Eligibility condition¶
The validator must prove
where \(v\) is the (hidden) committed stake amount in the commitment whose image is \(\varphi\). Since \(\text{KH}\) is a uniform 256-bit hash, a single commitment is eligible in a bucket with probability \(\Pr[\text{KH} \le v\,T_{\text{pos}}] = v\,T_{\text{pos}} / 2^{256}\) — linear in \(v\). Because \(\text{KH}\) binds \(\varphi\), each of a node's commitments draws independently, so the node's expected eligibility per bucket is \(\bigl(\sum_i v_i\bigr) T_{\text{pos}} / 2^{256}\) — proportional to its total staked value, regardless of how that value is split across commitments. This is the familiar PoS lottery, proved in zero knowledge and weighted by total stake.
Bulletproofs+ range argument¶
Implementation: rangeProof = RangeProver.Prove(Scalars{m}, gamma_seed{f}, {}, eta_phi, min_value) in ProofOfStake::ProofOfStake. A Bulletproofs++ range proof over commitment \(\sigma\) proves
The generators used by this range proof are rebased under \(\eta_\varphi\) via range_proof::GeneratorsFactory<T>::GetInstance(eta_phi) — identical rebase as the set-membership proof, so prover and verifier agree on the generator basis. Block-body binding (anti-malleability) is provided separately by \(\eta_{\text{FS}}\) (§5), not by this rebase.
Crucially, rangeProof.Vs.Clear() is called after proving: the commitment \(V\) carried by a standard Bulletproof is stripped, because the verifier reconstructs \(V = \varphi\) from the set-membership proof's output. The range proof proves "\(v \ge v_{\min}\) for the value committed in \(\varphi\)" — not for a separate commitment.
Verification¶
- Compute \(v_{\min}\) from \((\text{KH}, T_{\text{pos}})\).
- Attach \(\varphi\) (from the set-membership proof) as the range proof's \(V\).
- Run Bulletproofs+ verification with the provided \(\eta_\varphi\)-rebased generators.
5. Block-level bindings (anti-grinding, anti-malleability)¶
The two proof seeds have separate, deliberately disjoint roles. \(\eta_{\text{FS}}\) (the Fiat-Shamir challenge) carries the block-body signature; \(\eta_\varphi\) (the generator rebase) carries unlinkability and must be ungrindable. Mixing the two — e.g. deriving the generators from the block body — would make \(\varphi\) (and hence the kernel) grindable through the transaction list, so they are kept apart.
eta_fiat_shamir — signs the block body¶
blsct::CalculateSetMemProofRandomness(pindexPrev, block) {
HashWriter ss{};
ss << pindexPrev->GetBlockHash()
<< pindexPrev->nStakeModifier
<< TX_NO_WITNESS(block.vtx);
return H(ss);
}
It binds the parent block (no cross-height replay) and the block's entire transaction list. \(\eta_{\text{FS}}\) feeds the proof's Fiat-Shamir transcript (GenInitialFiatShamir), so the resulting challenges — and the whole proof — are valid only for this exact vtx. Tampering with any transaction changes \(\eta_{\text{FS}}\) and invalidates the proof: the proof is a signature over the block body. Because \(\eta_{\text{FS}}\) feeds only the challenge, never the generators, it grants no grinding leverage over \(\varphi\) or the kernel.
eta_phi — ungrindable generator rebase¶
blsct::CalculateSetMemProofGeneratorSeedV2(pindexPrev) {
HashWriter ss{};
ss << pindexPrev->nHeight
<< pindexPrev->nStakeModifier
<< pindexPrev->GetBlockHash();
return H(ss);
}
It rebases the set-membership / range-proof generators \((g_2, h_3)\) from fixed parent state only. Consequences:
- Ungrindable. \(\varphi = g_2^m h_3^f\) depends on \((g_2, h_3)\), which now depend only on the parent. A staker cannot vary \(\varphi\) for a given coin by reshaping the block, so the kernel input \(\varphi\) (§4) is fixed per (coin, parent) and cannot be ground.
- Still unlinkable. \((g_2, h_3)\) change every block height (each height has a distinct parent), so the same coin produces a different \(\varphi\) in every block it stakes. By DDH in \(\mathbb{G}\) those images are uncorrelatable. A coin stakes at most once per height it wins, so the per-height-constant rebase is never a linkage.
Future-time cap¶
Because the kernel binds \(\text{bucket}_{16}(\text{time})\) and the block's own nTime is staker-chosen, a wide future-time window would let a staker pre-compute many not-yet-valid kernel buckets at once. PoPS caps how far a PoS block's timestamp may lead the validator's clock:
// src/blsct/pos/helpers.h
static constexpr int64_t POPS_MAX_FUTURE_BLOCK_TIME = 6 * POPS_TIME_GRANULARITY_SECONDS; // 96 s
ContextualCheckBlockHeader rejects a PoS block whose time exceeds now + POPS_MAX_FUTURE_BLOCK_TIME (96 s ≈ 6 buckets), versus the generic 2-hour limit for non-PoS headers. Honest stakers build at curtime ≈ now and are unaffected; a grinder is bounded to ~6 future buckets per slot. The block's nTime still feeds the kernel (advancing real time reveals fresh buckets — essential for liveness when a slot has no winner), but the reachable set is small and bounded.
Stake modifier¶
nStakeModifier is a 64-bit value accumulated across historical PoS blocks; a staker cannot cheaply predict it ahead of time. GetStakeModifierSelectionInterval sums 64 sections using MODIFIER_INTERVAL_RATIO = 3. The same Bitcoin-PoS (ppcoin) style scheme; see GetLastStakeModifier, GetStakeModifierSelectionIntervalSection in pos.cpp.
6. Full verifier flow¶
From ProofOfStakeLogic::Verify:
- Sample the staked commitment ring from the coins view using \(\text{ringSeed}\) (§2) — the same deterministic sample the prover used.
- Reject if the sampled ring size < 2 (anti-deanonymisation).
- Recompute \(\eta_{\text{FS}} = H(\text{prevHash} \mathbin\Vert \text{prevStakeModifier} \mathbin\Vert \text{TX\_NO\_WITNESS(vtx)})\).
- Recompute \(\eta_\varphi = H(\text{prevHeight} \mathbin\Vert \text{prevStakeModifier} \mathbin\Vert \text{prevHash})\).
- Recompute \(\text{KH} = H(\dots \mathbin\Vert \varphi)\) from the proof's \(\varphi\), and \(T_{\text{pos}}\).
- Call
block.posProof.Verify(staked_commitments, eta_fiat_shamir, eta_phi, kernel_hash, next_target):- Verify the set-membership sub-proof → returns
VALID/SM_INVALID. - Reconstruct the range proof with \(\varphi\) as its value commitment and rebased generators; verify →
VALID/RP_INVALID.
- Verify the set-membership sub-proof → returns
Both legs must pass. One call, two ZK sub-proofs, no revealed secrets.
7. Serialisation of ProofOfStake¶
On-chain block layout (see primitives/block.h):
class CBlock : public CBlockHeader {
blsct::ProofOfStake posProof;
std::vector<CTransactionRef> vtx;
};
posProof serialises (in order):
| Field | Size | Purpose |
|---|---|---|
setMemProof.phi |
48 B | Set element image \(\varphi\) |
setMemProof.A1 |
48 B | |
setMemProof.A2 |
48 B | |
setMemProof.S1 |
48 B | |
setMemProof.S2 |
48 B | |
setMemProof.S3 |
48 B | |
setMemProof.T1 |
48 B | |
setMemProof.T2 |
48 B | |
setMemProof.tau_x |
32 B | |
setMemProof.mu |
32 B | |
setMemProof.z_alpha |
32 B | |
setMemProof.z_tau |
32 B | |
setMemProof.z_beta |
32 B | |
setMemProof.t |
32 B | |
setMemProof.Ls |
var | \(\log_2 n\) IPA left points |
setMemProof.Rs |
var | \(\log_2 n\) IPA right points |
setMemProof.a, b |
32 B × 2 | IPA final scalars |
setMemProof.omega |
32 B | |
rangeProof (no Vs) |
var | Bulletproofs++, \(V\) reconstructed from \(\varphi\) |
For \(n \le 1024\) (the SetMemProofSetup::N ceiling), \(\log_2 n \le 10\) — the IPA is ~10 left + 10 right points. Typical posProof footprint: ~2.5 – 3 KB per block.
8. Cryptographic assumptions¶
From the paper and the set_mem_proof comments, soundness of PoPS relies on:
- Discrete log hardness in \(\mathbb{G}\) — protects the binding of Pedersen commitments and prevents forging \(\sigma\) that opens to a value of the attacker's choice.
- Decisional Diffie-Hellman (DDH) in \(\mathbb{G}\) — protects coin unlinkability: \((g^x h^y, g^a h^b, g^{xa} h^{yb})\) is indistinguishable from \((g^x h^y, g^a h^b, g^c h^d)\), so a \(\varphi\) cannot be correlated across blocks.
- Bulletproofs++ soundness + zero-knowledge — guarantees the range proof is sound (\(v < v_{\min}\) cannot produce a valid proof) and ZK (\(v\) is hidden).
- EUF-CMA of BLS signatures — used for all transaction-level authorisations that feed into the block and therefore into \(\eta_\varphi\).
- Random oracle model — all hashes (\(H\), \(H_1, \dots, H_5\), kernel hash, Fiat-Shamir challenge extraction) are treated as random oracles. The \(H_k\) families are deliberately constructed via
GenPoint(msg, k)so the discrete log of their output is unknown.
9. Fork resolution¶
From paper §7.2.3, realised in the chain-selection logic:
"Validators measure the accumulation of PoS Target in a specific leaf to determine which one is the valid. Since a lower accumulated target implies a higher amount of staked coins, the consensus rules will select the divergent chain with the lower target from the candidates."
Concretely the comparator is the same Bitcoin-like "most chain work" logic, with nBits-derived chain work summed across PoPS blocks (not PoW work). A chain backed by larger hidden stakes accumulates chain work faster; the ZK range proofs still prevent observers from seeing who those stakers are.
10. Staker workflow (navio-staker)¶
flowchart TD
A["Loop: poll getblocktemplate"] --> B["Receive staked_commitments set,<br>eta_fiat_shamir, eta_phi, bits,<br>prev_time, modifier, curtime"]
B --> C{"My locked output's<br>commitment in set?"}
C -- no --> A
C -- yes --> D["Build ProofOfStake (m, f, ...)"]
D --> E["SetMemProofProver::Prove<br>set-membership sub-proof"]
D --> F["Range proof over m >= v_min<br>with rebased generators"]
E --> G["Assemble CBlock.posProof"]
F --> G
G --> H{"Self-verify"}
H -- fail --> A
H -- pass --> I["submitblock"]
I --> A
Code path: navio-staker.cpp, functions GetStakedCommitments, GetBlockProposal, and the Loop outer driver.
11. Security properties (what PoPS hides and what it does not)¶
| Property | Status under PoPS |
|---|---|
| Staked amount per validator | Hidden (Pedersen commitment, Bulletproofs range proof) |
| Staker identity on a given block | Hidden (set-membership proof) |
| Linkage across blocks by the same staker | Hidden (DDH-protected \(\varphi\) rebase per block) |
| Total number of active stakers | Publicly visible (size of stakedCommitments set) |
| Total locked supply | Publicly hidden (sum over OP_LOCKSTAKE outputs' commitments is hidden, but can be inferred through extapolation of your own node staking yield) |
| Double-staking protection | Enforced by UTXO set — a locked commitment is spent once it leaves the set, preventing a proof that reuses it against a later block (the commitment will no longer appear in \(\mathbf{Y}\)). |
| Grinding (forging extra lottery draws) | All kernel/ring inputs are seeded from fixed parent state except a bucketed time bounded by a 96 s future cap (§5, §12.2). A staker reaches ~6 kernels per slot, not an open-ended sweep; \(\varphi\) and the ring cannot be ground at all. Stake weight stays proportional to total holdings. |
| Long-range attacks | Mitigated by weak-subjectivity checkpoints (paper §9 security considerations) — standard PoS-style assumption. |
| Range-proof forgery (fake \(v \ge v_{\min}\)) | Computationally infeasible under Bulletproofs++ soundness. |
| Rogue-key attack on aggregated BLS balance sig | Prevented by signing the constant message "BLSCTBALANCE" combined with a valid Bulletproofs range proof; an attacker cannot forge without the secret mask. |
12. Consensus hardening rules¶
The following rules are part of the PoPS consensus design. Each closes a specific class of attack on the stake-eligibility, grinding, or long-range-attack surface. They are gated by height via Consensus::Params::nPoPSKernelV2Height (the kernel/grinding rules of §12.2, active on mainnet from genesis and on testnet from a scheduled height) and by Consensus::Params::fPoPSHardened (the saturation and subgroup rules). On a network whose historical chain predates a rule, blocks below its activation point validate under that network's historical parameters. Slashing is out of scope for the current design and is tracked separately — see Slashing (future work).
12.1. Saturating min_value extraction¶
ProofOfStake::CalculateMinValue returns a 256-bit integer KH / T_pos. A naive narrowing to 64 bits via GetUint64(0) would silently wrap on overflow, producing an easier eligibility bound than intended — exploitable at pathologically tight nBits. PoPS therefore specifies SaturateToU64: values exceeding 2^64 − 1 clamp to UINT64_MAX. ProofOfStakeLogic::Verify additionally rejects blocks whose saturating min_value exceeds the representable CAmount range. No legitimate BLSCT commitment opens to a value in that regime, so the block is vacuously invalid; the explicit reject surfaces the condition directly instead of relying on downstream failures.
12.2. Grinding surface reduction¶
A grinding attacker boosts their block rate above their stake share by cheaply trying many independent kernel hashes per slot. PoPS removes every cheap source of kernel variation by seeding each input from fixed prior chain state, leaving only a small, bounded time component:
- Ungrindable \(\varphi\). The kernel binds \(\varphi\), but \(\varphi\)'s generators are rebased from \(\eta_\varphi\) — derived from parent state only (§5). A staker cannot vary \(\varphi\) for a given coin by reshaping the block, so the dominant grinding axis (an unbounded sweep over block contents) is closed.
- Ungrindable ring. The staked-commitment ring is seeded from
stakeModifier+ a 128-block-deep ancestor (§2), neither of which the parent-block producer controls. A staker cannot grind which competitors are sampled into the ring. - Time bucketing + future cap.
block.nTimeenters the kernel only as a 16-second bucket (POPS_TIME_GRANULARITY_SECONDS), and a PoS block may lead the clock by at mostPOPS_MAX_FUTURE_BLOCK_TIME = 96 s(§5). Together these bound the reachable kernels per slot to ~6, while still letting real time advance buckets for liveness. - Chain-work binding. The kernel hashes
pindexPrev->nChainWork. Two forks diverging from a common ancestor disagree onnChainWorkimmediately, so grinding effort on one fork does not carry to a parallel private branch.
These rules activate at Consensus::Params::nPoPSKernelV2Height (mainnet: from genesis; testnet: a scheduled height). Blocks below that height on a network that predates the rules validate under the network's historical parameters.
12.3. Long-range-attack mitigation¶
Hard finality checkpoints in Consensus::Params::finalityCheckpoints. Any candidate whose block at a listed height disagrees with the baked-in hash is rejected regardless of accumulated chain work. Populated per-release from agreed hashes. Strictly additive to nMinimumChainWork / defaultAssumeValid — those handle weak subjectivity via cumulative work; checkpoints handle posterior-corruption attacks where leaked historical validator material could otherwise rewrite deep history.
12.4. Subgroup membership on G1 deserialization¶
MclG1Point::SetVch calls mclBnG1_isValidOrder after mclBnG1_deserialize on every G1 point it accepts. BLS12-381 G1 has a cofactor; curve membership alone does not imply prime-order-subgroup membership, and the discrete-log assumption only holds on the order-r subgroup. The check covers every deserialised G1 point — PoPS proofs, range proofs, signatures, public keys — with an explicit carve-out for the identity (the point at infinity is a valid BLSCT-commitment value).
Subgroup checks are the largest per-point cost in deserialisation, so the codebase additionally exposes MclG1Point::SubgroupCheckDeferralScope: within a scope, individual SetVch calls queue points; on scope exit, BatchCheckSubgroup validates the entire batch via a single random-linear-combination multi-exp. ProofOfStake::Unserialize uses this to amortise the tens-of-G1-points in a PoPS proof into one check without relaxing the rule.
12.5. Slashing (out of scope)¶
Slashing is not part of the current PoPS consensus rules. The codebase reserves OP_SLASH_STAKE (opcode + SlashingWitness struct + script-pattern recognition) so that future activation is a targeted upgrade rather than a redesign. Until then, consensus rejects any scriptSig matching the slashing-unlock pattern with slashing-not-activated. See Slashing (future work) for the intended construction (DLEQ-tagged nonces preserving coin unlinkability) and the activation roadmap.
12.6. Residual assumptions¶
The rules above close specific attack surfaces within the PoPS threat model. The following baseline assumptions remain — they are standard for PoS-class protocols and BLS-based privacy schemes, and nothing about PoPS uniquely strengthens or weakens them:
- CSPRNG. Per-proof nonces are sampled from the OS RNG. Compromised randomness leaks the commitment opening via one-time exposure.
- Weak subjectivity. New nodes must obtain a recent trusted hash to sync safely. Handled by
nMinimumChainWork+defaultAssumeValid+finalityCheckpoints. - Majority honest stake. Chain-selection is "lowest accumulated target wins"; a majority of stake rewriting history trivially forks the chain. Matches the standard PoS assumption.
- Bulletproofs+ soundness / BLS EUF-CMA / DL + DDH on BLS12-381 G1. The baseline hardness assumptions.
Source tree¶
navio-core/
├── src/blsct/pos/
│ ├── pos.{h,cpp} Kernel hash, retarget, stake modifier, entropy binding
│ ├── helpers.{h,cpp} Kernel hash (binds phi), time bucketing, future cap, ring-seed depth
│ ├── proof.{h,cpp} ProofOfStake class: combined (set-mem, range) proof
│ └── proof_logic.{h,cpp} Create / Verify wrappers against CCoinsViewCache
├── src/blsct/set_mem_proof/
│ ├── set_mem_proof.h SetMemProof struct on-chain layout
│ ├── set_mem_proof_setup.{h,cpp} Domain-separated hash families, N=1024 generators
│ └── set_mem_proof_prover.{h,cpp} Prove + Verify, improved inner-product argument
├── src/coins.{h,cpp} CCoinsView::GetStakedCommitments, STAKED_COMMITMENT_* flags
├── src/validation.cpp ConnectBlock → ProofOfStakeLogic::Verify
├── src/primitives/block.h CBlock::posProof field
└── src/navio-staker.cpp Staker daemon: polling, proof construction, submission
See also¶
- Concepts → Consensus & supply — high-level overview.
- Node operator → Staking — operational setup,
stakelock/stakeunlock. - BLSCT → Range proofs — Bulletproofs++ primitives used here.
- BLSCT → Signatures — BLS aggregation behind
BLSCTBALANCEtx signatures. - Navio design paper: Navio: A Privacy-enhanced UTXO Blockchain (Dec 2025 draft). Implementation in
navio-coresupersedes the paper where they diverge.