Skip to main content

In detail: the counter DApp

The counter DApp is written in TypeScript. This page of the tutorial is intended to serve as a guide to help you find your way around the code, not reviewing every line, but looking at the 'big pieces' and the most interesting parts. You can follow along in your own copy of the code.

Project structure

You may find it useful to split DApps into several sub-projects. This is especially true for larger, more complex DApps with web-based user interfaces, where it is desirable to test the core logic independently from the user experience. Even for a DApp as simple as the counter, however, you will see that the code is split into two sub-projects:

  • contract - This sub-project contains the contract itself, plus supporting code to define the implementation of the local private state and code to test the contract's logic.
  • counter-cli - This sub-project defines the command-line interface of the DApp. It depends on the outputs of the contract sub-project.

You will find project definition and configuration files in each sub-project's top level directory, while the source code is in its src subdirectory.

The contract sub-project

The preceding page already gave you a tour of the contract itself. Because the contract is so simple, there is very little additional code to be written.

Witness implementation

Look in the file contract/src/witnesses.ts. As the preceding page discussed, the counter contract defines no private state and therefore requires no witness functions to be defined. In the TypeScript representation of contracts, however, every contract is parameterized over its private state, so there remains a need to define some private state object for the contract. In this case, we use the type Record<string, never> to represent the type of an empty object. The definitions in counter's witnesses.ts therefore provide the minimal (empty) implementations of witnesses possible.

export type CounterPrivateState = Record<string, never>;

export const witnesses = {};

Part 3 of the Midnight tutorial introduces an example that follows the same structure, but with more interesting contents in its witnesses.ts.

Generated source

The file contract/src/index.ts merely re-exports the definitions from the other files in the contract sub-project. Which definitions? The empty definitions of the witnesses, of course, but also the definitions generated by the Compact compiler.

If you are following the steps of this tutorial in order, then you have already built the counter project. That means you have already run the Compact compiler on counter.compact.

Look in the managed/counter subdirectory of the contract sub-project. In it, you see four directories:

  • contract - contains files defining the TypeScript/JavaScript API for the contract
  • zkir - contains the intermediate representations for the circuits defined in the contract
  • keys - contains the prover and verifier keys for each circuit defined in the contract
  • compiler - contains metadata about the contract, including the names of exported circuits and their argument and return types in JSON format

The counter contract defines only one circuit, increment, so there is a single pair of prover and verifier key for increment in keys. The zkir directory contains two files, increment.bzkir and increment.zkir. Respectively, these files are the binary and JSON representations of the ZKIR of the increment circuit.

Most of this generated content is not useful to examine, but you might want to look at managed/counter/contract/index.d.ts. You will see that it defines various types and functions that correspond directly to the code in counter.compact. For example:

  • The Circuits type for this contract includes an increment function.
  • The Ledger type for this contract includes a round field, declared to be a JavaScript bigint.
  • The Witnesses type for this contract is empty, because the contract declares no witnesses.

Notice that many of the types are parameterized over some type T, representing the DApp's private state implementation, which is opaque to the circuits and ledger types emitted by the compiler.

Tests

Part 3 of the Midnight tutorial explores unit testing, so it will not be discussed here, but you are free to examine the code in the contract/src/test subdirectory. It defines just a few Jest tests to exercise the contract in a simulation environment.

The counter-cli subproject

All the interactive logic for the counter DApp's command-line interface is exposed through counter-cli/src/index.ts. Before examining that file, though, it may be useful to see how the whole DApp is launched.

Different configurations

There are four 'entry-point' files in the directory:

  • testnet-remote.ts - the first way you ran the counter DApp, so that it uses a local proof server, but a remote Midnight node and a remote pub-sub indexer, both connected to TestNet
  • testnet-local.ts - the second way you ran the counter DApp, if you tried the optional step of running your own TestNet node and indexer
  • testnet-remote-start-proof-server.ts - the same configuration as testnet-remote.ts, but not assuming you already have a proof server running and instead starting one as part of the script
  • standalone.ts - invokes a DockerComposeEnvironment that launches a proof server, Midnight node, and pub-sub indexer, all disconnected from TestNet and running entirely locally, for unit testing.

If you look in test-remote.ts, you will see that it is very short:

const config = new TestnetRemoteConfig();
const logger = await createLogger(config.logDir);
await run(config, logger);

and the other entry points are similar, because they rely on the configurations defined in config.ts. Each of those configurations is quite similar. For example, here is the one that says to connect to the Midnight-hosted instances of a Midnight node and an indexer:

