Skip to main content

Midnight wallet - developer guide

There are many flavors of wallet software - browser extensions, wallets used in various backend systems, CLI apps, wallets integrated into testing environments, etc. In Midnight, they all need to implement common functionalities, like:

  • deriving keys
  • deriving token balance from chain data
  • managing coins and data needed to generate necessary zero-knowledge proofs
  • managing connection with an indexer instance
  • creating token transfers and balancing transactions (i.e. paying fees for a contract call), including proof generation requests

It all, to implement robustly, is a non-trivial amount of work. For that reason, there are a couple of packages available to help with integration of wallet functionality:

  • @midnight-ntwrk/wallet-api - high-level wallet interfaces, target abstraction to be used by code utilizing wallet functionality (e.g. browser extension implementation);
  • @midnight-ntwrk/wallet - implementation of the @midnight-ntwrk/wallet-api, with some additional tools and helpers
  • @midnight-ntwrk/zswap - reference implementation of building blocks of Zswap
  • @midnight-ntwrk/dapp-connector-api - description of the API meant to be injected by browser extensions to enable DApp connections.

Installing

The Midnight wallet is provided as an NPM package @midnight-ntwrk/wallet. You can install it using any node package manager, including Yarn. To install the package using Yarn, run the following command:

yarn add @midnight-ntwrk/wallet

Important information

The wallet uses the @midnight-ntwrk/zswap library to manage its local state and construct transactions. The serialization format, which ensures transactions are processed correctly depending on the network (eg, TestNet or MainNet) they belong to, relies on the NetworkId provided when building the wallet.


Managing wallet instances

The @midnight-ntwrk/wallet package offers a builder that enables you to create wallets in various ways. Once created and started, each wallet will automatically connect to the specified node for submitting transactions, indexer for state synchronization, and proving server for generation of zero-knowledge proofs.

To create a wallet instance, begin by importing the builder from the @midnight-ntwrk/wallet package:

import { WalletBuilder } from '@midnight-ntwrk/wallet';

Next, the builder can be used to build instances:

  • from seed
  • from state snapshot
  • random, disposable ones

Creating instance from a seed

The wallet builder offers a method that enables you to instantiate a wallet with a specific seed, resulting in obtaining the same address and keys but with a fresh state that is then synchronized with the indexer. The method requires the following parameters (in the exact order):

NameData typeRequired?Default
Indexer URLStringYesN/A
Indexer WebSocket URLStringYesN/A
Proving server URLStringYesN/A
Node URLStringYesN/A
SeedStringYesN/A
Network IDNetworkIdYesN/A
Log levelLogLevelNowarn
Discard Transaction HistoryBooleanNofalse
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { NetworkId } from '@midnight-ntwrk/zswap';

const wallet = await WalletBuilder.buildFromSeed(
'https://indexer.testnet.midnight.network/api/v1/graphql', // Indexer URL
'wss://indexer.testnet.midnight.network/api/v1/graphql', // Indexer WebSocket URL
'http://localhost:6300', // Proving Server URL
'https://rpc.testnet.midnight.network', // Node URL
'0000000000000000000000000000000000000000000000000000000000000000', // Seed
NetworkId.TestNet,
'error' // LogLevel
);

Creating a random, disposable instance

Use the WalletBuilder.build function to create a brand-new wallet instance. There won't be a way to access its private keys, thus - such kind of instance is really only useful for ad-hoc wallets used in testing. This requires the following parameters (in the precise order):

NameData typeRequired?Default
Indexer URLStringYesN/A
Indexer WebSocket URLStringYesN/A
Proving server URLStringYesN/A
Node URLStringYesN/A
Network IDNetworkIdYesN/A
Log levelLogLevelNowarn
Discard Transaction HistoryBooleanNofalse
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { NetworkId } from '@midnight-ntwrk/zswap';

const wallet = await WalletBuilder.build(
'https://indexer.testnet.midnight.network/api/v1/graphql', // Indexer URL
'wss://indexer.testnet.midnight.network/api/v1/graphql', // Indexer WebSocket URL
'http://localhost:6300', // Proving Server URL
'https://rpc.testnet.midnight.network', // Node URL
NetworkId.TestNet, // Network ID
'error' // LogLevel
);

State snapshots

The wallet state can be serialized, allowing it to be stored and later re-instantiated from that serialized checkpoint, so wallet synchronization can restart from that point.

This functionality is especially valuable in scenarios like browser extensions, where it's crucial to swiftly restore the wallet state for the user.

To serialize the state to restore from, use the serialize() method.

The serialized state contains a lot of data, which is meant to be kept private. For that reason it should be stored securely.

To create a wallet instance from the serialized state use method WalletBuilder.restore. This method requires the following parameters (in the precise order):

NameData typeRequired?Default
Indexer URLStringYesN/A
Indexer WebSocket URLStringYesN/A
Proving server URLStringYesN/A
Node URLStringYesN/A
Serialized stateStringYesN/A
Log levelLogLevelNowarn
Discard Transaction HistoryBooleanNofalse

Note that this builder method doesn't provide a network ID parameter, because it is stored in the serialized snapshot.

The example below shows how to prepare a state snapshot and later restore wallet from it:

import { WalletBuilder } from '@midnight-ntwrk/wallet';

const originalWallet = await WalletBuilder.build(/* ... */);

// start the wallet, use it, etc.

const serializedState = await originalWallet.serialize();

