A proof-of-concept demonstrating how to use Noir to prove that selected Bitcoin UTXOs have a combined value of at least 1 BTC, without revealing which UTXOs they are.
The prover generates a proof for the following statement:
I know a set of Bitcoin UTXOs belonging to a committed Bitcoin snapshot whose combined value is at least 100,000,000 sats.
The verifier learns only that the statement is true.
The proof does not reveal:
- UTXO identifiers,
- Bitcoin addresses,
- exact balances,
- transaction history,
- private keys.
This repository prioritizes simplicity and portability over production readiness.
Current implementation status:
- Rust can load the sample snapshot and generate
Prover.toml. - Noir verifies up to four selected UTXOs in a fixed two-level Merkle tree.
- Noir enforces
sum(values) >= 100_000_000. - Tests cover valid input, below-threshold input, and a wrong Merkle path.
- The prototype Merkle tree uses Blake2s over fixed byte encodings.
- Bitcoin ownership can be checked off-circuit with signed WIF ownership proofs.
Bitcoin Snapshot
│
▼
Build Merkle Tree
│
▼
Publish Merkle Root
│
▼
Prover selects owned UTXOs
│
▼
Generate Merkle inclusion proofs
│
▼
Noir Circuit
├─ Verify membership
├─ Sum UTXO values
└─ Assert total ≥ 1 BTC
│
▼
Generate Proof
│
▼
Verifier checks proof
Public statement:
∃ utxos :
valid_membership(utxos)
∧ sum(values) ≥ 100_000_000
Private witness:
- UTXO entries,
- Merkle paths,
- UTXO values.
Public inputs:
- Merkle root,
- proof.
zkpoh/
├── circuits/
│ ├── merkle.nr
│ ├── threshold.nr
│ └── main.nr
├── snapshots/
│ └── utxo_snapshot.json
├── src/
│ ├── snapshot_builder.rs
│ ├── prover.rs
│ └── verifier.rs
├── Nargo.toml
└── README.md
Each provided UTXO must belong to the committed Bitcoin snapshot.
Inputs:
leaf
merkle_path
merkle_index
merkle_root
Constraint:
blake2s(left_digest || right_digest) == merkle_root
The circuit aggregates the values of all provided UTXOs.
Constraint:
Σ(value_i) ≥ 100_000_000
If the condition is not satisfied, proof generation fails.
Current prototype assumptions:
- Bitcoin snapshot is generated off-chain.
- Snapshot is trusted.
- UTXO ownership is assumed by the prover.
- No nullifiers are implemented.
- Proofs represent ownership only at snapshot time.
Future versions may include:
- Schnorr ownership verification,
- Utreexo commitments,
- snapshot epochs,
- nullifiers,
- arbitrary thresholds.
- Noir
- Nargo
- Rust
- Bitcoin Core (optional for regtest experiments)
This tutorial walks through the current prototype from a clean clone to a
successful Noir constraint run. In this version, prove means "generate witness
inputs and execute the Noir circuit constraints." It does not yet produce a
portable cryptographic proof artifact with a separate verifier command.
The crate exposes the witness, Merkle hashing, ownership proof, regtest snapshot,
and circuit helper APIs from src/lib.rs.
From another local Rust project:
[dependencies]
zkpoh = { path = "../zkPoH" }Basic witness generation:
use zkpoh::{build_witness, format_digest, load_snapshot};
fn main() -> anyhow::Result<()> {
let snapshot = load_snapshot("snapshots/utxo_snapshot.json")?;
let witness = build_witness(&snapshot)?;
println!("merkle_root = {}", format_digest(&witness.merkle_root));
Ok(())
}Run the included example:
cargo run --example build_witnessInstall:
- Rust and Cargo
- Noir / Nargo
1.0.0-beta.7or compatible - Bitcoin Core, only if you want to run the regtest tutorial
Check the main tools:
cargo --version
nargo --version
bitcoin-cli -versiongit clone https://github.com/fabohax/zkPoH.git
cd zkPoHRun the full local validation suite:
cargo run -- test-allOr use the Makefile shortcut:
make testYou can also run the individual checks directly:
cargo test
nargo test
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warningsCheck the Noir circuit:
cargo run -- check-circuitThe default snapshot is snapshots/utxo_snapshot.json. It contains two example
UTXOs whose values sum to exactly 100_000_000 sats. The circuit supports up
to four UTXOs; unused slots are padded with empty leaves.
Run the full happy path:
cargo run -- demoOr:
make demoGenerate Prover.toml from that snapshot:
cargo run -- build-witnessExecute the Noir circuit with the generated inputs:
nargo executeOr run the full prototype path:
cargo run -- proveExpected result:
zkPoH proof constraints passed
Prover.toml is the input file consumed by nargo execute. It contains:
- public
merkle_root - private
txid_tags - private
vouts - private
values - private
merkle_paths - private
merkle_indices
The current prototype converts each UTXO into a leaf with:
leaf = blake2s(txid_tag || vout || value)
Then it pads unused slots with hash_leaf(0, 0, 0) and computes a fixed
four-leaf Merkle root:
node_0 = blake2s(leaf_0 || leaf_1)
node_1 = blake2s(leaf_2 || leaf_3)
root = blake2s(node_0 || node_1)
txid_tag is currently the final 8 bytes of the Bitcoin txid interpreted as a
big-endian u64. This keeps the circuit compact for the prototype.
The repository includes a regtest-derived fixture:
snapshots/regtest_utxo_snapshot.json
Prover.regtest.toml
To regenerate witness inputs from the regtest snapshot:
cargo run -- build-witness \
--snapshot snapshots/regtest_utxo_snapshot.json \
--output Prover.regtest.tomlTo run the circuit against the regtest snapshot:
cargo run -- prove \
--snapshot snapshots/regtest_utxo_snapshot.json \
--output Prover.tomlThis should solve the Noir witness and report totals in sats and BTC.
Start a local regtest node if one is not already running:
bitcoind -regtest -daemon -fallbackfee=0.0001
bitcoin-cli -regtest -rpcwait getblockchaininfoCreate a dedicated wallet:
bitcoin-cli -regtest createwallet zkpoh-regtestIf the wallet already exists, load it instead:
bitcoin-cli -regtest loadwallet zkpoh-regtestMine spendable regtest BTC:
MINING_ADDR=$(bitcoin-cli -regtest -rpcwallet=zkpoh-regtest getnewaddress mining bech32)
bitcoin-cli -regtest generatetoaddress 101 "$MINING_ADDR"Create two wallet UTXOs that sum to 1 BTC:
ADDR_A=$(bitcoin-cli -regtest -rpcwallet=zkpoh-regtest getnewaddress proof-a bech32)
ADDR_B=$(bitcoin-cli -regtest -rpcwallet=zkpoh-regtest getnewaddress proof-b bech32)
TXID=$(bitcoin-cli -regtest -rpcwallet=zkpoh-regtest sendmany "" \
"{\"$ADDR_A\":0.42,\"$ADDR_B\":0.58}")
bitcoin-cli -regtest generatetoaddress 1 "$MINING_ADDR"Generate a zkPoH snapshot automatically from the wallet's spendable confirmed UTXOs:
cargo run -- snapshot-regtest \
--wallet zkpoh-regtest \
--output snapshots/regtest_utxo_snapshot.jsonThe command selects the smallest set of up to four safe, spendable, confirmed UTXOs whose combined value meets the threshold.
Then generate and execute the witness:
cargo run -- prove \
--snapshot snapshots/regtest_utxo_snapshot.json \
--output Prover.regtest.tomlTo inspect or build the snapshot manually, list the selected UTXOs:
bitcoin-cli -regtest -rpcwallet=zkpoh-regtest listunspent \
1 9999999 "[\"$ADDR_A\",\"$ADDR_B\"]"Copy the resulting txid, vout, amount, and address fields into a snapshot
JSON file with this shape. Convert BTC amounts to sats for value, and replace
the example vout values with the actual output indexes from listunspent.
{
"snapshot": "bitcoin-regtest-utxo-snapshot",
"timestamp": "2026-06-12T00:00:00Z",
"threshold_sats": 100000000,
"utxos": [
{
"txid": "<txid>",
"vout": 1,
"value": 42000000,
"address": "<address-a>"
},
{
"txid": "<txid>",
"vout": 2,
"value": 58000000,
"address": "<address-b>"
}
]
}Verify each UTXO is live in Bitcoin Core, using the actual vout numbers from
listunspent:
VOUT_A=1
VOUT_B=2
bitcoin-cli -regtest gettxout "$TXID" "$VOUT_A"
bitcoin-cli -regtest gettxout "$TXID" "$VOUT_B"Then run:
cargo run -- prove --snapshot snapshots/regtest_utxo_snapshot.json --output Prover.regtest.tomlThe circuit currently proves snapshot membership and threshold. The terminal ownership signer adds a local pre-proof check: each selected UTXO address must match a provided Bitcoin private key, and the key must sign the deterministic zkPoH ownership challenge.
Preferred key input is a file with one WIF private key per line:
chmod 600 /path/to/wifs.txt
cargo run -- sign-ownership \
--snapshot snapshots/regtest_utxo_snapshot.json \
--wif-file /path/to/wifs.txt \
--output ownership_proof.json \
--network regtestYou can also read WIFs from an environment variable. Multiple WIFs may be comma-separated:
export ZKPOH_WIFS='c...'
cargo run -- sign-ownership \
--snapshot snapshots/regtest_utxo_snapshot.json \
--wif-env ZKPOH_WIFS \
--output ownership_proof.json \
--network regtestFor one-off testing only, pass WIFs directly:
cargo run -- sign-ownership \
--snapshot snapshots/regtest_utxo_snapshot.json \
--wif c... \
--output ownership_proof.json \
--network regtestPassing secrets directly in shell commands can leak through shell history and
process listings. Prefer --wif-file or --wif-env for terminal use.
The signer writes ownership_proof.json containing:
- the canonical ownership challenge
- the challenge SHA-256 digest
- the Merkle root
- each signed UTXO
- each compressed public key
- each DER-encoded ECDSA signature
The CLI verifies every signature and checks that each public key maps to the UTXO's P2WPKH address before writing the proof file. This is still an off-circuit ownership check; future versions may verify Bitcoin signatures inside Noir.
Verify an ownership proof later:
cargo run -- verify-ownership \
--proof ownership_proof.json \
--network regtestThe Noir tests already cover failure behavior:
nargo testThe circuit rejects:
- below-threshold values
- invalid Merkle paths
- invalid Merkle indices
You can also edit Prover.toml manually and run:
nargo executeIf the root, path, or threshold no longer matches, witness solving fails.
Given the sample private UTXOs:
0.42 BTC
0.58 BTC
The circuit computes:
0.42 + 0.58 = 1.00 BTC
Since:
1.00 BTC ≥ 1 BTC
a valid proof is generated.
The verifier learns only:
The prover controls at least 1 BTC.
- Prototype Merkle membership proofs
- Prototype 1 BTC threshold proof
- Bitcoin regtest snapshot generation
- Up to four selected UTXOs
- Schnorr ownership gadget
- Arbitrary threshold support
- Utreexo integration
- Nullifier support
- Taproot interoperability
MIT