diff --git a/README.md b/README.md index 111214a..8442362 100644 --- a/README.md +++ b/README.md @@ -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` 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 diff --git a/api/src/common-types.ts b/api/src/common-types.ts index be94010..0486451 100644 --- a/api/src/common-types.ts +++ b/api/src/common-types.ts @@ -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"); @@ -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; @@ -77,23 +77,29 @@ export type BBoardProviders = MidnightProviders; +/** + * 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 ? diff --git a/api/src/index.ts b/api/src/index.ts index 3e9ccc0..869f27d 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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"); @@ -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 */ @@ -28,6 +28,7 @@ import { type BBoardContract, type BBoardProviders, type DeployedBBoardContract, + type PostEntry, bboardPrivateStateKey, } from './common-types.js'; import { CompiledBBoardContractContract } from '../../contract/src/index'; @@ -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; - post: (message: string) => Promise; - takeDown: () => Promise; + post: (message: string) => Promise; + takeDown: (postId: number) => Promise; } /** @@ -58,7 +59,7 @@ 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 @@ -66,7 +67,6 @@ export interface DeployedBBoardAPI { * 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( @@ -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), ], // ...and combine them to produce the required derived state. @@ -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 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, }; }, ); @@ -127,14 +136,16 @@ export class BBoardAPI implements DeployedBBoardAPI { readonly state$: Observable; /** - * 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 { + async post(message: string): Promise { this.logger?.info(`postingMessage: ${message}`); const txData = await this.deployedContract.callTx.post(message); @@ -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 { - this.logger?.info('takingDownMessage'); + async takeDown(postId: number): Promise { + 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: { @@ -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. diff --git a/bboard-cli/src/index.ts b/bboard-cli/src/index.ts index 23a4e7b..4d5489f 100644 --- a/bboard-cli/src/index.ts +++ b/bboard-cli/src/index.ts @@ -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"); @@ -14,7 +14,7 @@ // limitations under the License. /* - * This file is the main driver for the Midnight bulletin board example. + * This file is the main driver for the Midnight multi-post bulletin board example. * The entry point is the run function, at the end of the file. * We expect the startup files (testnet-remote.ts, standalone.ts, etc.) to * call run with some specific configuration that sets the network addresses @@ -31,9 +31,10 @@ import { type BBoardProviders, type DeployedBBoardContract, type PrivateStateId, + type PostEntry, } from '../../api/src/index'; import { type WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade'; -import { ledger, type Ledger, State } from '../../contract/src/managed/bboard/contract/index.js'; +import { ledger, type Ledger } from '../../contract/src/managed/bboard/contract/index.js'; import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider'; import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider'; import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider'; @@ -70,9 +71,6 @@ export const getBBoardLedgerState = async ( const contractState = await providers.publicDataProvider.queryContractState(contractAddress); return contractState != null ? ledger(contractState.data) : null; }; -// providers.publicDataProvider -// .queryContractState(contractAddress) -// .then((contractState) => (contractState != null ? ledger(contractState.data) : null)); /* ********************************************************************** * deployOrJoin: returns a contract, by prompting the user about @@ -82,7 +80,7 @@ export const getBBoardLedgerState = async ( const DEPLOY_OR_JOIN_QUESTION = ` You can do one of the following: - 1. Deploy a new bulletin board contract + 1. Deploy a new multi-post bulletin board contract 2. Join an existing bulletin board contract 3. Exit Which would you like to do? `; @@ -125,12 +123,15 @@ const displayLedgerState = async ( if (ledgerState === null) { logger.info(`There is no bulletin board contract deployed at ${contractAddress}`); } else { - const boardState = ledgerState.state === State.OCCUPIED ? 'occupied' : 'vacant'; - const latestMessage = !ledgerState.message.is_some ? 'none' : ledgerState.message.value; - logger.info(`Current state is: '${boardState}'`); - logger.info(`Current message is: '${latestMessage}'`); - logger.info(`Current sequence is: ${ledgerState.sequence}`); - logger.info(`Current owner is: '${toHex(ledgerState.owner)}'`); + logger.info(`Post counter: ${ledgerState.postCounter}`); + logger.info(`Active posts: ${ledgerState.posts.size}`); + logger.info(`Sequence: ${ledgerState.sequence}`); + if (ledgerState.posts.size > 0) { + logger.info('--- Posts on the board ---'); + for (const [id, post] of ledgerState.posts.entries()) { + logger.info(` [${id}] "${post.message}" (owner: ${toHex(post.owner).substring(0, 16)}...)`); + } + } } }; @@ -150,37 +151,60 @@ const displayPrivateState = async (providers: BBoardProviders, logger: Logger): /* ********************************************************************** * displayDerivedState: shows the values of derived state which is made * by combining the ledger state with private state. In this example, the - * derived state compares the owner's key with the private secret key to - * determine if the current user is the owner of the current message. + * derived state computes ownership for each post. */ -const displayDerivedState = (ledgerState: BBoardDerivedState | undefined, logger: Logger) => { - if (ledgerState === undefined) { +const displayDerivedState = (state: BBoardDerivedState | undefined, logger: Logger) => { + if (state === undefined) { logger.info(`No bulletin board state currently available`); } else { - const boardState = ledgerState.state === State.OCCUPIED ? 'occupied' : 'vacant'; - const latestMessage = ledgerState.state === State.OCCUPIED ? ledgerState.message : 'none'; - logger.info(`Current state is: '${boardState}'`); - logger.info(`Current message is: '${latestMessage}'`); - logger.info(`Current sequence is: ${ledgerState.sequence}`); - logger.info(`Current owner is: '${ledgerState.isOwner ? 'you' : 'not you'}'`); + logger.info(`Post counter: ${state.postCounter}`); + logger.info(`Active posts: ${state.posts.length}`); + logger.info(`Sequence: ${state.sequence}`); + if (state.posts.length > 0) { + logger.info('--- Posts on the board ---'); + for (const post of state.posts) { + const ownerLabel = post.isOwner ? 'Owner' : 'Not Owner'; + logger.info(` [${post.id}] "${post.message}" (${ownerLabel})`); + } + } else { + logger.info('No posts on the board.'); + } + } +}; + +/* ********************************************************************** + * displayPostsList: shows all posts in a formatted list with ownership + */ + +const displayPostsList = (posts: PostEntry[], logger: Logger) => { + if (posts.length === 0) { + logger.info('No posts on the bulletin board.'); + return; + } + logger.info('=== Active Posts ==='); + for (const post of posts) { + const ownerLabel = post.isOwner ? 'Owner' : 'Not Owner'; + logger.info(`[${post.id}] - "${post.message}" (${ownerLabel})`); } + logger.info('===================='); }; /* ********************************************************************** - * mainLoop: the main interactive menu of the bulletin board CLI. + * mainLoop: the main interactive menu of the multi-post bulletin board CLI. * Before starting the loop, the user is prompted to deploy a new * contract or join an existing one. */ const MAIN_LOOP_QUESTION = ` You can do one of the following: - 1. Post a message - 2. Take down your message - 3. Display the current ledger state (known by everyone) - 4. Display the current private state (known only to this DApp instance) - 5. Display the current derived state (known only to this DApp instance) - 6. Exit + 1. Post a new message + 2. Take down a post (by ID) + 3. List all posts + 4. Display the current ledger state (known by everyone) + 5. Display the current private state (known only to this DApp instance) + 6. Display the current derived state (known only to this DApp instance) + 7. Exit Which would you like to do? `; const mainLoop = async (providers: BBoardProviders, rli: Interface, logger: Logger): Promise => { @@ -199,22 +223,54 @@ const mainLoop = async (providers: BBoardProviders, rli: Interface, logger: Logg switch (choice) { case '1': { const message = await rli.question(`What message do you want to post? `); - await bboardApi.post(message); + try { + const postId = await bboardApi.post(message); + logger.info(`Posted message with ID: ${postId}`); + } catch (e) { + if (e instanceof Error) { + logger.error(`Failed to post: ${e.message}`); + } + } break; } - case '2': - await bboardApi.takeDown(); + case '2': { + // Show current posts first + if (currentState) { + displayPostsList(currentState.posts, logger); + } + const postIdStr = await rli.question(`Enter ID of the post you want to delete: `); + const postId = parseInt(postIdStr, 10); + if (isNaN(postId)) { + logger.error('Invalid post ID. Please enter a number.'); + break; + } + try { + await bboardApi.takeDown(postId); + logger.info(`Successfully took down post with ID: ${postId}`); + } catch (e) { + if (e instanceof Error) { + logger.error(`Failed to take down post: ${e.message}`); + } + } break; + } case '3': - await displayLedgerState(providers, bboardApi.deployedContract, logger); + if (currentState) { + displayPostsList(currentState.posts, logger); + } else { + logger.info('No state available yet. Please wait for the board to sync.'); + } break; case '4': - await displayPrivateState(providers, logger); + await displayLedgerState(providers, bboardApi.deployedContract, logger); break; case '5': - displayDerivedState(currentState, logger); + await displayPrivateState(providers, logger); break; case '6': + displayDerivedState(currentState, logger); + break; + case '7': logger.info('Exiting...'); return; default: @@ -268,7 +324,7 @@ const buildWallet = async (config: Config, rli: Interface, logger: Logger): Prom }; /* ********************************************************************** - * run: the main entry point that starts the whole bulletin board CLI. + * run: the main entry point that starts the whole multi-post bulletin board CLI. * * If called with a Docker environment argument, the application * will wait for Docker to be ready before doing anything else. diff --git a/contract/src/bboard.compact b/contract/src/bboard.compact index d639f59..8ea6dd5 100644 --- a/contract/src/bboard.compact +++ b/contract/src/bboard.compact @@ -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"); @@ -17,44 +17,74 @@ pragma language_version >= 0.20; import CompactStandardLibrary; -export enum State { - VACANT, - OCCUPIED +// Maximum number of posts allowed on the board +const MAX_POSTS: Uint32 = 10; + +// Post structure containing message and owner's public key hash +export struct Post { + message: Opaque<"string">; + owner: Bytes<32>; } -export ledger state: State; +// Map of posts indexed by post ID +export ledger posts: Map; -export ledger message: Maybe>; +// Counter for generating unique post IDs (also tracks total posts ever created) +export ledger postCounter: Uint32; +// Sequence counter for public key derivation export ledger sequence: Counter; -export ledger owner: Bytes<32>; - constructor() { - state = State.VACANT; - message = none>(); + postCounter = 0; sequence.increment(1); } witness localSecretKey(): Bytes<32>; -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; +// Post a new message to the board +export circuit post(newMessage: Opaque<"string">): Uint32 { + // Check that we haven't exceeded MAX_POSTS (count active posts) + assert(posts.size() < MAX_POSTS, "Maximum number of posts reached"); + + // Generate owner's public key from secret key + const ownerPk = publicKey(localSecretKey(), sequence as Field as Bytes<32>); + + // Create new post with disclosed message and owner + const newPost = Post { + message: disclose(newMessage), + owner: disclose(ownerPk) + }; + + // Get current post ID and store the post + const postId = postCounter; + posts.insert(disclose(postId), newPost); + + // Increment post counter for next post + postCounter = disclose(postId + 1); + + return postId; } -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; +// Take down (delete) a post by its ID - only owner can delete +export circuit takeDown(postId: Uint32): [] { + // Check that the post exists + assert(posts.member(postId), "Post does not exist"); + + // Get the post to verify ownership + const existingPost = posts.lookup(postId); + + // Generate caller's public key + const callerPk = publicKey(localSecretKey(), sequence as Field as Bytes<32>); + + // Verify caller is the owner of the post + assert(existingPost.owner == callerPk, "Only the post owner can take down this post"); + + // Remove the post from the map + posts.remove(postId); } +// Pure circuit to compute public key from secret key and sequence export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> { return persistentHash>>([pad(32, "bboard:pk:"), sequence, sk]); } diff --git a/contract/src/test/bboard-simulator.ts b/contract/src/test/bboard-simulator.ts index 9b4fa94..b8409dc 100644 --- a/contract/src/test/bboard-simulator.ts +++ b/contract/src/test/bboard-simulator.ts @@ -25,11 +25,12 @@ import { Contract, type Ledger, ledger, + MAX_POSTS, } from "../managed/bboard/contract/index.js"; import { type BBoardPrivateState, witnesses } from "../witnesses.js"; /** - * Serves as a testbed to exercise the contract in tests + * Serves as a testbed to exercise the multi-post contract in tests */ export class BBoardSimulator { readonly contract: Contract; @@ -55,10 +56,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 = { @@ -74,22 +73,33 @@ 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( + /** + * Post a new message to the board + * @returns The ID of the newly created post + */ + public post(message: string): number { + const result = this.contract.impureCircuits.post( this.circuitContext, message, - ).context; - return ledger(this.circuitContext.currentQueryContext.state); + ); + this.circuitContext = result.context; + return result.result; } - public takeDown(): Ledger { + /** + * Take down (delete) a post by ID + * @param postId The ID of the post to delete + */ + public takeDown(postId: number): void { this.circuitContext = this.contract.impureCircuits.takeDown( this.circuitContext, + postId, ).context; - return ledger(this.circuitContext.currentQueryContext.state); } + /** + * Compute the public key for the current user + */ public publicKey(): Uint8Array { const sequence = convertFieldToBytes( 32, @@ -102,4 +112,11 @@ export class BBoardSimulator { sequence, ).result; } + + /** + * Get the maximum number of posts allowed + */ + public getMaxPosts(): number { + return MAX_POSTS; + } } diff --git a/contract/src/test/bboard.test.ts b/contract/src/test/bboard.test.ts index 090fb3b..5606e4a 100644 --- a/contract/src/test/bboard.test.ts +++ b/contract/src/test/bboard.test.ts @@ -20,11 +20,10 @@ import { } 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("Multi-Post BBoard smart contract", () => { it("generates initial ledger state deterministically", () => { const key = randomBytes(32); const simulator0 = new BBoardSimulator(key); @@ -37,113 +36,190 @@ describe("BBoard smart contract", () => { 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); + expect(initialLedgerState.postCounter).toEqual(0); + expect(initialLedgerState.posts.size).toEqual(0); const initialPrivateState = simulator.getPrivateState(); expect(initialPrivateState).toEqual({ secretKey: key }); }); - 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); - }); + // ========== Test 1: Post multiple messages from different accounts ========== + it("lets multiple users post messages (Test 1: multi-post)", () => { + const userA = randomBytes(32); + const userB = randomBytes(32); + const simulator = new BBoardSimulator(userA); - 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 + // User A posts first message + const message1 = "First post by User A"; + const postId1 = simulator.post(message1); + expect(postId1).toEqual(0); + + // User A posts second message + const message2 = "Second post by User A"; + const postId2 = simulator.post(message2); + expect(postId2).toEqual(1); + + // Switch to User B and post + simulator.switchUser(userB); + const message3 = "Post by User B"; + const postId3 = simulator.post(message3); + expect(postId3).toEqual(2); + + // Verify the state contains all 3 posts 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); + expect(ledgerState.postCounter).toEqual(3); + expect(ledgerState.posts.size).toEqual(3); + + // Verify post contents + const post0 = ledgerState.posts.get(0); + expect(post0).toBeDefined(); + expect(post0!.message).toEqual(message1); + + const post1 = ledgerState.posts.get(1); + expect(post1).toBeDefined(); + expect(post1!.message).toEqual(message2); + + const post2 = ledgerState.posts.get(2); + expect(post2).toBeDefined(); + expect(post2!.message).toEqual(message3); }); - 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 + // ========== Test 2: Valid takedown by owner ========== + it("lets owner take down their own post (Test 2: valid takedown)", () => { + const userA = randomBytes(32); + const simulator = new BBoardSimulator(userA); + + // User A posts a message + const message = "This post will be deleted"; + const postId = simulator.post(message); + expect(simulator.getLedger().posts.size).toEqual(1); + + // User A takes down their post + simulator.takeDown(postId); + + // Verify the post is gone 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); + expect(ledgerState.posts.size).toEqual(0); + expect(ledgerState.posts.get(postId)).toBeUndefined(); }); - 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); + // ========== Test 3: Invalid takedown by non-owner (should fail) ========== + it("doesn't let users take down someone else's post (Test 3: invalid takedown)", () => { + const userA = randomBytes(32); + const userB = randomBytes(32); + const simulator = new BBoardSimulator(userA); + + // User A posts a message + const message = "Only User A can delete this"; + const postId = simulator.post(message); + + // Switch to User B and try to take down User A's post + simulator.switchUser(userB); + expect(() => simulator.takeDown(postId)).toThrow( + "failed assert: Only the post owner can take down this post", + ); + + // Verify the post still exists 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); + expect(ledgerState.posts.size).toEqual(1); + expect(ledgerState.posts.get(postId)!.message).toEqual(message); }); - it("doesn't let the same user post twice", () => { + // ========== Test 4: Exceed MAX_POSTS limit (should fail) ========== + it("rejects posts when MAX_POSTS limit is reached (Test 4: exceed limit)", () => { 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.", + const maxPosts = simulator.getMaxPosts(); + + // Post messages up to the limit + for (let i = 0; i < maxPosts; i++) { + simulator.post(`Post number ${i + 1}`); + } + + // Verify we have MAX_POSTS posts + expect(simulator.getLedger().posts.size).toEqual(maxPosts); + + // Try to post one more - should fail + expect(() => simulator.post("One post too many")).toThrow( + "failed assert: Maximum number of posts reached", ); - 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"); + + // Verify the count hasn't changed + expect(simulator.getLedger().posts.size).toEqual(maxPosts); }); - it("doesn't let different users post twice", () => { + // ========== Additional Tests ========== + + it("allows posting after taking down a post", () => { 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"); + + // Post and take down + const postId = simulator.post("Temporary message"); + simulator.takeDown(postId); + + // Post again + const message = "New message after deletion"; + const newPostId = simulator.post(message); + + const ledgerState = simulator.getLedger(); + expect(ledgerState.posts.size).toEqual(1); + expect(ledgerState.posts.get(newPostId)!.message).toEqual(message); + // New post ID should be higher than the deleted one + expect(newPostId).toBeGreaterThan(postId); }); - it("doesn't let users take down someone elses posts", () => { + it("throws when trying to take down non-existent post", () => { const simulator = new BBoardSimulator(randomBytes(32)); - simulator.post( - "Sometimes a hypocrite is nothing more than a man in the process of changing.", + + // Try to take down a post that doesn't exist + expect(() => simulator.takeDown(999)).toThrow( + "failed assert: Post does not exist", ); - simulator.switchUser(randomBytes(32)); - expect(() => simulator.takeDown()).toThrow( - "failed assert: Attempted to take down post, but not the current owner", + }); + + it("maintains correct post ownership across multiple users", () => { + const userA = randomBytes(32); + const userB = randomBytes(32); + const simulator = new BBoardSimulator(userA); + + // User A posts + const postIdA = simulator.post("User A's post"); + const ownerA = simulator.publicKey(); + + // Switch to User B and post + simulator.switchUser(userB); + const postIdB = simulator.post("User B's post"); + const ownerB = simulator.publicKey(); + + // Verify ownership in ledger + const ledgerState = simulator.getLedger(); + expect(ledgerState.posts.get(postIdA)!.owner).toEqual(ownerA); + expect(ledgerState.posts.get(postIdB)!.owner).toEqual(ownerB); + + // User B can take down their own post + simulator.takeDown(postIdB); + expect(simulator.getLedger().posts.size).toEqual(1); + + // User B cannot take down User A's post + expect(() => simulator.takeDown(postIdA)).toThrow( + "failed assert: Only the post owner can take down this post", ); }); + + it("allows re-posting after reaching and clearing limit", () => { + const simulator = new BBoardSimulator(randomBytes(32)); + const maxPosts = simulator.getMaxPosts(); + + // Fill the board + const postIds: number[] = []; + for (let i = 0; i < maxPosts; i++) { + postIds.push(simulator.post(`Post ${i}`)); + } + + // Take down one post + simulator.takeDown(postIds[0]); + + // Now we should be able to post again + const newPostId = simulator.post("New post after clearing one slot"); + expect(simulator.getLedger().posts.size).toEqual(maxPosts); + expect(simulator.getLedger().posts.get(newPostId)).toBeDefined(); + }); }); diff --git a/package-lock.json b/package-lock.json index 8124ecb..ecf5b90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "rxjs": "^7.8.2", "semver": "^7.7.4", "testcontainers": "^11.12.0", - "ws": "^8.19.0" + "ws": "^8.20.0" }, "devDependencies": { "@eslint/js": "^9.39.2", @@ -11504,9 +11504,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 67c4704..e20a9a4 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "rxjs": "^7.8.2", "semver": "^7.7.4", "testcontainers": "^11.12.0", - "ws": "^8.19.0" + "ws": "^8.20.0" }, "devDependencies": { "@eslint/js": "^9.39.2",