export class TestnetRemoteConfig implements Config {
<<<<<<< HEAD
privateStateStoreName = 'counter-private-state';
logDir = path.resolve(currentDir, '..', 'logs', 'testnet', `${new Date().toISOString()}.log`);
zkConfigPath = path.resolve(currentDir, '..', '..', 'contract', 'dist', 'managed', 'counter');
indexer = 'https://indexer.testnet.midnight.network:443/api/v1/graphql';
indexerWS = 'wss://indexer.testnet.midnight.network:443/api/v1/graphql/ws';
node = 'https://rpc.testnet.midnight.network:9944';
=======
logDir = path.resolve(currentDir, '..', 'logs', 'testnet-remote', `${new Date().toISOString()}.log`);
indexer = 'https://indexer.testnet.midnight.network/api/v1/graphql';
indexerWS = 'wss://indexer.testnet.midnight.network/api/v1/graphql/ws';
node = 'https://rpc.testnet.midnight.network';
>>>>>>> origin/PM-9833-testnet
proofServer = 'http://127.0.0.1:6300';
constructor() {
setNetworkId(NetworkId.TestNet);
}
}

The various subsystems are all configured for operation on Midnight TestNet.

Notice the setNetworkId call in the TestnetRemoteConfig constructor. Many Midnight APIs and packages require the API consumer to explicitly specify the network they are targeting via a network ID parameter. Providing such a parameter to each API would be tedious and error-prone. Midnight.js provides the setNetworkId function so that the user can specify the network they are targeting once and have the correct network ID used everywhere.

Generated types, instantiated

Recall that many of the types, such as Contract, in the source files generated by the Compact compiler, are parameterized. Writing out the instantiated forms of these types can become unwieldy, especially when the type arguments will be the same, over and over. Thus, the Midnight team often creates a short file defining instantiated versions of some of these generated types and uses those instantiated versions in the rest of the code.

You can see an example of this pattern in the file common-types.ts, which defines types such as CounterContract, like this:

export type CounterContract = Contract<CounterPrivateState, Witnesses<CounterPrivateState>>;

The Dapp

The two main files implementing our application are counter-cli/src/cli.ts and counter-cli/src/api.ts. The former contains the main run loop of the application. The latter contains convenience functions for interacting with the Midnight network.

To understand counter-cli/src/cli.ts, start at the end, looking at the run function. Ignoring the startup and error-handling code, the core actions of the DApp are to

  1. instantiate a wallet,
  2. instantiate a collection of 'providers' for working with the contract, and
  3. start the main user interaction loop.

Creating a wallet

If this DApp were running in a web browser, it would begin by connecting to the browser's Midnight Lace wallet extension. Instead, the counter DApp is running outside any web browser, so it initializes a 'headless' wallet to be used for funding transactions.

Depending on the user's input in the interactive buildWallet function, the DApp either prompts for a wallet seed or generates some random bytes to serve as the seed. Either way, the flow eventually reaches buildWalletAndWaitForFunds in counter-cli/src/api.ts. Omitting the logging code, this function is as follows:

export const buildWalletAndWaitForFunds = async (
{ indexer, indexerWS, node, proofServer }: Config,
seed: string,
): Promise<Wallet & Resource> => {
const wallet = await WalletBuilder.buildFromSeed(
indexer,
indexerWS,
proofServer,
node,
seed,
getZswapNetworkId(),
'warn',
);
wallet.start();
const state = await Rx.firstValueFrom(wallet.state());
let balance = state.balances[nativeToken()];
if (balance === undefined || balance === 0n) {
balance = await waitForFunds(wallet);
}
return wallet;
};

The WalletBuilder class provides the main entry point into creating a wallet: buildFromSeed. The wallet needs to know about the indexer, the node, and the proof server, because it watches the ledger for information about funds and transaction results (using the indexer), submits transactions through the node, and calls on the proof server to generate proofs that transactions are valid.

Notice the getZswapNetworkId argument to WalletBuilder.buildFromSeed. The getZswapNetworkId function is provided by Midnight.js. It retrieves the current network ID (set by the setNetworkId call we have already seen) and converts it to a format the wallet can understand.

After the wallet is started, the DApp pauses to wait for funds to appear. You may find it instructive to look at the definition of waitForFunds just above buildWalletAndWaitForFunds in the file. The wallet's state appears in TypeScript as an observable stream, so waitForFunds uses RxJS functions to watch for the wallet's balance to be non-zero.

export const waitForFunds = (wallet: Wallet) =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(10_000),
Rx.tap((state) => {
const scanned = state.syncProgress?.synced ?? 0n;
const total = state.syncProgress?.total.toString() ?? 'unknown number';
}),
Rx.filter((state) => {
// Let's allow progress only if wallet is close enough
const synced = state.syncProgress?.synced ?? 0n;
const total = state.syncProgress?.total ?? 1_000n;
return total - synced < 100n;
}),
Rx.map((s) => s.balances[nativeToken()] ?? 0n),
Rx.filter((balance) => balance > 0n),
),
);

Notice that the wallet's state includes information about the degree to which it is synchronized with the total state of the blockchain.

Hypothetically, the wallet could track the balances of many different tokens, so the code in waitForFunds asks specifically for the nativeToken balance, meaning tDUST. Once the tDUST balance is non-zero, the waitForFunds function returns, and the buildWalletAndWaitForFunds function returns the wallet itself.

Providers

The Midnight programmatic interface to a smart contract is extremely flexible and thus highly parameterized. For example, a DApp developer might implement a new kind of private state storage or interpose some kind of balance-checking functionality between the contract and a wallet. The way that the Midnight libraries handle all this functional parameterization is through a JavaScript object with fields for different types of providers.

