Skip to content

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:

  1. It controls an output in the current staked commitment set — without revealing which one.
  2. That output's hidden amount is large enough to satisfy the Kernel-Hash eligibility inequality at this block height.
  3. 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 = 0 and 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::Verify in src/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):

\[ \text{ringSeed} = H\bigl(\text{stakeModifier}\ \Vert\ B_{n-128}\ \Vert\ \text{bucket}_{16}(\text{time})\bigr) \]

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

\[ \mathbf{b_L} \circ \mathbf{b_R} = \mathbf{0}^n, \qquad \mathbf{b_L} - \mathbf{b_R} = \mathbf{1}^n, \qquad \langle \mathbf{b_L}, \mathbf{1}^n \rangle = 1. \]

Commit 1. Compute

\[ h_2 = H_5(\mathbf{Y}), \qquad (g_2, h_3) = \text{GenFactory.Rebase}(\eta_\varphi). \]

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

\[ \begin{aligned} A_1 &= h_2^{\alpha} \cdot \mathbf{Y}^{\mathbf{b_L}} = h_2^{\alpha} Y_{i^*} \\ A_2 &= h_2^{\beta}\cdot \mathbf{h}^{\mathbf{b_R}} \\ S_1 &= h_2^{r_\alpha} h^{r_\beta} g^{r_\tau} \\ S_2 &= h_2^{\rho} \cdot \mathbf{Y}^{\mathbf{s_L}} \cdot \mathbf{h}^{\mathbf{s_R}} \\ S_3 &= h_3^{r_\tau} g_2^{r_\beta}. \end{aligned} \]

Set element image.

\[ \varphi = g_2^m h_3^f. \]

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:

\[ \text{str} = \mathbf{Y} \mathbin\Vert A_1 \mathbin\Vert A_2 \mathbin\Vert S_1 \mathbin\Vert S_2 \mathbin\Vert S_3 \mathbin\Vert \varphi \mathbin\Vert \eta_{\text{FS}}. \]

Derive \(y, z, \omega \leftarrow H(\text{str})\).

Commit 2. Build degree-1 vector polynomials

\[ \begin{aligned} \mathbf{l}(X) &= \mathbf{b_L} - z \cdot \mathbf{1}^n + \mathbf{s_L} \cdot X \\ \mathbf{r}(X) &= \mathbf{y}^n \circ (\omega \mathbf{b_R} + \omega z \mathbf{1}^n + \mathbf{s_R} X) + z^2 \mathbf{1}^n \end{aligned} \]

and the scalar polynomial \(t(X) = \langle \mathbf{l}(X), \mathbf{r}(X) \rangle = t_0 + t_1 X + t_2 X^2\) where

\[ t_0 = z^2 + \omega(z - z^2)\langle \mathbf{1}^n, \mathbf{y}^n \rangle - z^3 \langle \mathbf{1}^n, \mathbf{1}^n \rangle. \]

Pick \(\tau_1, \tau_2 \leftarrow \mathbb{F}_p\) and commit:

\[ T_1 = g^{t_1} h^{\tau_1}, \qquad T_2 = g^{t_2} h^{\tau_2}. \]

Challenge 2. \(x = H_1(\omega, y, z, T_1, T_2)\).

Response.

\[ \begin{aligned} \tau_x &= \tau_1 x + \tau_2 x^2 \\ \mu &= \alpha + \beta \omega + \rho x \\ z_\alpha &= r_\alpha + \alpha x \\ z_\tau &= r_\tau + f x \\ z_\beta &= r_\beta + m x \\ \mathbf{l} &= \mathbf{l}(x), \quad \mathbf{r} = \mathbf{r}(x), \quad t = \langle \mathbf{l}, \mathbf{r} \rangle. \end{aligned} \]

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:

\[ \pi_{\text{set}} = (\varphi,\ A_1, A_2, S_1, S_2, S_3, T_1, T_2,\ \tau_x, \mu, z_\alpha, z_\tau, z_\beta, t,\ \mathbf{L}, \mathbf{R}, a, b, \omega). \]

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