const wallet = await WalletBuilder.restore(
'https://indexer.testnet.midnight.network/api/v1/graphql', // Indexer URL
'wss://indexer.testnet.midnight.network/api/v1/graphql', // Indexer WebSocket URL
'http://localhost:6300', // Proving Server URL
'https://rpc.testnet.midnight.network', // Node URL
serializedState,
'error' // LogLevel
);

Wallet instance lifecycle

Creating a wallet does only initialize its internal state.

To begin synchronizing the wallet with the indexer, use the start() method on wallet instance:

 wallet.start();

To gracefully close a wallet instance, use the close() method:

await wallet.close();

Returned promise will resolve after all resources used for synchronization are released.

Accessing the wallet state

The wallet state is provided through an rx.js observable. You can operate on the state value using various operators supported by rx.js. The simplest use is to simply log the state upon each change:

wallet.state().subscribe((state) => {
console.log(state);
});

Working with transactions

Conceptually, preparing a transaction and submitting it to network is done in 3 steps:

  1. Prepare an unproven transaction - outline of transaction to be created
  2. Compute needed zero-knowledge proofs and convert outline into the final transaction
  3. Submit the transaction
note

It is important to ensure that wallet's synchronization progress has reached the tip of the chain before preparing a transaction. Otherwise, there is a very likely possibility of spending a coin, which was already spent, but wallet instance has not learned that fact yet, and in turn - node rejecting the transaction because of detected double spend attempt.

Preparing transaction - making a transfer

The wallet API includes a transferTransaction() method that enables you to construct transactions specifying the token type, amount, and recipient address.

This method requires an array of objects containing the following properties:

NameData typeRequired?
amountBigIntYes
tokenTypeTokenTypeYes
receiverAddressAddressYes

Below, you can see an example of how you can utilize the API:

import { nativeToken } from '@midnight-ntwrk/zswap';

const transferRecipe = await wallet.transferTransaction([
{
amount: 1n,
receiverAddress: '<midnight-wallet-address>',
tokenType: nativeToken() // tDUST token type
}
]);

Preparing transaction - balancing an existing transaction

Balancing an existing transaction is particularly useful, when working with DApps or complementing a swap. It is a process, where transaction is inspected for imbalances between values of provided inputs and outputs, and then a complementary transaction is created, which provides or extracts value (by adding inputs or outputs) needed to pay fees and reduce imbalances. It can be done with balanceTransaction method, which requires the following parameters:

NameData typeRequired?
transactionTransactionYes
newCoinsCoinInfo[]No
note

The newCoins parameter is intended for cases where a new coin is created, such as when a DApp mints one and intends to send it to the wallet. Due to the nature of the Midnight TestNet, these newly created coins must be explicitly provided to the wallet using this method. This allows the wallet to monitor and incorporate them into its state effectively.

const balancedRecipe = await wallet.balanceTransaction(transaction);

Proving a transaction

Once unproven transaction is ready - it can be proven. Wallet does wrap the results of transferTransaction and balanceTransaction into objects called ProvingRecipe - they allow passing the results of these methods directly to proveTransaction method, without conditional logic between to decide what exactly needs to be proven and merged. It takes following parameters:

NameData typeRequired?
provingRecipeProvingRecipeYes

For example, the transferRecipe created above has a following shape and can be proven like below:

import { TRANSACTION_TO_PROVE } from '@midnight-ntwrk/wallet-api';

const recipe = {
type: TRANSACTION_TO_PROVE, // available from the Wallet API
transaction: anUnprovenTransferTransaction // this is a balanced, unproven transaction
};

const provenTransaction = await wallet.proveTransaction(recipe);
note

Computing zero-knowledge proofs is a very computationally heavy operation. For that reason one needs to expect that calls to proveTransaction take tens of seconds. This is also reason why a proving server is needed at this moment - running native code makes the zero-knowledge technology feasible to use in Midnight.

Submitting a transaction

Once final, proven transaction is created, it can be submitted. To submit a transaction, you need to use the submitTransaction method, which requires the following parameters:

NameData typeRequired?
transactionTransactionYes

The transaction must be balanced (value of tokens in inputs needs to be at least equal to value of tokens in output, as well as cover fees) and proven for it to be accepted by the node.

The example below uses the provenTransaction from the section above:

const submittedTransaction = await wallet.submitTransaction(provenTransaction);

Connecting to a DApp

See the DApp connector overview and Wallet Provider for more details. The former is the API expected to be injected by a browser extension wallet, the latter is interface used by Midnight.js to interact with wallet.

Examples

In this section, you'll find examples of how you can fully utilize the wallet APIs.

Transferring tDUST

This example instantiates a new wallet and uses it to transfer one tDUST to another wallet:

import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { NetworkId, nativeToken } from '@midnight-ntwrk/zswap';

try {
const wallet = await WalletBuilder.build(
'https://indexer.testnet.midnight.network/api/v1/graphql',
'wss://indexer.testnet.midnight.network/api/v1/graphql',
'http://localhost:6300',
'https://rpc.testnet.midnight.network',
NetworkId.TestNet
);

wallet.start();

const transferRecipe = await wallet.transferTransaction([
{
amount: 1n,
tokenType: nativeToken(), // tDUST token type
receiverAddress: '2f646b14cbcbfc43ccdae6379891c2b01e9731d1e4c1e0c1b71c04b7948a3e0e|010001f38d17a48161d6248ee10a799dca0799eecbd8f1f20bbeb4eb2645656c104cde'
}
]);

const provenTransaction = await wallet.proveTransaction(transferRecipe);

const submittedTransaction = await wallet.submitTransaction(provenTransaction);

console.log('Transaction submitted', submittedTransaction);
} catch (error) {
console.log('An error occurred', error);
}