DEV Community

Cover image for I Built a Payment Gate That Never Sees Your Balance πŸ”βš‘
CyprianTinasheAarons
CyprianTinasheAarons

Posted on

I Built a Payment Gate That Never Sees Your Balance πŸ”βš‘

Why Zero-Knowledge Payments Matter Right Now

"Send me your bank statement so I can confirm you can afford this."

I hear some version of that every time remittance compliance comes up in my work.

And honestly… I get why it exists. Nobody wants to release funds to a sender who can't cover them.

But here's the problem: proving you have enough money has always meant showing exactly how much money you have.

Your balance.
Your full statement.
Your whole financial life, just to move one payment.

You shouldn't have to show your hand to prove you can play the round.

With 24 hours left on the clock for Stellar Hacks: Real-World ZK, I shipped zkRemit Guard β€” a Stellar smart contract that releases an escrowed payment only after the sender proves, with math, that their balance clears the required amount.

The raw number never touches the chain.
Not once!


What You'll Build πŸš€

  • A Noir circuit that proves balance >= required_amount without revealing balance
  • A Poseidon2 commitment that locks the proof to one specific balance, so it can't be faked after the fact
  • A Soroban contract (payment_gate) that escrows real tokens and only releases them after verifying the proof on-chain
  • Anti-replay binding so a valid proof from one transfer can never be reused on another
  • Pass and fail demo scripts that prove the gate actually rejects bad proofs, not just accepts good ones