\[ g^{t} h^{\tau_x} = g^{z^2 + \omega(z-z^2)\langle\mathbf{1}^n,\mathbf{y}^n\rangle - z^3 \langle\mathbf{1}^n,\mathbf{1}^n\rangle} \cdot T_1^{x} \cdot T_2^{x^2} \]

(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

\[ A_1 A_2^{\omega} S_2^{x} h_2^{-\mu} \cdot \prod L_i^{x_i^2} R_i^{x_i^{-2}} \stackrel{?}{=} \mathbf{G}^{\vec{g}\text{-exps}} \mathbf{H}^{\vec{h}\text{-exps}} g^{(t - ab) c_{\text{factor}}}. \]

(20) — opening of \(\sigma\) at \(A_1\)

\[ h_2^{z_\alpha} h^{z_\beta} g^{z_\tau} \stackrel{?}{=} S_1 \cdot A_1^{x}. \]

(21) — opening of \(\varphi\) at \(S_3\) (the Navio-specific addition over RingCT 3.0)

\[ h_3^{z_\tau} g_2^{z_\beta} \stackrel{?}{=} S_3 \cdot \varphi^{x}. \]

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\),

\[ \Pr\left[\mathcal{A}(\mathbb{G}, g, g^x, g^y, h) = b \,\middle|\, b \leftarrow \{0,1\},\ h = \begin{cases} g^{xy} & b=0 \\ g^z & b=1 \end{cases}\right] \le \tfrac{1}{2} + \text{negl}(\lambda). \]

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

\[ \text{KH} = H\bigl(\text{prevTime} \mathbin\Vert \text{stakeModifier} \mathbin\Vert \text{prevChainWork} \mathbin\Vert \text{bucket}_{16}(\text{time}) \mathbin\Vert \varphi\bigr). \]

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

\[ v_{\min} = \text{SaturateToU64}\!\left(\left\lfloor \frac{\text{KH}}{T_{\text{pos}}} \right\rfloor\right). \]

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

\[ v \ge v_{\min} \]

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

\[ v - v_{\min} \in [0, 2^{64}). \]

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

ProofOfStake::VerifyKernelHash(range_proof, kernel_hash, next_target, eta_phi, setMemProof.phi)
  1. Compute \(v_{\min}\) from \((\text{KH}, T_{\text{pos}})\).
  2. Attach \(\varphi\) (from the set-membership proof) as the range proof's \(V\).
  3. 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);
}
\[ \eta_{\text{FS}} = H\bigl(\text{parentHash} \mathbin\Vert \text{parentStakeModifier} \mathbin\Vert \text{TX\_NO\_WITNESS}(\text{vtx})\bigr). \]

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);
}
\[ \eta_\varphi = H\bigl(\text{parentHeight} \mathbin\Vert \text{parentStakeModifier} \mathbin\Vert \text{parentHash}\bigr). \]

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:

  1. Sample the staked commitment ring from the coins view using \(\text{ringSeed}\) (§2) — the same deterministic sample the prover used.
  2. Reject if the sampled ring size < 2 (anti-deanonymisation).
  3. Recompute \(\eta_{\text{FS}} = H(\text{prevHash} \mathbin\Vert \text{prevStakeModifier} \mathbin\Vert \text{TX\_NO\_WITNESS(vtx)})\).
  4. Recompute \(\eta_\varphi = H(\text{prevHeight} \mathbin\Vert \text{prevStakeModifier} \mathbin\Vert \text{prevHash})\).
  5. Recompute \(\text{KH} = H(\dots \mathbin\Vert \varphi)\) from the proof's \(\varphi\), and \(T_{\text{pos}}\).
  6. 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.

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:

  1. 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.
  2. 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.
  3. Bulletproofs++ soundness + zero-knowledge — guarantees the range proof is sound (\(v < v_{\min}\) cannot produce a valid proof) and ZK (\(v\) is hidden).
  4. EUF-CMA of BLS signatures — used for all transaction-level authorisations that feed into the block and therefore into \(\eta_\varphi\).
  5. 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.nTime enters the kernel only as a 16-second bucket (POPS_TIME_GRANULARITY_SECONDS), and a PoS block may lead the clock by at most POPS_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 on nChainWork immediately, 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