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):
Name | Data type | Required? | Default |
---|---|---|---|
Indexer URL | String | Yes | N/A |
Indexer WebSocket URL | String | Yes | N/A |
Proving server URL | String | Yes | N/A |
Node URL | String | Yes | N/A |
Seed | String | Yes | N/A |
Network ID | NetworkId | Yes | N/A |
Log level | LogLevel | No | warn |
Discard Transaction History | Boolean | No | false |
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):
Name | Data type | Required? | Default |
---|---|---|---|
Indexer URL | String | Yes | N/A |
Indexer WebSocket URL | String | Yes | N/A |
Proving server URL | String | Yes | N/A |
Node URL | String | Yes | N/A |
Network ID | NetworkId | Yes | N/A |
Log level | LogLevel | No | warn |
Discard Transaction History | Boolean | No | false |
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):
Name | Data type | Required? | Default |
---|---|---|---|
Indexer URL | String | Yes | N/A |
Indexer WebSocket URL | String | Yes | N/A |
Proving server URL | String | Yes | N/A |
Node URL | String | Yes | N/A |
Serialized state | String | Yes | N/A |
Log level | LogLevel | No | warn |
Discard Transaction History | Boolean | No | false |
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:
- Prepare an unproven transaction - outline of transaction to be created
- Compute needed zero-knowledge proofs and convert outline into the final transaction
- Submit the transaction
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:
Name | Data type | Required? |
---|---|---|
amount | BigInt | Yes |
tokenType | TokenType | Yes |
receiverAddress | Address | Yes |
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:
Name | Data type | Required? |
---|---|---|
transaction | Transaction | Yes |
newCoins | CoinInfo[] | No |
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:
Name | Data type | Required? |
---|---|---|
provingRecipe | ProvingRecipe | Yes |
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);
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:
Name | Data type | Required? |
---|---|---|
transaction | Transaction | Yes |
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);
}