Prerequisites

  • nargo 1.0.0-beta.9 (Noir's compiler β€” install via noirup)
  • bb 0.87.0 (Barretenberg, the UltraHonk proving backend β€” install via bbup)
  • stellar-cli ^3.2.0 for deploying and invoking Soroban contracts
  • Rust + the wasm32v1-none target
  • Docker, if you want a localnet before you touch testnet
  • Clone the repo https://github.com/CyprianTinasheAarons/zkremit-guard so you can follow along with full context

Step 1: Write the Circuit

This is the whole idea, in 14 lines:

fn main(
    balance: u64,
    salt: Field,
    transfer_id: pub Field,
    required_amount: pub u64,
    balance_commitment: pub Field,
) {
    assert(balance >= required_amount);

    let computed_commitment = Poseidon2::hash([balance as Field, salt, transfer_id], 3);
    assert(computed_commitment == balance_commitment);
}
Enter fullscreen mode Exit fullscreen mode

balance and salt have no pub keyword. They never leave the sender's machine.

transfer_id, required_amount, and balance_commitment are the only three numbers that ever reach the chain.

Two asserts, two guarantees:

  1. The balance actually clears the bar.
  2. The balance used in this proof is the same one committed to earlier β€” not a bigger number invented on the spot.

Step 2: Bind the Secret to a Commitment

Here's the thing most people building their first ZK demo skip:

A private input with no binding is just a number nobody can check.

Before proving anything, the sender commits to their balance with a Poseidon2 hash β€” a one-way seal. salt keeps two people with the same balance from producing the same public commitment.

Prover.toml
  balance = "120"
  salt = "7"
  transfer_id = "1"
  required_amount = "50"
  balance_commitment = "1121312...5850"   # computed, not guessed
Enter fullscreen mode Exit fullscreen mode

That commitment gets computed in Rust β€” using the exact same Poseidon2 hash the circuit and the contract both use β€” and written straight into Prover.toml. Three pieces of code, one shared source of truth.


Step 3: Compile and Prove

nargo check
nargo compile
nargo execute

bb prove --scheme ultra_honk --oracle_hash keccak \
  --bytecode_path target/reserve_threshold.json \
  --witness_path target/reserve_threshold.gz \
  --output_path target --output_format bytes_and_fields

bb write_vk --scheme ultra_honk --oracle_hash keccak \
  --bytecode_path target/reserve_threshold.json \
  --output_path target --output_format bytes_and_fields
Enter fullscreen mode Exit fullscreen mode

nargo compiles your circuit and runs it once on real numbers.
bb generates the actual zero-knowledge proof β€” UltraHonk, the scheme Stellar's Protocol 26 host functions were built to verify cheaply on-chain.

Output: a proof, a vk (1,760 fixed bytes β€” the circuit's public fingerprint, reusable across every future transfer), and public_inputs.


Step 4: Build the Payment Gate as a State Machine

Every transfer moves through exactly one path:

PendingProof β†’ ProofVerified β†’ Released
Enter fullscreen mode Exit fullscreen mode

Three functions drive it:

pub fn create_transfer(env: Env, sender: Address, recipient: Address, amount: i128, transfer_id: BytesN<32>) -> Result<(), PaymentGateError> {
    sender.require_auth();
    // ...escrows tokens, status = PendingProof
}

pub fn submit_proof(env: Env, sender: Address, transfer_id: BytesN<32>, required_amount: i128, balance_commitment: BytesN<32>, public_inputs: Bytes, proof_bytes: Bytes) -> Result<(), PaymentGateError> {
    // rebuild expected public inputs, reject any mismatch, then verify the proof
}

pub fn release_transfer(env: Env, sender: Address, transfer_id: BytesN<32>) -> Result<(), PaymentGateError> {
    // only pays out if status == ProofVerified
}
Enter fullscreen mode Exit fullscreen mode

create_transfer locks tokens into escrow. Money moves out of the sender's wallet, but nowhere near the recipient yet.


Step 5: Wire in the Anti-Replay Check (The Part People Skip)

Before the contract touches any cryptography, it does something cheaper first:

let expected_public_inputs = expected_public_inputs(
    &env, &transfer_id, required_amount, &balance_commitment,
)?;

if public_inputs != expected_public_inputs {
    return Err(PaymentGateError::PublicInputsMismatch);
}
Enter fullscreen mode Exit fullscreen mode

This rebuilds, byte-for-byte, what the public inputs should say for this specific transfer and rejects anything that doesn't match exactly.

Why does this matter?

A valid proof with no binding to a transfer ID is a proof anyone can replay anywhere.

This one line is what stops that.

Only after this check passes does verify_proof() run the real math β€” parsing the proof, rebuilding the Fiat-Shamir transcript, running sumcheck, and closing it out with a pairing check via Shplemini. If any of it fails, the transfer stays stuck in PendingProof. Money never moves.


Step 6: Run the Happy Path

./scripts/deploy_local.sh
STELLAR_NETWORK_NAME=local ./scripts/demo_pass.sh
Enter fullscreen mode Exit fullscreen mode

Three contract calls, in order:

create_transfer β†’ submit_proof β†’ release_transfer
Enter fullscreen mode Exit fullscreen mode

Sender escrows 50 tokens. Proof verifies. Recipient gets paid.

The sender's balance of 120 never appears anywhere on-chain β€” not in an event, not in storage, not in a log.


Step 7: Prove the Fail Path Actually Fails

This is the step that separates a real demo from a slide deck.

printf '\x01' | dd of=proof.bin bs=1 seek=100 conv=notrunc
Enter fullscreen mode Exit fullscreen mode

One corrupted byte. Same transfer context. Run it:

create_transfer  β†’ βœ… succeeds
submit_proof     β†’ ❌ rejected
release_transfer β†’ ❌ blocked (status still PendingProof)
Enter fullscreen mode Exit fullscreen mode

Escrowed funds stay locked when the proof is bad. They don't pay out anyway "just in case."

That's the whole point of a proof gate β€” not that it accepts good proofs, but that it refuses bad ones under real economic stakes.


Step 8: Put a Control Panel in Front of It

I'm not going to pretend this is a full wallet-connected dApp β€” it isn't, and saying otherwise to judges is the fastest way to lose credibility.

ui/ is a Next.js page that streams the same shell scripts' output into the browser live. Click a button, watch create_transfer β†’ submit_proof β†’ release_transfer happen in real time instead of scrolling a terminal.

Not client-side proof generation. Not Freighter wallet integration. A control panel for a CLI-first demo. Said plainly, upfront, every time.


In Conclusion

If you're building anything that needs to prove a fact about private data a balance, a credential, an age, a KYC tier without leaking the underlying number, this is the shape of the answer: circuit proves the fact, contract checks the proof, chain never sees the secret.


Your Turn πŸ‘‡

What's the first private fact you'd want a smart contract to verify without ever seeing it?

A balance? A credential? A KYC tier?

Drop it below πŸ‘‡

Let's build the boring, load-bearing infrastructure nobody's hyping yet πŸ˜„

Resources

Stellar Developer Docs | Stellar Docs

Navigating the docs

favicon developers.stellar.org

Noir | Noir Documentation

Noir is an open-source, Rust-influenced domain-specific language for writing privacy-preserving programs with zero-knowledge proofs, requiring no prior knowledge of the underlying mathematics or cryptography.

favicon noir-lang.org

zkremit-guard

zkRemit Guard is a Stellar proof-gated escrow demo.

It demonstrates:

  • a Noir reserve-threshold proof
  • UltraHonk proof generation with nargo 1.0.0-beta.9 and bb 0.87.0
  • on-chain Soroban verification
  • a real escrowed token transfer that only releases after proof verification
  • pass/fail localnet and testnet demo flows

Status

The top-level MVP path is implemented and runnable.

Implemented:

  • circuits/reserve_threshold uses Poseidon2 commitment binding
  • contracts/payment_gate stores the VK at deploy time and verifies proofs on-chain
  • create_transfer escrows demo tokens into the contract
  • release_transfer pays escrowed tokens to the recipient
  • scripts/ runs local/testnet deploy and pass/fail demos

Still useful follow-ups:

  • replace demo token issuance with your intended production asset model
  • polish contract events and indexing
  • expand docs/UI beyond the CLI-first hackathon flow

Layout

zkremit-guard/
  README.md
  demo/
  circuits/
  contracts/
  scripts/
  ui/
  docs/

Suggested next steps

  1. Build proof artifacts with ./scripts/build_proof.sh.
  2. Deploy locally with ./scripts/deploy_local.sh.
  3. Run the happy path with STELLAR_NETWORK_NAME=local ./scripts/demo_pass.sh.
  4. Run the fail…





Rust Tutorial

Rust is a modern systems programming language developed by the Mozilla Corporation. It is intended to be a language for highly concurrent and highly secure systems. It compiles to native code; hence, it is blazingly fast like C and C++.

favicon tutorialspoint.com

Top comments (0)