Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Install Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 #v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #v6.3.0
with:
node-version: "22"

Expand Down
104 changes: 102 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,111 @@
# Bulletin Board DApp
# Multi-Post Bulletin Board DApp

This project is built on the [Midnight Network](https://midnight.network/).

[![Generic badge](https://img.shields.io/badge/Compact%20Compiler-0.29.0-1abc9c.svg)](https://shields.io/)
[![Generic badge](https://img.shields.io/badge/TypeScript-5.8.3-blue.svg)](https://shields.io/)

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.
A Midnight smart contract example demonstrating a **multi-post bulletin board** with zero-knowledge proofs on testnet. Users can post multiple messages (up to MAX_POSTS), and only the respective message authors can remove their own posts.

---

## Track A: Multi-Post Board - Design Write-up

### Design Decisions

#### Why Map + postCounter Instead of Array?

The original bulletin board contract used a single message storage model. For the multi-post extension, we chose to use `Map<Uint32, Post>` combined with a `postCounter` instead of an `Array` for several important reasons:

1. **Efficient Deletion**: A `Map` allows O(1) deletion of any post by its key (ID) without affecting other entries. With an `Array`, removing an element from the middle would require either:
- Shifting all subsequent elements (expensive and breaks existing IDs)
- Leaving "holes" in the array (wastes space and complicates iteration)

2. **Stable Post IDs**: Each post gets a permanent, unique ID that never changes. This is crucial for the CLI user experience—users can reference posts by ID even after other posts are deleted.

3. **Sparse Storage**: The `Map` naturally handles gaps when posts are deleted. We don't need to track which indices are "valid" vs "empty."

4. **Simple Counter Logic**: The `postCounter` auto-increments for each new post, providing unique IDs without needing to search for the next available slot.

#### Why MAX_POSTS = 10?

The `MAX_POSTS` constant limits the number of concurrent posts for several reasons:

1. **Resource Constraints**: Zero-knowledge circuits have computational limits. Unbounded data structures would make proof generation impractical.

2. **DoS Prevention**: Without a limit, an attacker could flood the board with posts, potentially causing performance issues.

3. **Practical Use Case**: A bulletin board typically doesn't need unlimited posts. Ten concurrent posts is sufficient for demonstration while keeping the circuit size manageable.

### Compact Patterns Applied

#### Using `disclose()` for Public Data

The `disclose()` function in Compact explicitly marks data that should be written to the public ledger:

```compact
const newPost = Post {
message: disclose(newMessage),
owner: disclose(ownerPk)
};
posts.insert(disclose(postId), newPost);
```

Every piece of data visible on-chain is intentionally disclosed. This makes the privacy model explicit—developers must consciously choose what becomes public.

#### Witness Functions for Secret Keys

The `localSecretKey()` witness function retrieves the user's secret key from off-chain private state:

```compact
witness localSecretKey(): Bytes<32>;
```

This pattern ensures:
- The secret key never appears in the transaction or on-chain
- Only the derived public key (via `publicKey()`) is disclosed
- Users can prove ownership without revealing their identity

#### Assert Statements for State Protection

`assert` statements guard against invalid state transitions:

```compact
assert(posts.size() < MAX_POSTS, "Maximum number of posts reached");
assert(posts.member(postId), "Post does not exist");
assert(existingPost.owner == callerPk, "Only the post owner can take down this post");
```

These assertions are enforced in the zero-knowledge proof—if any assertion fails, the proof cannot be generated, and the transaction is rejected. This provides cryptographic guarantees for access control.

### Privacy Properties

#### What Is Public (On-Chain)

1. **Post Messages**: The content of each post is fully public on the ledger. Anyone can read all active posts.

2. **Owner Public Keys**: Each post stores a derived public key (`Bytes<32>`) that identifies the owner. This is a hash of `[prefix, sequence, secretKey]`.

3. **Post Counter & IDs**: The total number of posts ever created and each post's unique ID are public.

#### What Is Private (Off-Chain)

1. **Secret Keys**: The actual secret key (`localSecretKey`) never leaves the user's device. It's only accessed through the witness function during proof generation.

2. **Ownership Linkage**: While public keys are visible, linking them to real-world identities is computationally infeasible without the secret key. Each public key is derived using a secure hash, providing pseudonymous identity.

3. **Proof of Ownership**: When a user takes down a post, the zero-knowledge proof demonstrates they possess the secret key corresponding to the owner's public key—without revealing the secret key itself.

#### Privacy Trade-offs

This design prioritizes **transparency** of content while maintaining **identity privacy**:
- Anyone can see what's posted (public bulletin board semantics)
- No one can impersonate another user without their secret key
- Users can prove ownership without revealing their identity

For use cases requiring private messages, the contract could be modified to encrypt messages using asymmetric encryption, with only the intended recipient able to decrypt.

---

## Project Structure

Expand Down
38 changes: 22 additions & 16 deletions api/src/common-types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// This file is part of midnightntwrk/example-counter.
// This file is part of midnightntwrk/example-bboard.
// Copyright (C) 2025 Midnight Foundation
// SPDX-License-Identifier: Apache-2.0
// Licensed under the Apache License, Version 2.0 (the "License");
Expand All @@ -21,7 +21,7 @@

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;
Expand Down Expand Up @@ -77,23 +77,29 @@ export type BBoardProviders = MidnightProviders<BBoardCircuitKeys, PrivateStateI
*/
export type DeployedBBoardContract = FoundContract<BBoardContract>;

/**
* Represents a single post in the multi-post board.
*
* @public
*/
export type PostEntry = {
/** The unique ID of the post */
readonly id: number;
/** The message content */
readonly message: string;
/** Whether the current user owns this post */
readonly isOwner: boolean;
};

/**
* A type that represents the derived combination of public (or ledger), and private state.
* Now supports multiple posts instead of a single message.
*/
export type BBoardDerivedState = {
readonly state: State;
/** The sequence counter for public key derivation */
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;
/** Total number of posts ever created */
readonly postCounter: number;
/** Array of all active posts with ownership info */
readonly posts: PostEntry[];
};

// 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 ?
70 changes: 43 additions & 27 deletions api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// This file is part of midnightntwrk/example-counter.
// This file is part of midnightntwrk/example-bboard.
// Copyright (C) 2025 Midnight Foundation
// SPDX-License-Identifier: Apache-2.0
// Licensed under the Apache License, Version 2.0 (the "License");
Expand All @@ -14,7 +14,7 @@
// limitations under the License.

/**
* Provides types and utilities for working with bulletin board contracts.
* Provides types and utilities for working with multi-post bulletin board contracts.
*
* @packageDocumentation
*/
Expand All @@ -28,6 +28,7 @@ import {
type BBoardContract,
type BBoardProviders,
type DeployedBBoardContract,
type PostEntry,
bboardPrivateStateKey,
} from './common-types.js';
import { CompiledBBoardContractContract } from '../../contract/src/index';
Expand All @@ -40,14 +41,14 @@ import { BBoardPrivateState, createBBoardPrivateState } from '@midnight-ntwrk/bb
/** @internal */

/**
* An API for a deployed bulletin board.
* An API for a deployed multi-post bulletin board.
*/
export interface DeployedBBoardAPI {
readonly deployedContractAddress: ContractAddress;
readonly state$: Observable<BBoardDerivedState>;

post: (message: string) => Promise<void>;
takeDown: () => Promise<void>;
post: (message: string) => Promise<number>;
takeDown: (postId: number) => Promise<void>;
}

/**
Expand All @@ -58,15 +59,14 @@ export interface DeployedBBoardAPI {
* 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
* user, and is used to determine if the current user is the owner of each post 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(
Expand All @@ -84,18 +84,14 @@ export class BBoardAPI implements DeployedBBoardAPI {
logger?.trace({
ledgerStateChanged: {
ledgerState: {
...ledgerState,
state: ledgerState.state === BBoard.State.OCCUPIED ? 'occupied' : 'vacant',
owner: toHex(ledgerState.owner),
postCounter: ledgerState.postCounter,
postsCount: ledgerState.posts.size,
},
},
}),
),
),
// ...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<BBoardPrivateState>),
],
// ...and combine them to produce the required derived state.
Expand All @@ -104,12 +100,25 @@ export class BBoardAPI implements DeployedBBoardAPI {
privateState.secretKey,
convertFieldToBytes(32, ledgerState.sequence, 'api/src/index.ts'),
);
const hashedSecretKeyHex = toHex(hashedSecretKey);

// Convert Map<number, Post> to array of PostEntry with ownership info
const posts: PostEntry[] = [];
for (const [id, post] of ledgerState.posts.entries()) {
posts.push({
id,
message: post.message,
isOwner: toHex(post.owner) === hashedSecretKeyHex,
});
}

// Sort posts by ID for consistent display
posts.sort((a, b) => a.id - b.id);

return {
state: ledgerState.state,
message: ledgerState.message.value,
sequence: ledgerState.sequence,
isOwner: toHex(ledgerState.owner) === toHex(hashedSecretKey),
postCounter: ledgerState.postCounter,
posts,
};
},
);
Expand All @@ -127,14 +136,16 @@ export class BBoardAPI implements DeployedBBoardAPI {
readonly state$: Observable<BBoardDerivedState>;

/**
* Attempts to post a given message to the bulletin board.
* Attempts to post a new message to the multi-post bulletin board.
*
* @param message The message to post.
* @returns The ID of the newly created post.
*
* @remarks
* This method can fail during local circuit execution if the bulletin board is currently occupied.
* This method can fail during local circuit execution if the maximum number of posts
* (MAX_POSTS) has been reached.
*/
async post(message: string): Promise<void> {
async post(message: string): Promise<number> {
this.logger?.info(`postingMessage: ${message}`);

const txData = await this.deployedContract.callTx.post(message);
Expand All @@ -146,20 +157,25 @@ export class BBoardAPI implements DeployedBBoardAPI {
blockHeight: txData.public.blockHeight,
},
});

// Return the post ID from the transaction result
return txData.public.result as number;
}

/**
* Attempts to take down any currently posted message on the bulletin board.
* Attempts to take down (delete) a specific post from the bulletin board.
*
* @param postId The ID of the post to delete.
*
* @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.
* This method can fail during local circuit execution if:
* - The post does not exist
* - The current user is not the owner of the post
*/
async takeDown(): Promise<void> {
this.logger?.info('takingDownMessage');
async takeDown(postId: number): Promise<void> {
this.logger?.info(`takingDownMessage: postId=${postId}`);

const txData = await this.deployedContract.callTx.takeDown();
const txData = await this.deployedContract.callTx.takeDown(postId);

this.logger?.trace({
transactionAdded: {
Expand All @@ -171,7 +187,7 @@ export class BBoardAPI implements DeployedBBoardAPI {
}

/**
* Deploys a new bulletin board contract to the network.
* Deploys a new multi-post bulletin board contract to the network.
*
* @param providers The bulletin board providers.
* @param logger An optional 'pino' logger to use for logging.
Expand Down
Loading