From 6e7d406aed6489060c7ec401c34f70097612bb0f Mon Sep 17 00:00:00 2001 From: "Andres F. Chavez" Date: Mon, 11 May 2026 15:24:54 -0600 Subject: [PATCH] MANO: Anonymous ZK check-in credential system for peer recovery ODC --- README.md | 295 ++++++-------------------- api/src/common-types.ts | 86 +------- api/src/index.ts | 200 ++++------------- contract/src/bboard.compact | 93 ++++---- contract/src/test/bboard-simulator.ts | 82 +++---- contract/src/test/bboard.test.ts | 204 +++++++----------- package-lock.json | 17 +- package.json | 2 +- 8 files changed, 302 insertions(+), 677 deletions(-) diff --git a/README.md b/README.md index 4c87a0d7..eb98e546 100644 --- a/README.md +++ b/README.md @@ -1,277 +1,100 @@ -# Bulletin Board DApp +# MANO — Anonymous Check-In with ZK Attendance Credentials -This project is built on the [Midnight Network](https://midnight.network/). +**Developer:** Andres F. Chavez — Anonymous Haven LLC +**Track:** D — Custom Extension +**Network:** Midnight Preprod +**Hackathon:** May 15, 2026 -[![Generic badge](https://img.shields.io/badge/Compact%20Compiler-0.30.0-1abc9c.svg)](https://shields.io/) -[![Generic badge](https://img.shields.io/badge/TypeScript-5.9.3-blue.svg)](https://shields.io/) +--- +## What This Is and Why It Exists -> **Use this repo as a template. Do not fork it.** -> -> This repository is intended to be used via GitHub’s “Use this template” flow. -> Forking this repo is discouraged, as forks are not tracked as independent projects. +MANO is a zero-knowledge anonymous check-in system built for a peer recovery overdose drop-in center (ODC) operated by Recovery Alliance in El Paso, Texas. The people who walk through that door are among the most vulnerable in any community — unhoused, in active addiction, often with criminal justice involvement. Many have been burned by systems that promised confidentiality and then used their data against them. -A Midnight smart contract example demonstrating a simple one-item bulletin board with zero-knowledge proofs on testnet. Users can post a single message at a time, and only the message author can remove it. +The core problem: recovery service providers need attendance records to satisfy funder reporting requirements, but participants need genuine privacy protection. Traditional databases create a permanent, identifiable record of every visit. Even "de-identified" data can be re-linked. For people who have been criminalized for their substance use, that risk is not abstract. -## Project Structure +MANO's answer is **private by default, transparent by choice**. A participant enrolls once and receives a ZK credential. Every subsequent check-in proves they are the enrolled participant — without ever revealing who they are. The on-chain record shows that *someone* checked in on *this date* and has *this many* verified visits. Nothing else. -``` -bulletin-board/ -├── contract/ # Smart contract in Compact language -│ └── src/ # Contract source and utilities -├── api/ # Methods, classes and types for CLI and UI -├── bboard-cli/ # Command-line interface -│ └── src/ # CLI implementation -└── bboard-ui/ # Web browser interface - └── src/ # Web UI implementation -``` - -## Prerequisites +When a participant wants to use their attendance record — for employment verification, housing applications, or reentry programs — they can selectively disclose their milestone count to a specific verifier, cryptographically proven, without revealing their identity to the blockchain or to anyone else in the system. -### 1. Node.js Version Check +This is a real application being built for real people. The Midnight blockchain makes it possible. -You need Node.js: - -```bash -node --version -``` +--- -Expected output: `v24.11.1` or higher. The repository includes an [.nvmrc](./.nvmrc) pinned to `24.11.1`. +## Compact Patterns Used -If you get a lower version: [Install Node.js LTS](https://nodejs.org/). +### Sealed vs. Exported Ledger Fields -### 2. Docker Installation +The contract uses `export ledger` for all fields. The privacy of participant identity does not come from hiding the `owner` field itself — it comes from the fact that `owner` stores a *derived public key*, not any real-world identifier. Without the secret key that generated it, the on-chain value reveals nothing. -The [proof server](https://docs.midnight.network/develop/tutorial/using/proof-server) runs in Docker and is required for both CLI and UI to generate zero-knowledge proofs: - -```bash -docker --version +```compact +export ledger owner: Bytes<32>; // derived public key — reveals no identity +export ledger isEnrolled: Boolean; // public enrollment status +export ledger milestoneCount: Counter; // verifiable attendance count ``` -Expected output: `Docker version X.X.X`. - -If Docker is not found: [Install Docker Desktop](https://docs.docker.com/desktop/). Make sure Docker Desktop is running. - -### 3. Lace Wallet Extension (UI Only) - -For the web interface, install the official Lace wallet extension on [Chrome Store](https://chromewebstore.google.com/detail/lace/gafhhkghbfjjkeiendhlofajokpaflmk) or the [Edge Store](https://microsoftedge.microsoft.com/addons/detail/lace/efeiemlfnahiidnjglmehaihacglceia) (tested with version 1.36.0). +### The `disclose()` Requirement -After installing, set up the Midnight wallet: +Any value derived from a witness (private data) that gets stored in the ledger must be wrapped in `disclose()`. This is one of Compact's most important safety constraints — it makes the boundary between private and public explicit and auditable. -1. Create a **new wallet** — Midnight will appear as a network option -2. Set **Network** to **Preprod** -3. Set **Proof server** to **Local (http://localhost:6300)** — this must point to your local proof server started via Docker -4. Click **Enter Wallet** -5. Fund your wallet with tNIGHT tokens from the [Preprod Faucet](https://faucet.preprod.midnight.network/) -6. Go to **Tokens** in the wallet, click **Generate tDUST**, and confirm the transaction — tDUST tokens are required to pay transaction fees on preprod - -## Setup Instructions - -### Install Project Dependencies - -```bash -npm install +```compact +owner = disclose(pk); +isEnrolled = disclose(true); ``` -This repository uses npm workspaces. Run installation once from the repository root. +### Witness Functions and Private State -### Compile the Smart Contract +The `localSecretKey` witness provides the participant's private credential — a 32-byte value derived from their biometric or device ID that never leaves their machine. The ZK proof system proves knowledge of this key without transmitting it. -The Compact compiler (`compactc 0.30.0`) generates TypeScript bindings and zero-knowledge circuits from the smart contract source code: - -```bash -cd contract -npm run compact # Compiles the Compact contract -npm run build # Copies compiled files to dist/ -cd .. +```compact +witness localSecretKey(): Bytes<32>; ``` -Expected output: +### Pure Circuits for Deterministic Derivation -``` -> compact -> compact compile src/bboard.compact ./src/managed/bboard - -Compiling 2 circuits: - circuit "post" (k=14, rows=10070) - circuit "takeDown" (k=14, rows=10087) - -> build -> rm -rf dist && tsc --project tsconfig.build.json && cp -Rf ./src/managed ./dist/managed && cp ./src/bboard.compact ./dist +`publicKey` is declared as `export pure circuit` — it has no side effects and is deterministic. Given the same secret key, it always produces the same public key. This is the foundation of the ownership proof: enroll with a derived key, prove ownership by re-deriving the same key. +```compact +export pure circuit publicKey(sk: Bytes<32>): Bytes<32> { + return persistentHash>>( + [pad(32, "mano:participant:"), sk] + ); +} ``` -### Build the CLI Interface - -```bash -cd bboard-cli -npm run build -cd .. -``` - -### Build the UI Interface (Optional) - -Only needed if you want to use the web interface: - -```bash -cd bboard-ui -npm run build -cd .. -``` - -## Option 1: CLI Interface - -### Start the Proof Server - -The CLI requires a local proof server running in Docker: - -```bash -cd bboard-cli -docker compose -f proof-server-local.yml up -d -``` - -This uses `midnightntwrk/proof-server:8.0.3` on `http://127.0.0.1:6300`. - -### Run the CLI - -```bash -# For preprod network -npm run preprod-remote - -# For preview network -npm run preview-remote -``` - -### Using the CLI - -#### Create a Wallet - -1. Choose option `1` to build a fresh wallet -2. The system will generate a wallet address and seed -3. **Save both the address and seed** - you'll need them later +Domain separation via `pad(32, "mano:participant:")` ensures hashes produced by this contract cannot collide with hashes from other contract types. -Expected output is similar to: +### Circuit Breaker Pattern -``` -Your wallet seed is: [64-character hex string] -Using unshielded address: mn_addr_preprod1hdvtst70zfgd8wvh7l8ppp7mcrxnjn56wc5hlxpwflz3fxdykaesrw0ln4 waiting for funds... -``` - -#### Fund Your Wallet - -Before deploying contracts, you need testnet tokens. - -1. Copy your wallet address from the output above -2. Visit the [faucet](https://faucet.preprod.midnight.network/) -3. Paste your address and request funds -4. Wait for the CLI to detect the funds (takes 2-3 minutes) - -Expected output after funding is similar to: - -``` -Your NIGHT wallet balance is: 1000000000 -``` - -#### Deploy Your Contract - -1. Choose the contract deployment option -2. Wait for deployment (takes ~30 seconds) -3. **Save the contract address** for future use - -Expected output: - -``` -Deployed bulletin board contract at address: [contract address] -``` - -#### Use the Bulletin Board - -You can now: - -- **Post** a message to the bulletin board -- **View** the current message -- **Remove** your message (only if you posted it) -- **Exit** when done - -Each action creates a real transaction on Midnight Testnet using zero-knowledge proofs generated by the proof server. - -## Option 2: Web UI Interface - -The web interface uses the same proof server and requires additional browser setup. - -### Start the Proof Server (if not already running) - -If you haven't started the proof server for the CLI, start it now: - -```bash -cd bboard-cli -docker compose -f proof-server-local.yml up -d -cd .. -``` - -Verify it's running: - -```bash -docker ps -``` - -### Start the Web Interface - -The UI can run against preprod or preview networks: - -```bash -cd bboard-ui - -# For preprod network -npm run build:start - -# For preview network -npm run build:start:preview -``` +`isPaused` is a Boolean ledger field checked at the start of every circuit. The `pauseContract` and `resumeContract` admin circuits implement an emergency stop — if a vulnerability is discovered or an operational issue arises, the contract can be frozen without destroying participant data. -The UI will be available at: +### Counter Fields -- http://127.0.0.1:8080 +`sequence` and `milestoneCount` use Compact's `Counter` type, which increments monotonically. `milestoneCount` is the verifiable credential value — the number of confirmed check-ins. `sequence` tracks visit history on the ledger. -### Browser Setup +--- -1. **Open the UI URL** in a browser with Lace wallet extension installed -2. **Set up Lace wallet** if it's your first time -3. **Authorize the application** when Lace wallet prompts -4. Use the bulletin board web interface +## Privacy Properties -## Useful Links +| Data | On-chain | Visible to staff | Visible to verifier | +|---|---|---|---| +| Participant name / identity | Never | Never | Never | +| Secret key / biometric | Never | Never | Never | +| Enrollment status | Yes (boolean) | Yes | Only if disclosed | +| Check-in date | Yes (string) | Yes | Only if disclosed | +| Milestone count | Yes (integer) | Yes | Only if disclosed | +| Owner (derived public key) | Yes | Yes | Reveals nothing without secret key | -- Get Testnet tNIGHT on [Preprod Faucet](https://faucet.preprod.midnight.network/) or [Preview Faucet](https://faucet.preview.midnight.network/) -- [Midnight Documentation](https://docs.midnight.network/examples/dapps/bboard) - Complete developer guide -- [Compatibility Matrix](https://docs.midnight.network/relnotes/support-matrix) - Current supported Midnight component versions -- [Compact Language Guide](https://docs.midnight.network/compact/writing) - Smart contract language reference -- Get Lace wallet on the [Chrome Store](https://chromewebstore.google.com/detail/lace/gafhhkghbfjjkeiendhlofajokpaflmk) or the [Edge Store](https://microsoftedge.microsoft.com/addons/detail/lace/efeiemlfnahiidnjglmehaihacglceia) +The system proves three things without revealing identity: (1) this person is enrolled, (2) they are the enrolled participant, (3) they have attended a certain number of times. -## Troubleshooting +--- -| Common Issue | Solution | -| ---------------------------------- |-----------------------------------------------------------------------------------------------------------| -| `npm install` fails | Ensure you're using Node `v24.11.1` or newer. Older Node versions can install with warnings but are not the target runtime | -| Contract compilation fails | Ensure the Compact toolchain is installed and run `npm run compact` from `contract/` | -| Network connection timeout | CLI requires internet connection, restart if connection times out | -| Token funding takes too long | Wait 1-2 minutes, funding is automatic in CLI | -| "Application not authorized" error | Start proof server: `docker compose -f proof-server-local.yml up -d` | -| Lace wallet not detected | Install Lace wallet browser extension and refresh page | -| Docker issues | Ensure Docker Desktop is running, check `docker --version` | -| Port 6300 in use | Run `docker compose down` then restart services | -| Dependencies won't install | Use Node.js LTS version. For older npm versions, you may need `--legacy-peer-deps` | -| Contract deployment fails | Verify wallet has sufficient balance and network connection | +## Trade-offs -## Notes +**Fixed identity commitment vs. session-scoped keys.** An earlier version derived the public key from both the secret key and the current sequence, producing a different public key each session — stronger unlinkability. This was removed because it broke the ownership check after sequence increments. The current design uses a fixed commitment: `hash("mano:participant:", sk)`. The trade-off is that all check-ins by the same participant share the same derived public key on-chain. For a single-enrollment-per-contract model, this is acceptable. -- CLI and UI can run simultaneously and share the same proof server -- Proof server (Docker) is required for both CLI and UI to generate zero-knowledge proofs -- Contract must be compiled before building CLI or UI -- Fund your wallet using the testnet faucet before deploying contracts +**One contract per participant.** Each enrollment deploys a separate contract instance. This maximizes isolation between participants but increases chain footprint. A future version could use a single registry contract with per-participant sub-states. -## Implementation Notes +**Admin circuits are ungated.** `revokeEnrollment`, `pauseContract`, and `resumeContract` have no on-chain access control in this version — any caller can invoke them in the simulator. Production deployment would add an admin public key commitment checked at the start of those circuits. -- **Transaction fee configuration** - The default `additionalFeeOverhead` value (`500_000_000_000_000_000n`) from `@midnight-ntwrk/testkit-js` is required on the `undeployed` network. Lower values can fail with `BalanceCheckOverspend` on the node side. On remote networks, that overhead requires too much dust, so the CLI overrides it to `1_000n`. -- CLI private state is stored per contract address, matching the `Midnight.js 4.x` private-state provider model. +**`verifyMilestone` uses equality, not inequality.** Compact circuits cannot perform `>=` comparisons on Field types directly — ZK arithmetic over finite fields does not support inequality without range proofs. The current design proves an exact milestone count. Future work could implement a range proof gadget for threshold verification. diff --git a/api/src/common-types.ts b/api/src/common-types.ts index 53a07722..003bad01 100644 --- a/api/src/common-types.ts +++ b/api/src/common-types.ts @@ -1,99 +1,29 @@ -// This file is part of midnightntwrk/example-counter. -// Copyright (C) Midnight Foundation -// SPDX-License-Identifier: Apache-2.0 -// Licensed under the Apache License, Version 2.0 (the "License"); -// You may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * Bulletin board common types and abstractions. - * - * @module - */ - import { type MidnightProviders } from '@midnight-ntwrk/midnight-js-types'; import { type FoundContract } from '@midnight-ntwrk/midnight-js-contracts'; -import type { State, BBoardPrivateState, Contract, Witnesses } from '../../contract/src/index'; +import type { BBoardPrivateState, Contract, Witnesses } from '../../contract/src/index'; export const bboardPrivateStateKey = 'bboardPrivateState'; export type PrivateStateId = typeof bboardPrivateStateKey; -/** - * The private states consumed throughout the application. - * - * @remarks - * {@link PrivateStates} can be thought of as a type that describes a schema for all - * private states for all contracts used in the application. Each key represents - * the type of private state consumed by a particular type of contract. - * The key is used by the deployed contract when interacting with a private state provider, - * and the type (i.e., `typeof PrivateStates[K]`) represents the type of private state - * expected to be returned. - * - * Since there is only one contract type for the bulletin board example, we only define a - * single key/type in the schema. - * - * @public - */ export type PrivateStates = { - /** - * Key used to provide the private state for {@link BBoardContract} deployments. - */ readonly bboardPrivateState: BBoardPrivateState; }; -/** - * Represents a bulletin board contract and its private state. - * - * @public - */ export type BBoardContract = Contract>; - -/** - * The keys of the circuits exported from {@link BBoardContract}. - * - * @public - */ export type BBoardCircuitKeys = Exclude; - -/** - * The providers required by {@link BBoardContract}. - * - * @public - */ export type BBoardProviders = MidnightProviders; - -/** - * A {@link BBoardContract} that has been deployed to the network. - * - * @public - */ export type DeployedBBoardContract = FoundContract; /** - * A type that represents the derived combination of public (or ledger), and private state. + * Derived state for MANO — combines public ledger state with private state. + * isOwner: true if the current secret key matches the enrolled participant. */ export type BBoardDerivedState = { - readonly state: State; + readonly isEnrolled: boolean; + readonly isRevoked: boolean; + readonly milestoneCount: bigint; + readonly checkInDate: string | undefined; + readonly isPaused: boolean; readonly sequence: bigint; - readonly message: string | undefined; - - /** - * A readonly flag that determines if the current message was posted by the current user. - * - * @remarks - * The `owner` property of the public (or ledger) state is the public key of the message owner, while - * the `secretKey` property of {@link BBoardPrivateState} is the secret key of the current user. If - * `owner` corresponds to the public key derived from `secretKey`, then `isOwner` is `true`. - */ readonly isOwner: boolean; }; - -// TODO: for some reason I needed to include "@midnight-ntwrk/wallet-sdk-address-format": "1.0.0-rc.1", should we bump in to rc-2 ? diff --git a/api/src/index.ts b/api/src/index.ts index 0f7d70c9..c9946168 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1,26 +1,4 @@ -// This file is part of midnightntwrk/example-counter. -// Copyright (C) Midnight Foundation -// SPDX-License-Identifier: Apache-2.0 -// Licensed under the Apache License, Version 2.0 (the "License"); -// You may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -/** - * Provides types and utilities for working with bulletin board contracts. - * - * @packageDocumentation - */ - import * as BBoard from '../../contract/src/managed/bboard/contract/index.js'; - import { type ContractAddress, convertFieldToBytes } from '@midnight-ntwrk/compact-runtime'; import { type Logger } from 'pino'; import { @@ -37,38 +15,18 @@ import { combineLatest, map, tap, from, type Observable } from 'rxjs'; import { toHex } from '@midnight-ntwrk/midnight-js-utils'; import { BBoardPrivateState, createBBoardPrivateState } from '../../contract/src/witnesses.js'; -/** @internal */ - -/** - * An API for a deployed bulletin board. - */ export interface DeployedBBoardAPI { readonly deployedContractAddress: ContractAddress; readonly state$: Observable; - - post: (message: string) => Promise; - takeDown: () => Promise; + enroll: (dateStr: string) => Promise; + checkIn: (dateStr: string) => Promise; + verifyMilestone: (threshold: bigint) => Promise; + revokeEnrollment: () => Promise; + pauseContract: () => Promise; + resumeContract: () => Promise; } -/** - * Provides an implementation of {@link DeployedBBoardAPI} by adapting a deployed bulletin board - * contract. - * - * @remarks - * The `BBoardPrivateState` is managed at the DApp level by a private state provider. As such, this - * private state is shared between all instances of {@link BBoardAPI}, and their underlying deployed - * contracts. The private state defines a `'secretKey'` property that effectively identifies the current - * user, and is used to determine if the current user is the owner of the message as the observable - * contract state changes. - * - * In the future, Midnight.js will provide a private state provider that supports private state storage - * keyed by contract address. This will remove the current workaround of sharing private state across - * the deployed bulletin board contracts, and allows for a unique secret key to be generated for each bulletin - * board that the user interacts with. - */ -// TODO: Update BBoardAPI to use contract level private state storage. export class BBoardAPI implements DeployedBBoardAPI { - /** @internal */ private constructor( public readonly deployedContract: DeployedBBoardContract, providers: BBoardProviders, @@ -78,172 +36,108 @@ export class BBoardAPI implements DeployedBBoardAPI { providers.privateStateProvider.setContractAddress(this.deployedContractAddress); this.state$ = combineLatest( [ - // Combine public (ledger) state with... providers.publicDataProvider.contractStateObservable(this.deployedContractAddress, { type: 'latest' }).pipe( map((contractState) => BBoard.ledger(contractState.data)), tap((ledgerState) => logger?.trace({ ledgerStateChanged: { ledgerState: { - ...ledgerState, - state: ledgerState.state === BBoard.State.OCCUPIED ? 'occupied' : 'vacant', + isEnrolled: ledgerState.isEnrolled, + isRevoked: ledgerState.isRevoked, + milestoneCount: ledgerState.milestoneCount, + isPaused: ledgerState.isPaused, owner: toHex(ledgerState.owner), }, }, }), ), ), - // ...private state... - // since the private state of the bulletin board application never changes, we can query the - // private state once and always use the same value with `combineLatest`. In applications - // where the private state is expected to change, we would need to make this an `Observable`. from(providers.privateStateProvider.get(bboardPrivateStateKey) as Promise), ], - // ...and combine them to produce the required derived state. (ledgerState, privateState) => { - const hashedSecretKey = BBoard.pureCircuits.publicKey( + const derivedOwner = BBoard.pureCircuits.publicKey( privateState.secretKey, convertFieldToBytes(32, ledgerState.sequence, 'api/src/index.ts'), ); - return { - state: ledgerState.state, - message: ledgerState.message.value, + isEnrolled: ledgerState.isEnrolled, + isRevoked: ledgerState.isRevoked, + milestoneCount: ledgerState.milestoneCount, + checkInDate: ledgerState.checkInDate.value, + isPaused: ledgerState.isPaused, sequence: ledgerState.sequence, - isOwner: toHex(ledgerState.owner) === toHex(hashedSecretKey), + isOwner: toHex(ledgerState.owner) === toHex(derivedOwner), }; }, ); } - /** - * Gets the address of the current deployed contract. - */ readonly deployedContractAddress: ContractAddress; - - /** - * Gets an observable stream of state changes based on the current public (ledger), - * and private state data. - */ readonly state$: Observable; - /** - * Attempts to post a given message to the bulletin board. - * - * @param message The message to post. - * - * @remarks - * This method can fail during local circuit execution if the bulletin board is currently occupied. - */ - async post(message: string): Promise { - this.logger?.info(`postingMessage: ${message}`); + async enroll(dateStr: string): Promise { + this.logger?.info(`enrolling: ${dateStr}`); + const txData = await this.deployedContract.callTx.enroll(dateStr); + this.logger?.trace({ transactionAdded: { circuit: 'enroll', txHash: txData.public.txHash } }); + } - const txData = await this.deployedContract.callTx.post(message); + async checkIn(dateStr: string): Promise { + this.logger?.info(`checkIn: ${dateStr}`); + const txData = await this.deployedContract.callTx.checkIn(dateStr); + this.logger?.trace({ transactionAdded: { circuit: 'checkIn', txHash: txData.public.txHash } }); + } - this.logger?.trace({ - transactionAdded: { - circuit: 'post', - txHash: txData.public.txHash, - blockHeight: txData.public.blockHeight, - }, - }); + async verifyMilestone(threshold: bigint): Promise { + this.logger?.info(`verifyMilestone: ${threshold}`); + const txData = await this.deployedContract.callTx.verifyMilestone(threshold); + this.logger?.trace({ transactionAdded: { circuit: 'verifyMilestone', txHash: txData.public.txHash } }); } - /** - * Attempts to take down any currently posted message on the bulletin board. - * - * @remarks - * This method can fail during local circuit execution if the bulletin board is currently vacant, - * or if the currently posted message isn't owned by the owner computed from the current private - * state. - */ - async takeDown(): Promise { - this.logger?.info('takingDownMessage'); + async revokeEnrollment(): Promise { + this.logger?.info('revokeEnrollment'); + const txData = await this.deployedContract.callTx.revokeEnrollment(); + this.logger?.trace({ transactionAdded: { circuit: 'revokeEnrollment', txHash: txData.public.txHash } }); + } - const txData = await this.deployedContract.callTx.takeDown(); + async pauseContract(): Promise { + this.logger?.info('pauseContract'); + const txData = await this.deployedContract.callTx.pauseContract(); + this.logger?.trace({ transactionAdded: { circuit: 'pauseContract', txHash: txData.public.txHash } }); + } - this.logger?.trace({ - transactionAdded: { - circuit: 'takeDown', - txHash: txData.public.txHash, - blockHeight: txData.public.blockHeight, - }, - }); + async resumeContract(): Promise { + this.logger?.info('resumeContract'); + const txData = await this.deployedContract.callTx.resumeContract(); + this.logger?.trace({ transactionAdded: { circuit: 'resumeContract', txHash: txData.public.txHash } }); } - /** - * Deploys a new bulletin board contract to the network. - * - * @param providers The bulletin board providers. - * @param logger An optional 'pino' logger to use for logging. - * @returns A `Promise` that resolves with a {@link BBoardAPI} instance that manages the newly deployed - * {@link DeployedBBoardContract}; or rejects with a deployment error. - */ static async deploy(providers: BBoardProviders, logger?: Logger): Promise { logger?.info('deployContract'); - const deployedBBoardContract = await deployContract(providers, { compiledContract: CompiledBBoardContractContract, privateStateId: bboardPrivateStateKey, initialPrivateState: createBBoardPrivateState(utils.randomBytes(32)), }); - - logger?.trace({ - contractDeployed: { - finalizedDeployTxData: deployedBBoardContract.deployTxData.public, - }, - }); - return new BBoardAPI(deployedBBoardContract, providers, logger); } - /** - * Finds an already deployed bulletin board contract on the network, and joins it. - * - * @param providers The bulletin board providers. - * @param contractAddress The contract address of the deployed bulletin board contract to search for and join. - * @param logger An optional 'pino' logger to use for logging. - * @returns A `Promise` that resolves with a {@link BBoardAPI} instance that manages the joined - * {@link DeployedBBoardContract}; or rejects with an error. - */ static async join(providers: BBoardProviders, contractAddress: ContractAddress, logger?: Logger): Promise { - logger?.info({ - joinContract: { - contractAddress, - }, - }); - + logger?.info({ joinContract: { contractAddress } }); const deployedBBoardContract = await findDeployedContract(providers, { contractAddress, compiledContract: CompiledBBoardContractContract, privateStateId: bboardPrivateStateKey, initialPrivateState: await BBoardAPI.getPrivateState(providers, contractAddress), }); - - logger?.trace({ - contractJoined: { - finalizedDeployTxData: deployedBBoardContract.deployTxData.public, - }, - }); - return new BBoardAPI(deployedBBoardContract, providers, logger); } - private static async getPrivateState( - providers: BBoardProviders, - contractAddress: ContractAddress, - ): Promise { + private static async getPrivateState(providers: BBoardProviders, contractAddress: ContractAddress): Promise { providers.privateStateProvider.setContractAddress(contractAddress); const existingPrivateState = await providers.privateStateProvider.get(bboardPrivateStateKey); return existingPrivateState ?? createBBoardPrivateState(utils.randomBytes(32)); } } -/** - * A namespace that represents the exports from the `'utils'` sub-package. - * - * @public - */ export * as utils from './utils/index.js'; - export * from './common-types.js'; diff --git a/contract/src/bboard.compact b/contract/src/bboard.compact index d9b895a3..6bdf6c25 100644 --- a/contract/src/bboard.compact +++ b/contract/src/bboard.compact @@ -1,60 +1,67 @@ -// This file is part of midnightntwrk/example-counter. -// Copyright (C) Midnight Foundation -// SPDX-License-Identifier: Apache-2.0 -// Licensed under the Apache License, Version 2.0 (the "License"); -// You may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - pragma language_version >= 0.20; import CompactStandardLibrary; -export enum State { - VACANT, - OCCUPIED -} - -export ledger state: State; +export ledger owner: Bytes<32>; +export ledger isEnrolled: Boolean; +export ledger isRevoked: Boolean; +export ledger sequence: Counter; +export ledger milestoneCount: Counter; +export ledger checkInDate: Maybe>; +export ledger isPaused: Boolean; -export ledger message: Maybe>; +witness localSecretKey(): Bytes<32>; -export ledger sequence: Counter; +// publicKey: fixed commitment — does NOT include sequence. +// Enrollment creates a stable identity hash. Sequence still increments +// on every check-in for ledger history, but ownership proof is stable. +export pure circuit publicKey(sk: Bytes<32>): Bytes<32> { + return persistentHash>>( + [pad(32, "mano:participant:"), sk] + ); +} -export ledger owner: Bytes<32>; +export circuit enroll(dateStr: Opaque<"string">): [] { + assert(!isPaused, "contract is paused"); + assert(!isEnrolled, "already enrolled"); + const sk = localSecretKey(); + const pk = publicKey(sk); + owner = disclose(pk); + isEnrolled = disclose(true); + isRevoked = disclose(false); + checkInDate = disclose(some>(dateStr)); +} -constructor() { - state = State.VACANT; - message = none>(); +export circuit checkIn(dateStr: Opaque<"string">): [] { + assert(!isPaused, "contract is paused"); + assert(isEnrolled, "not enrolled"); + assert(!isRevoked, "enrollment revoked"); + const sk = localSecretKey(); + const pk = publicKey(sk); + assert(owner == pk, "not the enrolled participant"); sequence.increment(1); + milestoneCount.increment(1); + checkInDate = disclose(some>(dateStr)); } -witness localSecretKey(): Bytes<32>; +export circuit verifyMilestone(threshold: Field): [] { + assert(!isPaused, "contract is paused"); + assert(isEnrolled, "not enrolled"); + assert(!isRevoked, "enrollment revoked"); + const sk = localSecretKey(); + const pk = publicKey(sk); + assert(owner == pk, "not the enrolled participant"); + assert(milestoneCount as Field == threshold, "milestone count does not match threshold"); +} -export circuit post(newMessage: Opaque<"string">): [] { - assert(state == State.VACANT, "Attempted to post to an occupied board"); - owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>)); - message = disclose(some>(newMessage)); - state = State.OCCUPIED; +export circuit revokeEnrollment(): [] { + isRevoked = disclose(true); } -export circuit takeDown(): Opaque<"string"> { - assert(state == State.OCCUPIED, "Attempted to take down post from an empty board"); - assert(owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>), "Attempted to take down post, but not the current owner"); - const formerMsg = message.value; - state = State.VACANT; - sequence.increment(1); - message = none>(); - return formerMsg; +export circuit pauseContract(): [] { + isPaused = disclose(true); } -export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> { - return persistentHash>>([pad(32, "bboard:pk:"), sequence, sk]); +export circuit resumeContract(): [] { + isPaused = disclose(false); } diff --git a/contract/src/test/bboard-simulator.ts b/contract/src/test/bboard-simulator.ts index b8e6bd2d..25afaaf7 100644 --- a/contract/src/test/bboard-simulator.ts +++ b/contract/src/test/bboard-simulator.ts @@ -1,18 +1,3 @@ -// This file is part of midnightntwrk/example-bboard. -// Copyright (C) Midnight Foundation -// SPDX-License-Identifier: Apache-2.0 -// Licensed under the Apache License, Version 2.0 (the "License"); -// You may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - import { type CircuitContext, QueryContext, @@ -29,7 +14,8 @@ import { import { type BBoardPrivateState, witnesses } from "../witnesses.js"; /** - * Serves as a testbed to exercise the contract in tests + * MANO Simulator — exercises the anonymous check-in contract in tests + * without a live proof server or blockchain connection. */ export class BBoardSimulator { readonly contract: Contract; @@ -55,15 +41,8 @@ export class BBoardSimulator { }; } - /*** - * Switch to a different secret key for a different user - * - * TODO: is there a nicer abstraction for testing multi-user dApps? - */ public switchUser(secretKey: Uint8Array) { - this.circuitContext.currentPrivateState = { - secretKey, - }; + this.circuitContext.currentPrivateState = { secretKey }; } public getLedger(): Ledger { @@ -74,32 +53,55 @@ export class BBoardSimulator { return this.circuitContext.currentPrivateState; } - public post(message: string): Ledger { - // Update the current context to be the result of executing the circuit. - this.circuitContext = this.contract.impureCircuits.post( + public publicKey(): Uint8Array { + return this.contract.circuits.publicKey( + this.circuitContext, + this.getPrivateState().secretKey, + ).result; + } + + public enroll(dateStr: string): Ledger { + this.circuitContext = this.contract.impureCircuits.enroll( this.circuitContext, - message, + dateStr, ).context; return ledger(this.circuitContext.currentQueryContext.state); } - public takeDown(): Ledger { - this.circuitContext = this.contract.impureCircuits.takeDown( + public checkIn(dateStr: string): Ledger { + this.circuitContext = this.contract.impureCircuits.checkIn( this.circuitContext, + dateStr, ).context; return ledger(this.circuitContext.currentQueryContext.state); } - public publicKey(): Uint8Array { - const sequence = convertFieldToBytes( - 32, - this.getLedger().sequence, - "bboard-simulator.ts", - ); - return this.contract.circuits.publicKey( + public verifyMilestone(threshold: bigint): Ledger { + this.circuitContext = this.contract.impureCircuits.verifyMilestone( this.circuitContext, - this.getPrivateState().secretKey, - sequence, - ).result; + threshold, + ).context; + return ledger(this.circuitContext.currentQueryContext.state); + } + + public revokeEnrollment(): Ledger { + this.circuitContext = this.contract.impureCircuits.revokeEnrollment( + this.circuitContext, + ).context; + return ledger(this.circuitContext.currentQueryContext.state); + } + + public pauseContract(): Ledger { + this.circuitContext = this.contract.impureCircuits.pauseContract( + this.circuitContext, + ).context; + return ledger(this.circuitContext.currentQueryContext.state); + } + + public resumeContract(): Ledger { + this.circuitContext = this.contract.impureCircuits.resumeContract( + this.circuitContext, + ).context; + return ledger(this.circuitContext.currentQueryContext.state); } } diff --git a/contract/src/test/bboard.test.ts b/contract/src/test/bboard.test.ts index 3d488540..f36add57 100644 --- a/contract/src/test/bboard.test.ts +++ b/contract/src/test/bboard.test.ts @@ -1,149 +1,107 @@ -// This file is part of midnightntwrk/example-bboard. -// Copyright (C) Midnight Foundation -// SPDX-License-Identifier: Apache-2.0 -// Licensed under the Apache License, Version 2.0 (the "License"); -// You may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - import { BBoardSimulator } from "./bboard-simulator.js"; -import { - NetworkId, - setNetworkId, -} from "@midnight-ntwrk/midnight-js-network-id"; +import { NetworkId, setNetworkId } from "@midnight-ntwrk/midnight-js-network-id"; import { describe, it, expect } from "vitest"; import { randomBytes } from "./utils.js"; -import { State } from "../managed/bboard/contract/index.js"; setNetworkId("undeployed" as NetworkId); -describe("BBoard smart contract", () => { +describe("MANO anonymous check-in contract", () => { + it("generates initial ledger state deterministically", () => { const key = randomBytes(32); - const simulator0 = new BBoardSimulator(key); - const simulator1 = new BBoardSimulator(key); - expect(simulator0.getLedger()).toEqual(simulator1.getLedger()); + const sim0 = new BBoardSimulator(key); + const sim1 = new BBoardSimulator(key); + expect(sim0.getLedger()).toEqual(sim1.getLedger()); }); - it("properly initializes ledger state and private state", () => { - const key = randomBytes(32); - const simulator = new BBoardSimulator(key); - const initialLedgerState = simulator.getLedger(); - expect(initialLedgerState.sequence).toEqual(1n); - expect(initialLedgerState.message.is_some).toEqual(false); - expect(initialLedgerState.message.value).toEqual(""); - expect(initialLedgerState.owner).toEqual(new Uint8Array(32)); - expect(initialLedgerState.state).toEqual(State.VACANT); - const initialPrivateState = simulator.getPrivateState(); - expect(initialPrivateState).toEqual({ secretKey: key }); + it("properly initializes ledger state", () => { + const sim = new BBoardSimulator(randomBytes(32)); + const state = sim.getLedger(); + expect(state.isEnrolled).toEqual(false); + expect(state.isRevoked).toEqual(false); + expect(state.isPaused).toEqual(false); + expect(state.owner).toEqual(new Uint8Array(32)); + }); + + it("enrolls a new participant successfully", () => { + const sim = new BBoardSimulator(randomBytes(32)); + const state = sim.enroll("2026-05-15"); + expect(state.isEnrolled).toEqual(true); + expect(state.isRevoked).toEqual(false); + expect(state.owner).toEqual(sim.publicKey()); + expect(state.checkInDate.is_some).toEqual(true); + expect(state.checkInDate.value).toEqual("2026-05-15"); + }); + + it("records a check-in and increments milestone count", () => { + const sim = new BBoardSimulator(randomBytes(32)); + sim.enroll("2026-05-15"); + const beforeCount = sim.getLedger().milestoneCount; + const state = sim.checkIn("2026-05-16"); + expect(state.milestoneCount).toEqual(beforeCount + 1n); + expect(state.checkInDate.value).toEqual("2026-05-16"); + }); + + it("verifies milestone after sufficient check-ins", () => { + const sim = new BBoardSimulator(randomBytes(32)); + sim.enroll("2026-05-15"); + sim.checkIn("2026-05-16"); + const currentCount = sim.getLedger().milestoneCount; + expect(() => sim.verifyMilestone(currentCount)).not.toThrow(); + }); + + it("revokes enrollment successfully", () => { + const sim = new BBoardSimulator(randomBytes(32)); + sim.enroll("2026-05-15"); + const state = sim.revokeEnrollment(); + expect(state.isEnrolled).toEqual(true); + expect(state.isRevoked).toEqual(true); }); - it("lets you set a message", () => { - const simulator = new BBoardSimulator(randomBytes(32)); - const initialPrivateState = simulator.getPrivateState(); - const message = - "Szeth-son-son-Vallano, Truthless of Shinovar, wore white on the day he was to kill a king"; - simulator.post(message); - // the private ledger state shouldn't change - expect(initialPrivateState).toEqual(simulator.getPrivateState()); - // And all the correct things should have been updated in the public ledger state - const ledgerState = simulator.getLedger(); - expect(ledgerState.sequence).toEqual(1n); - expect(ledgerState.message.is_some).toEqual(true); - expect(ledgerState.message.value).toEqual(message); - expect(ledgerState.owner).toEqual(simulator.publicKey()); - expect(ledgerState.state).toEqual(State.OCCUPIED); + it("pauses and resumes the contract", () => { + const sim = new BBoardSimulator(randomBytes(32)); + const paused = sim.pauseContract(); + expect(paused.isPaused).toEqual(true); + const resumed = sim.resumeContract(); + expect(resumed.isPaused).toEqual(false); }); - it("lets you take down a message", () => { - const simulator = new BBoardSimulator(randomBytes(32)); - const initialPrivateState = simulator.getPrivateState(); - const initialPublicKey = simulator.publicKey(); - const message = - "Prince Raoden of Arelon awoke early that morning, completely unaware that he had been damned for all eternity."; - simulator.post(message); - simulator.takeDown(); - // the private ledger state shouldn't change - expect(initialPrivateState).toEqual(simulator.getPrivateState()); - // And all the correct things should have been updated in the public ledger state - const ledgerState = simulator.getLedger(); - expect(ledgerState.sequence).toEqual(2n); - expect(ledgerState.message.is_some).toEqual(false); - expect(ledgerState.message.value).toEqual(""); - // Technically the circuit doesn't clear the previous owner - expect(ledgerState.owner).toEqual(initialPublicKey); - expect(ledgerState.state).toEqual(State.VACANT); + it("does not allow enrolling twice", () => { + const sim = new BBoardSimulator(randomBytes(32)); + sim.enroll("2026-05-15"); + expect(() => sim.enroll("2026-05-15")).toThrow("failed assert: already enrolled"); }); - it("lets you post another message after taking down the first", () => { - const simulator = new BBoardSimulator(randomBytes(32)); - const initialPrivateState = simulator.getPrivateState(); - simulator.post("Life before Death."); - simulator.takeDown(); - const message = "Strength before Weakness."; - simulator.post(message); - // the private ledger state shouldn't change - expect(initialPrivateState).toEqual(simulator.getPrivateState()); - // And all the correct things should have been updated in the public ledger state - const ledgerState = simulator.getLedger(); - expect(ledgerState.sequence).toEqual(2n); - expect(ledgerState.message.is_some).toEqual(true); - expect(ledgerState.message.value).toEqual(message); - expect(ledgerState.owner).toEqual(simulator.publicKey()); - expect(ledgerState.state).toEqual(State.OCCUPIED); + it("does not allow check-in without enrolling", () => { + const sim = new BBoardSimulator(randomBytes(32)); + expect(() => sim.checkIn("2026-05-15")).toThrow("failed assert: not enrolled"); }); - it("lets a different user post a message after taking down the first", () => { - const simulator = new BBoardSimulator(randomBytes(32)); - simulator.post("Remember, the past need not become our future as well."); - simulator.takeDown(); - simulator.switchUser(randomBytes(32)); - const message = "Joy was more than just an absence of discomfort."; - simulator.post(message); - const ledgerState = simulator.getLedger(); - expect(ledgerState.sequence).toEqual(2n); - expect(ledgerState.message.is_some).toEqual(true); - expect(ledgerState.message.value).toEqual(message); - expect(ledgerState.owner).toEqual(simulator.publicKey()); - expect(ledgerState.state).toEqual(State.OCCUPIED); + it("does not allow check-in with a different secret key", () => { + const sim = new BBoardSimulator(randomBytes(32)); + sim.enroll("2026-05-15"); + sim.switchUser(randomBytes(32)); + expect(() => sim.checkIn("2026-05-16")).toThrow("failed assert: not the enrolled participant"); }); - it("doesn't let the same user post twice", () => { - const simulator = new BBoardSimulator(randomBytes(32)); - simulator.post( - "My name is Stephen Leeds, and I am perfectly sane. My hallucinations, however, are all quite mad.", - ); - expect(() => - simulator.post( - "You should know by now that I've already had greatness. I traded it for mediocrity and some measure of sanity.", - ), - ).toThrow("failed assert: Attempted to post to an occupied board"); + it("does not allow check-in after revocation", () => { + const sim = new BBoardSimulator(randomBytes(32)); + sim.enroll("2026-05-15"); + sim.revokeEnrollment(); + expect(() => sim.checkIn("2026-05-16")).toThrow("failed assert: enrollment revoked"); }); - it("doesn't let different users post twice", () => { - const simulator = new BBoardSimulator(randomBytes(32)); - simulator.post("Ash fell from the sky"); - simulator.switchUser(randomBytes(32)); - expect(() => - simulator.post("I am, unfortunately, the hero of ages."), - ).toThrow("failed assert: Attempted to post to an occupied board"); + it("does not allow enroll when contract is paused", () => { + const sim = new BBoardSimulator(randomBytes(32)); + sim.pauseContract(); + expect(() => sim.enroll("2026-05-15")).toThrow("failed assert: contract is paused"); }); - it("doesn't let users take down someone elses posts", () => { - const simulator = new BBoardSimulator(randomBytes(32)); - simulator.post( - "Sometimes a hypocrite is nothing more than a man in the process of changing.", - ); - simulator.switchUser(randomBytes(32)); - expect(() => simulator.takeDown()).toThrow( - "failed assert: Attempted to take down post, but not the current owner", - ); + it("does not allow verifying a milestone that has not been reached", () => { + const sim = new BBoardSimulator(randomBytes(32)); + sim.enroll("2026-05-15"); + sim.checkIn("2026-05-16"); + const wrongThreshold = sim.getLedger().milestoneCount + 1n; + expect(() => sim.verifyMilestone(wrongThreshold)).toThrow("failed assert: milestone count does not match threshold"); }); }); diff --git a/package-lock.json b/package-lock.json index ad0d211f..a2e1d053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@midnight-ntwrk/compact-js": "^2.5.0", - "@midnight-ntwrk/compact-runtime": "^0.15.0", + "@midnight-ntwrk/compact-runtime": "^0.16.0", "@midnight-ntwrk/dapp-connector-api": "^4.0.0", "@midnight-ntwrk/ledger-v8": "^8.0.0", "@midnight-ntwrk/midnight-js-compact": "^4.0.0", @@ -100,7 +100,7 @@ "node-stdlib-browser": "^1.3.1", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.14.0" + "react-router-dom": "7.14.0" }, "devDependencies": { "@swc/core": "^1.15.11", @@ -1325,7 +1325,7 @@ "tslib": "^2.8.1" } }, - "node_modules/@midnight-ntwrk/compact-runtime": { + "node_modules/@midnight-ntwrk/compact-js/node_modules/@midnight-ntwrk/compact-runtime": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/@midnight-ntwrk/compact-runtime/-/compact-runtime-0.15.0.tgz", "integrity": "sha512-6+odrxb83ZZwF29SqNBBgaOR3hf5ej4KZVGibDwdXta9+uiv6qYW0fMrsKP9S+c2AqvY+vEb5NEFFI54lA7G5A==", @@ -1336,6 +1336,17 @@ "object-inspect": "^1.12.3" } }, + "node_modules/@midnight-ntwrk/compact-runtime": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@midnight-ntwrk/compact-runtime/-/compact-runtime-0.16.0.tgz", + "integrity": "sha512-UR8+MI5zol+gkne17ZZru8xmZNf11xMgQJkiLT9KWsF+QD8Wuz1WONaqQLL0RAZ2aN2XlRsBFd0ViKDVh8prFw==", + "license": "Apache-2.0", + "dependencies": { + "@midnight-ntwrk/onchain-runtime-v3": "^3.0.0", + "@types/object-inspect": "^1.8.1", + "object-inspect": "^1.12.3" + } + }, "node_modules/@midnight-ntwrk/dapp-connector-api": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@midnight-ntwrk/dapp-connector-api/-/dapp-connector-api-4.0.0.tgz", diff --git a/package.json b/package.json index ac7a41b5..2b4bcd17 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@midnight-ntwrk/compact-js": "^2.5.0", - "@midnight-ntwrk/compact-runtime": "^0.15.0", + "@midnight-ntwrk/compact-runtime": "^0.16.0", "@midnight-ntwrk/dapp-connector-api": "^4.0.0", "@midnight-ntwrk/ledger-v8": "^8.0.0", "@midnight-ntwrk/midnight-js-compact": "^4.0.0",