Tutorial: Password-Protected Counter
This tutorial walks through building a complete password-protected counter contract. You’ll learn how to use witness functions to keep sensitive data private while still proving correctness in zero-knowledge.
What We’re Building
A counter contract where:
- Only users with the correct password can increment/decrement
- The password is never revealed on-chain
- Authentication happens via zero-knowledge proof
Prerequisites
- Midday SDK installed
- A local devnet running (or testnet access)
- Basic familiarity with Compact language
Step 1: Write the Contract
Create contracts/secret-counter/secret-counter.compact:
pragma language_version >= 0.19;
import CompactStandardLibrary;
// Ledger stateexport ledger counter: Counter;export ledger password_hash: Bytes<32>;
// Witness: provides the secret password (as 32-byte hash)witness provide_password(): Bytes<32>;
// Initialize with a password hash (can only be called once)export circuit init(hash: Bytes<32>): [] { assert(password_hash == default<Bytes<32>>, "already initialized"); password_hash = disclose(hash);}
// Protected increment: requires correct password via witnessexport circuit increment(amount: Uint<16>): [] { assert(password_hash == persistentHash<Bytes<32>>(provide_password()), "invalid password"); counter += disclose(amount);}
// Protected decrement: requires correct password via witnessexport circuit decrement(amount: Uint<16>): [] { assert(password_hash == persistentHash<Bytes<32>>(provide_password()), "invalid password"); counter -= disclose(amount);}Key Concepts
| Element | Purpose |
|---|---|
password_hash | Stored on-chain (public ledger) |
provide_password() | Witness function - runs locally, value stays private |
persistentHash<Bytes<32>>() | Deterministic hash for ZK circuits |
disclose() | Makes a value public in the transaction |
Step 2: Compile the Contract
compactc contracts/secret-counter/secret-counter.compact contracts/secret-counterThis generates:
contract/index.js- TypeScript bindingskeys/- Prover and verifier keyszkir/- Zero-knowledge intermediate representation
Step 3: Start a Local Devnet
The SDK includes a devnet cluster for local development. Start it before running your code:
import { Cluster } from '@no-witness-labs/midday-sdk/devnet';
const cluster = await Cluster.make({ clusterName: 'my-devnet', node: { port: 9945 }, indexer: { port: 8089 }, proofServer: { port: 6301 },});
await cluster.start();When you’re done, clean up:
await cluster.remove();Step 4: Set Up the Client
import * as Midday from '@no-witness-labs/midday-sdk';import { Cluster } from '@no-witness-labs/midday-sdk/devnet';import * as SecretCounterContract from './contracts/secret-counter/contract/index.js';No external Midnight imports needed - everything comes from the SDK.
Step 5: Create the Client
-
Initialize the client
const client = await Midday.Client.create({networkConfig: cluster.networkConfig, // from devnet clusterprivateStateProvider: Midday.PrivateState.inMemoryPrivateStateProvider(),}); -
Define your password
const SECRET_PASSWORD = Midday.Hash.stringToBytes32('my-secret-password');const PASSWORD_HASH = Midday.Hash.bytes32(SECRET_PASSWORD); -
Create the witness function
interface PasswordState {password: Uint8Array;}function createWitnesses(password: Uint8Array): SecretCounterContract.Witnesses<PasswordState> {return {provide_password: () => [{ password }, password],};}
Step 6: Deploy and Initialize
// Load the contract with witnessesconst loaded = await client.loadContract({ module: SecretCounterContract, zkConfig: Midday.ZkConfig.fromPath('./contracts/secret-counter'), privateStateId: 'my-secret-counter', witnesses: createWitnesses(SECRET_PASSWORD),});
// Deploy (returns a DeployedContract handle)const deployed = await loaded.deploy();console.log(`Deployed at: ${deployed.address}`);
// Initialize with the password hashawait deployed.call('init', PASSWORD_HASH);Step 7: Use the Contract
Increment (with correct password)
await deployed.call('increment', 5n);
const state = await deployed.ledgerState();console.log(`Counter: ${state.counter}`); // 5nDecrement (with correct password)
await deployed.call('decrement', 2n);
const state = await deployed.ledgerState();console.log(`Counter: ${state.counter}`); // 3nWrong password fails
// Another user tries with wrong passwordconst wrongWitnesses = createWitnesses(Midday.Hash.stringToBytes32('wrong-password'));
const attackerLoaded = await otherClient.loadContract({ module: SecretCounterContract, zkConfig: Midday.ZkConfig.fromPath('./contracts/secret-counter'), privateStateId: 'attacker-counter', witnesses: wrongWitnesses,});
const attackerDeployed = await attackerLoaded.join(deployed.address);
// This throws: "invalid password"await attackerDeployed.call('increment', 1n);How It Works
┌─────────────────────────────────────────────────────────────┐│ Client Side │├─────────────────────────────────────────────────────────────┤│ password: "my-secret-password" ││ ↓ ││ stringToBytes32() → Uint8Array(32) ││ ↓ ││ witness provide_password() returns bytes ││ ↓ ││ ZK Proof generated (password hidden) │└─────────────────────────────────────────────────────────────┘ ↓┌─────────────────────────────────────────────────────────────┐│ On-Chain │├─────────────────────────────────────────────────────────────┤│ password_hash: 0x7f3a... (stored at init) ││ Verifier checks: hash(witness) == password_hash ││ ✓ Proof valid → counter updated ││ ✗ Proof invalid → transaction rejected │└─────────────────────────────────────────────────────────────┘The password flows through the ZK circuit but the proof only reveals that hash(password) == stored_hash, not the password itself.
Complete Example
See the full working test at test/e2e/witness-contract.e2e.test.ts.