Of course, most of the time, a standard implementation for each type of provider is what you want, and the Midnight libraries define these for you. All you have to specify is some information about where to find the indexer, the proof server, and so on.

The counter-cli/src/api.ts file defines a function configureProviders that returns an object containing the providers our application will need to interact with the network.

export const configureProviders = async (wallet: Wallet & Resource, config: Config) => {
const walletAndMidnightProvider = await createWalletAndMidnightProvider(wallet);
return {
privateStateProvider: levelPrivateStateProvider<PrivateStates>({
privateStateStoreName: contractConfig.privateStateStoreName,
}),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider: new NodeZkConfigProvider<'increment'>(contractConfig.zkConfigPath),
proofProvider: httpClientProofProvider(config.proofServer),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};
};

Back in the run function, you can see where the counter DApp creates the providers.

const providers = await api.configureProviders(wallet, config);

The exception to the pattern of using standard implementations for the providers is seen in the value provided for the walletProvider and midnightProvider fields.

  • The wallet provider specifies the wallet's public key and defines a function for balancing transactions (that is, attaching to the transaction an appropriate amount of 'fuel' to run the transaction): balanceTx.
  • The Midnight provider defines a function for submitting transactions to the Midnight network: submitTx.

The counter DApp defines a single object to satisfy both provider interfaces:

const createWalletAndMidnightProvider = async (wallet: Wallet): Promise<WalletProvider & MidnightProvider> => {
const state = await Rx.firstValueFrom(wallet.state());
return {
coinPublicKey: state.coinPublicKey,
balanceTx(tx: UnbalancedTransaction, newCoins: CoinInfo[]): Promise<BalancedTransaction> {
return wallet
.balanceTransaction(
ZswapTransaction.deserialize(tx.serialize(getLedgerNetworkId()), getZswapNetworkId()),
newCoins,
)
.then((tx) => wallet.proveTransaction(tx))
.then((zswapTx) => Transaction.deserialize(zswapTx.serialize(getZswapNetworkId()), getLedgerNetworkId()))
.then(createBalancedTx);
},
submitTx(tx: BalancedTransaction): Promise<TransactionId> {
return wallet.submitTransaction(
ZswapTransaction.deserialize(tx.serialize(getLedgerNetworkId()), getZswapNetworkId()),
);
},
};
};

You can read more about what happens when a transaction is balanced and submitted to the network in the portion of the Midnight developer documentation that describes how Midnight works.

Working with a contract

The bulk of the counter DApp uses input from the user to direct its actions, which include:

  • deploying a new counter contract
  • joining an existing counter contract
  • calling the increment circuit on the contract
  • examining the ledger state associated with the contract.

With the wallet and providers objects in hand, these actions are performed by calling Midnight library functions. For example, the call to find and join an existing contract can be seen in the join function, near the top of `counter-cli/src/api.ts. The relevant call is:

const counterContract = await findDeployedContract(providers, {
contractAddress,
contract: counterContractInstance,
privateStateKey: 'counterPrivateState',
initialPrivateState: {},
});

where counterContractInstance is simply a Contract object defined in the index.ts file in the generated code.

export const counterContractInstance: CounterContract = new Contract(witnesses);

Submitting a Transaction

Using the generated code and the Midnight.js library, creating and submitting an increment transaction becomes a simple function call:

 const tx = await counterContract.callTx.increment();

The callTx access on the counterContract object indicates that we want to create a call transaction for the counter contract. The increment() call creates and submits a call transaction for the increment circuit. After the transaction is submitted and included in the blockchain, tx contains additional information about the transaction. For example,

 const { txHash, blockHeight } = tx.public;

gets the hash of the transaction that was submitted and the height of the blockchain when the transaction was added. The public property contains all public information about the transaction that was submitted. That is, data anyone can already view. Transactions for contracts with private state may contain sensitive data. To access private data, there is a corresponding private property.

Viewing Contract State

The publicDataProvider in the providers object can be used to query for information about the blockchain. To view the current value of round defined in the counter contract, we can query the publicDataProvider and convert the result to a JavaScript object using the ledger function generated for the counter contract by the compiler.

export const getCounterLedgerState = (
providers: CounterProviders,
contractAddress: ContractAddress,
): Promise<bigint | null> =>
providers.publicDataProvider
.queryContractState(contractAddress)
.then((contractState) => (contractState != null ? ledger(contractState.data).round : null));

At this point, you have seen enough to be able to read and understand the gist of the counter-cli/src/api.ts and counter-cli/src/cli.ts files.

Summary

This ends part 2 of the Midnight developer tutorial.

  1. You have installed the tools needed to be a Midnight developer.
  2. You have learned how to build a Midnight DApp from source and run it.
  3. You have learned how to run your own Midnight node and pub-sub indexer.
  4. You have seen the Compact and TypeScript code for a simple DApp.

Part 3 of the tutorial invites you to engage more deeply with the process of creating a Midnight DApp, writing some of the code yourself. It also introduces you to Midnight's ability to shield private data.