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. 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 three 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

The counter contract defines only one circuit, increment, so there is a single file in zkir and only one prover-verifier pair in keys.

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 sub-project

All of the interactive logic for the counter DApp's command-line interface is defined in 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 4 'entry-point' files in the directory:

  • devnet-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 the devnet
  • devnet-local.ts - the second way you ran the counter DApp, if you tried the optional step of running your own node and indexer
  • devnet-remote-start-proof-server.ts - the same configuration as devnet-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 the devnet and running entirely locally, for unit testing.

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

const CONFIG = new DevnetRemoteConfig();
CONFIG.setNetworkId();
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 DevnetRemoteConfig implements Config {
privateStateStoreName = 'counter-private-state';
logDir = path.resolve(currentDir, '..', 'logs', 'devnet', `${new Date().toISOString()}.log`);
zkConfigPath = path.resolve(currentDir, '..', '..', 'contract', 'dist', 'managed', 'counter');
indexer = 'https://indexer.devnet.midnight.network:443/api/v1/graphql';
indexerWS = 'wss://indexer.devnet.midnight.network:443/api/v1/graphql/ws';
node = 'https://rpc.devnet.midnight.network:9944';
proofServer = 'http://127.0.0.1:6300';
setNetworkId() {
const theNetworkId = networkId.devnet;
setNetworkId(theNetworkId);
zswap.setNetworkId(toZswapNetworkId(theNetworkId));
runtime.setNetworkId(toRuntimeNetworkId(theNetworkId));
ledger.setNetworkId(toLedgerNetworkId(theNetworkId));
}
}

Notice that the various subsystems are all configured for operation on the devnet. Notice also that this configuration, like the others in the same file, captures the path to the generated outputs from the Compact compiler, so that the Midnight libraries know where to find the keys and intermediate representation for the increment circuit.

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<
StateWithZswap<CounterPrivateState>,
ZSwapWitnesses<StateWithZswap<CounterPrivateState>>
>;

It is not necessary to understand what the ZSwap types are doing at this point to see that any code using CounterContract will be much clearer than code that writes out the fully instantiated form of Contract each time.

Main DApp activities

Now look at counter-cli/src/index.ts, the main file for the counter DApp. 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. Omitting the logging code, this function is as follows:

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

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.

Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(10_000),
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.

Back in the run function, you can see where the counter DApp creates such an object:

const providers = {
privateStateProvider: inMemoryPrivateStateProvider<PrivateStates>(),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider: new NodeZkConfigProvider<'increment'>(config.zkConfigPath),
proofProvider: httpClientProofProvider(config.proofServer),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};

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.tx.serialize()), newCoins)
.then((tx) => wallet.proveTransaction(tx))
.then((zswapTx) => Transaction.deserialize(zswapTx.serialize()))
.then(createBalancedTx);
},
submitTx(tx: BalancedTransaction): Promise<TransactionId> {
return wallet.submitTransaction(tx.tx);
},
};
};

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 index.ts. The relevant call is:

const counterContract = await findDeployedContract(
providers,
contractAddress,
createCounterContract(providers.walletProvider.coinPublicKey),
{
privateStateKey: 'counterPrivateState',
initialPrivateState: {},
},
);

where createCounterContract simply makes a new Contract object, as defined by the Compact compiler:

export const createCounterContract = (coinPublicKey: CoinPublicKey): CounterContract =>
new Contract(withZswapWitnesses(witnesses)(encodeCoinPublicKey(coinPublicKey)));

Using the generated code and the Midnight libraries, creating and submitting an increment transaction become simple function calls:

  const { txHash, blockHeight } = await counterContract.contractCircuitsInterface.increment().then((u) => u.submit());

When the call to submit is complete, it returns the transaction hash and the height of the blockchain at the block where the transaction was included.

Similarly, the round defined as a ledger field in the contract is rendered in JavaScript as a field in a ledger state object:

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 the whole file index.ts and understand the gist of what it is doing in each function.

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.