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 thecontract
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 contractzkir
- contains the intermediate representations for the circuits defined in the contractkeys
- contains the prover and verifier keys for each circuit defined in the contractcompiler
- 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 anincrement
function. - The
Ledger
type for this contract includes around
field, declared to be a JavaScriptbigint
. - 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 TestNettestnet-local.ts
- the second way you ran the counter DApp, if you tried the optional step of running your own TestNet node and indexertestnet-remote-start-proof-server.ts
- the same configuration astestnet-remote.ts
, but not assuming you already have a proof server running and instead starting one as part of the scriptstandalone.ts
- invokes aDockerComposeEnvironment
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 {
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';
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
- instantiate a wallet,
- instantiate a collection of 'providers' for working with the contract, and
- 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.
- You have installed the tools needed to be a Midnight developer.
- You have learned how to build a Midnight DApp from source and run it.
- You have learned how to run your own Midnight node and pub-sub indexer.
- 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.