Introduction

Imagine a bank transaction where the amount must be positive — you can't spend money you don't have — but the amount itself should stay private. How can the network verify the rule without seeing the number?

That tension — prove a fact about a secret without revealing the secret — is what zero-knowledge proofs (ZKPs) are built for. And one of the most practically important ZKPs is the range proof: a short cryptographic certificate that says "I know a number xx such that 0≤x<2320 \leq x < 2^{32}" without disclosing xx at all.

The key ingredient is a commitment: a value CC that binds you to xx (you can't change it later) but hides xx from everyone else — like sealing a number in an envelope. A range proof then convinces a verifier that the committed value really does fall inside the promised interval, using nothing but math.

This is not just a theoretical curiosity. Range proofs are the core cryptographic primitive behind confidential transactions in blockchains like Monero and the Mimblewimble protocol, and they appear in privacy-preserving credentials, anonymous voting, and secure multi-party computation.

Try It

Pick a secret value and a valid range, then commit it. The demo simulates a Prover and a Verifier: the prover generates a proof that the committed value lies in the range, and the verifier checks the proof — without ever learning what the value is.

<!-- Zero-knowledge range proof demo: commit a secret value, then prove it is in range -->
<div class="panel">
  <div class="section-label">Prover</div>
  <label class="field-label">Secret value (x):
    <input id="secret" type="number" min="0" max="255" value="42" title="The secret number you want to prove is in range">
  </label>
  <label class="field-label">Claimed range:
    <select id="rangeMax" title="Choose the upper bound of the range">
      <option value="7">0 – 7 (3 bits)</option>
      <option value="15">0 – 15 (4 bits)</option>
      <option value="31">0 – 31 (5 bits)</option>
      <option value="63">0 – 63 (6 bits)</option>
      <option value="127">0 – 127 (7 bits)</option>
      <option value="255" selected>0 – 255 (8 bits)</option>
    </select>
  </label>
  <button id="btnCommit" type="button">Commit</button>
</div>

<div class="panel commitment-panel" id="commitPanel" style="display:none">
  <div class="section-label">Commitment (public)</div>
  <div class="commit-row">
    <span class="mono" id="commitVal"></span>
    <span class="tag">C</span>
  </div>
  <div class="hint-small">The verifier sees this hash — it binds you to x without revealing x.</div>
  <button id="btnProve" type="button">Generate Proof</button>
</div>

<div class="panel proof-panel" id="proofPanel" style="display:none">
  <div class="section-label">Bit Decomposition Proof</div>
  <div id="bitRows" class="bit-rows"></div>
  <div class="hint-small">Each bit of x gets its own commitment. The verifier checks they are all 0 or 1 and reconstruct x.</div>
</div>

<div class="panel verifier-panel" id="verifierPanel" style="display:none">
  <div class="section-label">Verifier</div>
  <div class="status" id="verifyStatus"></div>
  <div class="hint-small" id="verifyDetail"></div>
  <button id="btnReset" type="button" class="ghost">Start Over</button>
</div>
/* Layout for the ZK range proof interactive demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 15px; }
.panel { background: #f4f7fa; border: 1px solid #d0dae4; border-radius: 10px;
         padding: .8rem 1rem; margin-bottom: .6rem; }
.section-label { font-size: .7rem; font-weight: 700; text-transform: uppercase;
                 letter-spacing: .06em; color: #5a7088; margin-bottom: .45rem; }
.field-label { display: flex; align-items: center; gap: .5rem; font-size: .9rem;
               font-weight: 600; margin-bottom: .45rem; }
input[type=number] { width: 72px; padding: .3rem .4rem; border: 1px solid #b0bec5;
                     border-radius: 6px; font-size: .95rem; text-align: center; }
select { padding: .3rem .4rem; border: 1px solid #b0bec5; border-radius: 6px;
         font-size: .9rem; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer;
         margin-top: .3rem; }
button.ghost { background: #fff; color: #1d3557; }
.commit-row { display: flex; align-items: center; gap: .6rem; margin-bottom: .35rem; }
.mono { font-family: ui-monospace, monospace; font-size: .82rem; color: #1d3557;
        background: #dce8f3; padding: .2rem .45rem; border-radius: 5px;
        word-break: break-all; }
.tag { font-size: .7rem; font-weight: 700; text-transform: uppercase; background: #e2ecf5;
       color: #3a6080; padding: .15rem .4rem; border-radius: 4px; }
.hint-small { font-size: .8rem; color: #555; line-height: 1.4; margin-top: .3rem; }
/* Bit decomposition cells — each bit gets its own Pedersen commitment */
.bit-rows { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .4rem; }
.bit-cell { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.bit-box { width: 34px; height: 34px; border-radius: 6px; display: flex;
           align-items: center; justify-content: center; font: 700 14px ui-monospace;
           border: 1.5px solid #8da7be; }
.bit-box.b0 { background: #e8eef3; color: #444; }
.bit-box.b1 { background: #1d3557; color: #fff; border-color: #1d3557; }
.bit-idx { font-size: .6rem; color: #7a8fa0; }
.commit-mini { font-family: ui-monospace; font-size: .7rem; color: #3a6080;
               background: #dce8f3; border-radius: 4px; padding: .1rem .3rem;
               margin-top: 2px; }
.status { font-size: 1rem; font-weight: 700; padding: .3rem 0; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.verifier-panel { background: #f0f9f4; border-color: #b8d8c6; }
.verifier-panel .status.bad { background: #fdf0f0; border-radius: 6px;
                              padding: .3rem .5rem; }
// Zero-Knowledge Range Proof demo
// We simulate Pedersen commitments and bit-decomposition proofs with toy arithmetic.
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

// Pedersen commitment: C = G^x * H^r mod p
// C = x*G + r*H  (mod p), simulated with integers mod a large prime
const P = 2147483647; // Mersenne prime used as group order (2^31 - 1, Mersenne prime)
const G = 3;          // generator G
const H = 7;          // independent generator H (chosen so nobody knows log_G(H))

function modpow(base, exp, mod) {
  // Fast modular exponentiation (BigInt for overflow safety)
  let result = 1n;
  base = BigInt(base) % BigInt(mod);
  exp = BigInt(exp);
  mod = BigInt(mod);
  while (exp > 0n) {
    if (exp % 2n === 1n) result = result * base % mod;
    exp = exp / 2n;
    base = base * base % mod;
  }
  return Number(result);
}

function pedersen(x, r) {
  // Compute Pedersen commitment C = G^x * H^r mod P
  return (modpow(G, x, P) * modpow(H, r, P)) % P;
}

function randBlind() {
  // Random blinding factor (in a real system this would be cryptographically random)
  return Math.floor(Math.random() * 1000000) + 1;
}

// Shared state between Prover and Verifier steps
let state = {};

function hexShort(n) {
  // Display a number as a short hex string
  return '0x' + n.toString(16).toUpperCase().padStart(8, '0');
}

document.getElementById('btnCommit').addEventListener('click', () => {
  // Prover: pick secret x, choose random blinding r, publish C = G^x * H^r mod P
  const x = parseInt(document.getElementById('secret').value, 10);
  const maxVal = parseInt(document.getElementById('rangeMax').value, 10);
  if (isNaN(x) || x < 0) {
    alert('Please enter a non-negative integer.');
    return;
  }
  const r = randBlind();
  const C = pedersen(x, r);
  state = { x, r, C, maxVal };

  document.getElementById('commitVal').textContent = hexShort(C);
  document.getElementById('commitPanel').style.display = '';
  document.getElementById('proofPanel').style.display = 'none';
  document.getElementById('verifierPanel').style.display = 'none';
  document.getElementById('bitRows').innerHTML = '';
});

document.getElementById('btnProve').addEventListener('click', () => {
  // Prover: decompose x into bits, commit each bit separately
  const { x, r, C, maxVal } = state;
  const bits = Math.round(Math.log2(maxVal + 1));

  // Decompose x in binary and create one commitment per bit
  const bitValues = [];
  const bitBlinds = [];
  const bitCommits = [];

  for (let i = 0; i < bits; i++) {
    const b = (x >> i) & 1;
    const rb = randBlind();
    const cb = pedersen(b, rb);
    bitValues.push(b);
    bitBlinds.push(rb);
    bitCommits.push(cb);
  }
  state.bitValues = bitValues;
  state.bitBlinds = bitBlinds;
  state.bitCommits = bitCommits;
  state.bits = bits;

  // Render the bit commitments as colored boxes (dark = 1, light = 0)
  const container = document.getElementById('bitRows');
  container.innerHTML = '';
  for (let i = bits - 1; i >= 0; i--) {
    const cell = document.createElement('div');
    cell.className = 'bit-cell';
    const box = document.createElement('div');
    box.className = 'bit-box b' + bitValues[i];
    box.textContent = bitValues[i];
    const idx = document.createElement('div');
    idx.className = 'bit-idx';
    idx.textContent = '2⁻' + i + ' ' + i18nFmt('(bit %i%)', { i });
    const cm = document.createElement('div');
    cm.className = 'commit-mini';
    cm.textContent = hexShort(bitCommits[i]);
    cell.appendChild(box);
    cell.appendChild(idx);
    cell.appendChild(cm);
    container.appendChild(cell);
  }

  document.getElementById('proofPanel').style.display = '';

  // Hand proof to the Verifier after a short pause
  setTimeout(() => verify(x, maxVal, bits, bitValues, bitCommits), 300);
});

function verify(x, maxVal, bits, bitValues, bitCommits) {
  // Verifier checks: all committed values are bits AND x is in [0, maxVal]
  const inRange = x >= 0 && x <= maxVal;

  // In a real proof the verifier checks the commitment equations; here we simulate the outcome
  const allBits = bitValues.every(b => b === 0 || b === 1);

  document.getElementById('verifierPanel').style.display = '';
  const statusEl = document.getElementById('verifyStatus');
  const detailEl = document.getElementById('verifyDetail');

  if (inRange && allBits) {
    statusEl.className = 'status ok';
    statusEl.textContent = '✓ Proof accepted — value is in range';
    detailEl.textContent = i18nFmt('Verified %bits% bit-commitments. Confirmed: 0 ≤ x ≤ %maxVal%. The verifier learned nothing about x itself.', { bits, maxVal });
  } else {
    statusEl.className = 'status bad';
    statusEl.textContent = '✗ Proof rejected — value is out of range';
    detailEl.textContent = i18nFmt('The value exceeds the claimed bound of %maxVal%. A real prover cannot fake this without breaking discrete log.', { maxVal });
  }
}

document.getElementById('btnReset').addEventListener('click', () => {
  // Reset demo to initial state
  state = {};
  document.getElementById('commitPanel').style.display = 'none';
  document.getElementById('proofPanel').style.display = 'none';
  document.getElementById('verifierPanel').style.display = 'none';
  document.getElementById('secret').value = '42';
  document.getElementById('rangeMax').value = '255';
  document.getElementById('bitRows').innerHTML = '';
});
Result
Console

Notice the key property: the verifier sees only the commitment and the proof, never the secret. Try entering a value outside the range — the proof fails, and the commitment gives nothing away about the actual number. The proof is zero-knowledge (the verifier learns nothing beyond "it's in range") and succinct (the certificate is tiny compared to checking all possibilities).

The Real Complexity

A naïve range proof for x∈[0,2n−1]x \in [0, 2^{n}-1] works by decomposing xx in binary and proving each bit is 00 or 11 — one sub-proof per bit, so the proof size is O(n)O(n). For n=32n = 32 that is already 32 Schnorr-style sub-proofs, which is workable but large.

The breakthrough came with Bulletproofs (Bünz et al., 2017), which use an inner-product argument to collapse the nn bit-commitments into a proof of size O(log⁡n)O(\log n) — about 700 bytes for a 64-bit range, without any trusted setup.

  • Soundness rests on the discrete-logarithm hardness assumption: an adversary who could forge a range proof would be able to compute discrete logs, which is believed to be computationally infeasible.
  • Zero-knowledge is guaranteed because the commitment scheme (Pedersen commitments, of the form C=xG+rHC = xG + rH for random blinding factor rr) reveals nothing about xx.
  • Verification costs O(log⁡n)O(\log n) group operations — fast enough to validate thousands of confidential transactions per second.

The result sits at the intersection of discrete logarithm hardness and interactive proof systems, and it exemplifies how modern cryptography turns intractability into a feature rather than a barrier.

Where It Matters

Range proofs solve the "prove a bound without disclosing the value" problem that appears across privacy-sensitive systems:

  • Confidential transactions: Monero uses Bulletproofs so that every transaction proves its amounts are non-negative and sum correctly, without revealing the amounts to anyone on the network.
  • Anonymous age verification: prove you are over 18 without disclosing your birth date — a range proof on a committed birthdate timestamp.
  • Private auction bids: prove your bid is above a reserve price without revealing the bid amount until the auction closes.
  • Zero-knowledge machine learning: prove a model output falls in an acceptable range without exposing the model weights or the input data.
  • Regulatory compliance: a financial institution proves a balance or position stays within regulatory limits, while the regulator learns only "compliant" or "not compliant."

Learn how range proofs work and you've unlocked a key building block of modern zero-knowledge proof systems — the same kind of privacy-preserving arithmetic that powers Zcash, StarkNet, and the next generation of privacy protocols.

Conclusion

A range proof is a certificate that a secret number obeys a rule — without revealing the number. It combines the hiding power of a commitment scheme with the persuasive force of a zero-knowledge argument, and Bulletproofs make the whole thing fit in a few hundred bytes.

The deeper lesson is that computational hardness can be an asset: because the discrete logarithm problem is believed to be hard, we can build commitments that are perfectly hiding yet publicly verifiable. An adversary who broke discrete log would break range proofs too — but as long as the problem stays hard, your secret stays safe, provably in range, and completely undisclosed.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/zero-knowledge-range-proofs/Content licensed under CC BY-NC 4.0.