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
295 changes: 59 additions & 236 deletions README.md

Large diffs are not rendered by default.

86 changes: 8 additions & 78 deletions api/src/common-types.ts
Original file line number Diff line number Diff line change
@@ -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<BBoardPrivateState, Witnesses<BBoardPrivateState>>;

/**
* The keys of the circuits exported from {@link BBoardContract}.
*
* @public
*/
export type BBoardCircuitKeys = Exclude<keyof BBoardContract['impureCircuits'], number | symbol>;

/**
* The providers required by {@link BBoardContract}.
*
* @public
*/
export type BBoardProviders = MidnightProviders<BBoardCircuitKeys, PrivateStateId, BBoardPrivateState>;

/**
* A {@link BBoardContract} that has been deployed to the network.
*
* @public
*/
export type DeployedBBoardContract = FoundContract<BBoardContract>;

/**
* 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 ?
200 changes: 47 additions & 153 deletions api/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<BBoardDerivedState>;

post: (message: string) => Promise<void>;
takeDown: () => Promise<void>;
enroll: (dateStr: string) => Promise<void>;
checkIn: (dateStr: string) => Promise<void>;
verifyMilestone: (threshold: bigint) => Promise<void>;
revokeEnrollment: () => Promise<void>;
pauseContract: () => Promise<void>;
resumeContract: () => Promise<void>;
}

/**
* 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,
Expand All @@ -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<BBoardPrivateState>),
],
// ...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<BBoardDerivedState>;

/**
* 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<void> {
this.logger?.info(`postingMessage: ${message}`);
async enroll(dateStr: string): Promise<void> {
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<void> {
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<void> {
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<void> {
this.logger?.info('takingDownMessage');
async revokeEnrollment(): Promise<void> {
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<void> {
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<void> {
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<BBoardAPI> {
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<BBoardAPI> {
logger?.info({
joinContract: {
contractAddress,
},
});

logger?.info({ joinContract: { contractAddress } });
const deployedBBoardContract = await findDeployedContract<BBoardContract>(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<BBoardPrivateState> {
private static async getPrivateState(providers: BBoardProviders, contractAddress: ContractAddress): Promise<BBoardPrivateState> {
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';
Loading