Polygon Miden
A rollup for high-throughput, private applications.
Using Polygon Miden, builders can create novel, high-throughput, private applications for payments, DeFi, digital assets, and gaming. Applications and users are secured by Ethereum and AggLayer.
If you want to join the technical discussion, please check out the following:
Info
- These docs are still work-in-progress.
- Some topics have been discussed in greater depth, while others require additional clarification.
Status and features
Polygon Miden is currently on release v0.7. This is an early version of the protocol and its components.
Important We expect breaking changes on all components.
At the time of writing, Polygon Miden doesn't offer all the features you may expect from a zkRollup. During 2024, we expect to gradually implement more features.
Feature highlights
Private accounts
The Miden operator only tracks a commitment to account data in the public database. Users can only execute smart contracts when they know the interface.
Private notes
Like private accounts, the Miden operator only tracks a commitment to notes in the public database. Users need to communicate note details to each other off-chain (via a side channel) in order to consume private notes in transactions.
Public accounts
Polygon Miden supports public smart contracts like Ethereum. The code and state of those accounts are visible to the network and anyone can execute transactions against them.
Public notes
As with public accounts, public notes are also supported. That means, the Miden operator publicly stores note data. Note consumption is not private.
Local transaction execution
The Miden client allows for local transaction execution and proving. The Miden operator verifies the proof and, if valid, updates the state DBs with the new data.
Simple smart contracts
Currently, there are three different smart contracts available. A basic wallet smart contract that sends and receives assets, and fungible and non-fungible faucets to mint and burn assets.
All accounts are written in MASM.
P2ID, P2IDR, and SWAP note scripts
Currently, there are three different note scripts available. Two different versions of pay-to-id scripts of which P2IDR is reclaimable, and a swap script that allows for simple token swaps.
Simple block building
The Miden operator running the Miden node builds the blocks containing transactions.
Maintaining state
The Miden node stores all necessary information in its state DBs and provides this information via its RPC endpoints.
Planned features
Warning The following features are at a planning stage only.
Customized smart contracts
Accounts can expose any interface in the future. This is the Miden version of a smart contract. Account code can be arbitrarily complex due to the underlying Turing-complete Miden VM.
Customized note scripts
Users will be able to write their own note scripts using the Miden client. Note scripts are executed during note consumption and they can be arbitrarily complex due to the underlying Turing-complete Miden VM.
Network transactions
Transaction execution and proving can be outsourced to the network and to the Miden operator. Those transactions will be necessary when it comes to public shared state, and they can be useful if the user's device is not powerful enough to prove transactions efficiently.
Rust compiler
In order to write account code, note or transaction scripts, in Rust, there will be a Rust -> Miden Assembly compiler.
Block and epoch proofs
The Miden node will recursively verify transactions and in doing so build batches of transactions, blocks, and epochs.
Benefits of Polygon Miden
- Ethereum security.
- Developers can build applications that are infeasible on other systems. For example:
- on-chain order book exchange due to parallel transaction execution and updatable transactions.
- complex, incomplete information games due to client-side proving and cheap complex computations.
- safe wallets due to hidden account state.
- Better privacy properties than on Ethereum - first web2 privacy, later even stronger self-sovereignty.
- Transactions can be recalled and updated.
- Lower fees due to client-side proving.
- dApps on Miden are safe to use due to account abstraction and compile-time safe Rust smart contracts.
License
Licensed under the MIT license.
This section shows you how to get started with Miden by generating a new Miden account, requesting funds from a public faucet, consuming private notes, and creating public pay-to-id-notes.
By the end of this tutorial, you will have:
- Configured the Miden client.
- Connected to a Miden node.
- Created an account and requested funds from the faucet.
- Transferred assets between accounts by creating and consuming notes.
Prerequisites
Rust
Download from the Rust website.
In this section, we show you how to create a new local Miden account and how to receive funds from the public Miden faucet website.
Configure the Miden client
The Miden client facilitates interaction with the Miden rollup and provides a way to execute and prove transactions.
Tip Check the Miden client documentation for more information.
-
If you haven't already done so as part of another tutorial, open your terminal and create a new directory to store the Miden client.
mkdir miden-client cd miden-client
-
Install the Miden client.
cargo install miden-cli --features concurrent
You can now use the
miden --version
command, and you should seeMiden 0.7.0
. -
Initialize the client. This creates the
miden-client.toml
file.miden init --network testnet # Creates a miden-client.toml configured with the testnet node's IP
Create a new Miden account
-
Create a new account of type
mutable
using the following command:miden new-wallet --mutable
-
List all created accounts by running the following command:
miden account -l
You should see something like this:
Save the account ID for a future step.
Request tokens from the public faucet
-
To request funds from the faucet navigate to the following website: Miden faucet website.
-
Copy the Account ID printed by the
miden account -l
command in the previous step. Feel free to change the amount of tokens to issue. -
Paste this ID into the Request test tokens input field on the faucet website and click Send Private Note.
Tip You can also click Send Public Note. If you do this, the note's details will be public and you will not need to download and import it, so you can skip to Sync the client.
-
After a few seconds your browser should download - or prompt you to download - a file called
note.mno
(mno = Miden note). It contains the funds the faucet sent to your address. -
Save this file on your computer, you will need it for the next step.
Import the note into the Miden client
-
Import the private note that you have received using the following commands:
miden import <path-to-note>/note.mno
-
You should see something like this:
Successfully imported note 0x0ff340133840d35e95e0dc2e62c88ed75ab2e383dc6673ce0341bd486fed8cb6
-
Now that the note has been successfully imported, you can view the note's information using the following command:
miden notes
-
You should see something like this:
Tip: The importance of syncing
- As you can see, the note is listed as
Expected
.- This is because you have received a private note but have not yet synced your view of the rollup to check that the note is the result of a valid transaction.
- Hence, before consuming the note we will need to update our view of the rollup by syncing.
- Many users could have received the same private note, but only one user can consume the note in a transaction that gets verified by the Miden operator.
Sync the client
Do this periodically to keep informed about any updates on the node by running the sync
command:
miden sync
You will see something like this as output:
State synced to block 179672
New public notes: 0
Tracked notes updated: 1
Tracked notes consumed: 0
Tracked accounts updated: 0
Commited transactions: 0
Consume the note & receive the funds
-
Now that we have synced the client, the input-note imported from the faucet should have a
Committed
status, confirming it exists at the rollup level:miden notes
-
You should see something like this:
-
Find your account and note id by listing both
accounts
andnotes
:miden account miden notes
-
Consume the note and add the funds from its vault to our account using the following command:
miden consume-notes --account <Account-Id> <Note-Id>
-
You should see a confirmation message like this:
-
After confirming you can view the new note status by running the following command:
miden notes
-
You should see something like this:
-
The note is
Processing
. This means that the proof of the transaction was sent, but there is no network confirmation yet. You can update your view of the rollup by syncing again:miden sync
-
After syncing, you should have received confirmation of the consumed note. You should see the note as
Consumed
after listing the notes:miden notes
Amazing! You just have created a client-side zero-knowledge proof locally on your machine and submitted it to the Miden rollup.
Tip You only need to copy the top line of characters of the Note ID.
View confirmations
-
View your updated account's vault containing the tokens sent by the faucet by running the following command:
miden account --show <Account-Id>
-
You should now see your accounts vault containing the funds sent by the faucet.
Congratulations!
You have successfully configured and used the Miden client to interact with a Miden rollup and faucet.
You have performed basic Miden rollup operations like submitting proofs of transactions, generating and consuming notes.
For more information on the Miden client, refer to the Miden client documentation.
Debugging tips (clear state and folder)
-
Need a fresh start? All state is maintained in
store.sqlite3
, located in the directory defined in themiden-client.toml
file. If you want to clear all state, delete this file. It recreates on any command execution. -
Getting an error? Only execute the
miden-client
command in the folder where yourmiden-client.toml
is located.
In this section, we show you how to make private transactions and send funds to another account using the Miden client.
Important: Prerequisite steps
- You should have already followed the prerequisite steps and get started documents.
- You should not have reset the state of your local client.
Create a second account
Tip Remember to use the Miden client documentation for clarifications.
-
Create a second account to send funds with. Previously, we created a type
mutable
account (account A). Now, create anothermutable
(account B) using the following command:miden new-wallet --mutable
-
List and view the newly created accounts with the following command:
miden account -l
-
You should see two accounts:
Transfer assets between accounts
-
Now we can transfer some of the tokens we received from the faucet to our second account B.
To do this, run:
miden send --sender <regular-account-id-A> --target <regular-account-id-B> --asset 50::<faucet-account-id> --note-type private
Note The faucet account id can be found on the Miden faucet website under the title Miden faucet.
This generates a private Pay-to-ID (
P2ID
) note containing50
assets, transferred from one account to the other. -
First, sync the accounts.
miden sync
-
Get the second note id.
miden notes
-
Have the second account consume the note.
miden consume-notes --account <regular-account-ID-B> <input-note-id>
Tip It's possible to use a short version of the note id: 7 characters after the
0x
is sufficient, e.g.0x6ae613a
.You should now see both accounts containing faucet assets with amounts transferred from
Account A
toAccount B
. -
Check the second account:
miden account --show <regular-account-ID-B>
-
Check the original account:
miden account --show <regular-account-ID-A>
Wanna do more? Sending public notes
Congratulations!
You have successfully configured and used the Miden client to interact with a Miden rollup and faucet.
You have performed basic Miden rollup operations like submitting proofs of transactions, generating and consuming notes.
For more information on the Miden client, refer to the Miden client documentation.
Clear data
All state is maintained in store.sqlite3
, located in the directory defined in the miden-client.toml
file.
To clear all state, delete this file. It recreates on any command execution.
In this section, we show you how to execute transactions and send funds to another account using the Miden client and public notes.
Important: Prerequisite steps
- You should have already followed the prerequisite steps and get started documents.
- You should have not reset the state of your local client.
Create a second client
Tip Remember to use the Miden client documentation for clarifications.
This is an alternative to the private P2P transactions process.
In this tutorial, we use two different clients to simulate two different remote users who don't share local state.
To do this, we use two terminals with their own state (using their own miden-client.toml
).
-
Create a new directory to store the new client.
mkdir miden-client-2 cd miden-client-2
-
Initialize the client. This creates the
miden-client.toml
file line-by-line.miden init --network testnet # Creates a miden-client.toml file configured with the testnet node's IP
-
On the new client, create a new basic account:
miden new-wallet --mutable -s public
We refer to this account as Account C. Note that we set the account's storage mode to
public
, which means that the account details are public and its latest state can be retrieved from the node. -
List and view the account with the following command:
miden account -l
Transfer assets between accounts
-
Now we can transfer some of the tokens we received from the faucet to our new account C. Remember to switch back to
miden-client
directory, since you'll be making the txn from Account ID A.To do this, from the first client run:
miden send --sender <basic-account-id-A> --target <basic-account-id-C> --asset 50::<faucet-account-id> --note-type public
Note The faucet account id is
0xad904b3138d71d3e
and can also be found on the Miden faucet website under the title Miden faucet.This generates a Pay-to-ID (
P2ID
) note containing50
tokens, transferred from one account to the other. As the note is public, the second account can receive the necessary details by syncing with the node. -
First, sync the account on the new client.
miden sync
-
At this point, we should have received the public note details.
miden notes --list
Because the note was retrieved from the node, the commit height will be included and displayed.
-
Have account C consume the note.
miden consume-notes --account <regular-account-ID-C> <input-note-id>
Tip It's possible to use a short version of the note id: 7 characters after the
0x
is sufficient, e.g.0x6ae613a
.
That's it!
Account C has now consumed the note and there should be new assets in the account:
miden account --show <account-ID>
Clear state
All state is maintained in store.sqlite3
, located in the directory defined in the miden-client.toml
file.
To clear all state, delete this file. It recreates on any command execution.
Welcome to MkDocs
For full documentation visit mkdocs.org.
Commands
mkdocs new [dir-name]
- Create a new project.mkdocs serve
- Start the live-reloading docs server.mkdocs build
- Build the documentation site.mkdocs -h
- Print help message and exit.
Project layout
mkdocs.yml # The configuration file.
docs/
index.md # The documentation homepage.
... # Other markdown pages, images and other files.
Overview
Components
The Miden client currently has two main components:
Miden client library
The Miden client library is a Rust library that can be integrated into projects, allowing developers to interact with the Miden rollup.
The library provides a set of APIs and functions for executing transactions, generating proofs, and managing activity on the Miden network.
Miden client CLI
The Miden client also includes a command-line interface (CLI) that serves as a wrapper around the library, exposing its basic functionality in a user-friendly manner.
The CLI provides commands for interacting with the Miden rollup, such as submitting transactions, syncing with the network, and managing account data.
Software prerequisites
- Rust installation minimum version 1.82.
Install the client
We currently recommend installing and running the client with the concurrent
feature.
Run the following command to install the miden-client:
cargo install miden-cli --features concurrent
This installs the miden
binary (at ~/.cargo/bin/miden
) with the concurrent
feature.
Concurrent
feature
The concurrent
flag enables optimizations that result in faster transaction execution and proving times.
Run the client
-
Make sure you have already installed the client. If you don't have a
miden-client.toml
file in your directory, create one or runmiden init
to initialize one at the current working directory. You can do so without any arguments to use its defaults or define either the RPC endpoint or the store config via--network
and--store-path
-
Run the client CLI using:
miden
The Miden client offers a range of functionality for interacting with the Miden rollup.
Transaction execution
The Miden client facilitates the execution of transactions on the Miden rollup; allowing users to transfer assets, mint new tokens, and perform various other operations.
Proof generation
The Miden rollup supports user-generated proofs which are key to ensuring the validity of transactions on the Miden rollup.
To enable such proofs, the client contains the functionality for executing, proving, and submitting transactions.
Miden network interactivity
The Miden client enables users to interact with the Miden network. This includes syncing with the latest blockchain data and managing account information.
Account generation and tracking
The Miden client provides features for generating and tracking accounts within the Miden rollup ecosystem. Users can create accounts and track their transaction status.
The Miden client has the following architectural components:
Important "Customizable"
- The RPC client and the store are Rust traits.
- This allow developers and users to easily customize their implementations.
Store
The store is central to the client's design.
It manages the persistence of the following entities:
- Accounts; including their state history and related information such as vault assets and account code.
- Transactions and their scripts.
- Notes.
- Note tags.
- Block headers and chain information that the client needs to execute transactions and consume notes.
Because Miden allows off-chain executing and proving, the client needs to know about the state of the blockchain at the moment of execution. To avoid state bloat, however, the client does not need to see the whole blockchain history, just the chain history intervals that are relevant to the user.
The store can track any number of accounts, and any number of notes that those accounts might have created or may want to consume.
RPC client
The RPC client communicates with the node through a defined set of gRPC methods.
Currently, these include:
GetBlockHeaderByNumber
: Returns the block header information given a specific block number.SyncState
: Asks the node for information relevant to the client. For example, specific account changes, whether relevant notes have been created or consumed, etc.SubmitProvenTransaction
: Sends a locally-proved transaction to the node for inclusion in the blockchain.
Transaction executor
The transaction executor executes transactions using the Miden VM.
When executing, the executor needs access to relevant blockchain history. The executor uses a DataStore
interface for accessing this data. This means that there may be some coupling between the executor and the store.
To use the Miden client library in a Rust project, include it as a dependency.
In your project's Cargo.toml
, add:
miden-client = { version = "0.7" }
Features
The Miden client library supports the concurrent
feature which is recommended for developing applications with the client. To use it, add the following to your project's Cargo.toml
:
miden-client = { version = "0.7", features = ["concurrent"] }
The library also supports several other features. Please refer to the crate's documentation to learn more.
Client instantiation
Spin up a client using the following Rust code and supplying a store and RPC endpoint.
#![allow(unused)] fn main() { let sqlite_store = SqliteStore::new("path/to/store".try_into()?).await?; let store = Arc::new(sqlite_store); // Generate a random seed for the RpoRandomCoin. let mut rng = rand::thread_rng(); let coin_seed: [u64; 4] = rng.gen(); // Initialize the random coin using the generated seed. let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // Create a store authenticator with the store and random coin. let authenticator = StoreAuthenticator::new_with_rng(store.clone(), rng); // Instantiate the client using a Tonic RPC client let endpoint = Endpoint::new("https".into(), "localhost".into(), Some(57291)); let client: Client<RpoRandomCoin> = Client::new( Box::new(TonicRpcClient::new(endpoint, 10_000)), rng, store, Arc::new(authenticator), false, // Set to true for debug mode, if needed. ); }
Create local account
With the Miden client, you can create and track any number of public and local accounts. For local accounts, the state is tracked locally, and the rollup only keeps commitments to the data, which in turn guarantees privacy.
The AccountBuilder
can be used to create a new account with the specified parameters and components. The following code creates a new local account:
#![allow(unused)] fn main() { let key_pair = SecretKey::with_rng(client.rng()); let anchor_block = client.get_latest_epoch_block().await.unwrap(); let (new_account, seed) = AccountBuilder::new(init_seed) // Seed should be random for each account .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::RegularAccountImmutableCode) .storage_mode(AccountStorageMode::Private) .with_component(RpoFalcon512::new(key_pair.public_key())) .with_component(BasicWallet) .build()?; client.add_account(&new_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair), false).await?; }
Once an account is created, it is kept locally and its state is automatically tracked by the client.
To create an public account, you can specify AccountStorageMode::Public
like so:
let key_pair = SecretKey::with_rng(client.rng());
let anchor_block = client.get_latest_epoch_block().await.unwrap();
let (new_account, seed) = AccountBuilder::new(init_seed) // Seed should be random for each account
.anchor((&anchor_block).try_into().unwrap())
.account_type(AccountType::RegularAccountImmutableCode)
.storage_mode(AccountStorageMode::Public)
.with_component(RpoFalcon512::new(key_pair.public_key()))
.with_component(BasicWallet)
.build()?;
client.add_account(&new_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair), false).await?;
The account's state is also tracked locally, but during sync the client updates the account state by querying the node for the most recent account data.
Execute transaction
In order to execute a transaction, you first need to define which type of transaction is to be executed. This may be done with the TransactionRequest
which represents a general definition of a transaction. Some standardized constructors are available for common transaction types.
Here is an example for a pay-to-id
transaction type:
#![allow(unused)] fn main() { // Define asset let faucet_id = AccountId::from_hex(faucet_id)?; let fungible_asset = FungibleAsset::new(faucet_id, *amount)?.into(); let sender_account_id = AccountId::from_hex(bob_account_id)?; let target_account_id = AccountId::from_hex(alice_account_id)?; let payment_transaction = PaymentTransactionData::new( vec![fungible_asset.into()], sender_account_id, target_account_id, ); let transaction_request = TransactionRequestBuilder::pay_to_id( payment_transaction, None, NoteType::Private, client.rng(), )?; // Execute transaction. No information is tracked after this. let transaction_execution_result = client.new_transaction(sender_account_id, transaction_request.clone()).await?; // Prove and submit the transaction, which is stored alongside created notes (if any) client.submit_transaction(transaction_execution_result).await? }
You can decide whether you want the note details to be public or private through the note_type
parameter.
You may also execute a transaction by manually defining a TransactionRequest
instance. This allows you to run custom code, with custom note arguments as well.
The following document lists the commands that the CLI currently supports.
Note Use
--help
as a flag on any command for more information.
Usage
Call a command on the miden-client
like this:
miden <command> <flags> <arguments>
Optionally, you can include the --debug
flag to run the command with debug mode, which enables debug output logs from scripts that were compiled in this mode:
miden --debug <flags> <arguments>
Note that the debug flag overrides the MIDEN_DEBUG
environment variable.
Commands
init
Creates a configuration file for the client in the current directory.
# This will create a config file named `miden-client.toml` using default values
# This file contains information useful for the CLI like the RPC provider and database path
miden init
# You can set up the CLI for any of the default networks
miden init --network testnet # This is the default value if no network is provided
miden init --network devnet
miden init --network localhost
# You can use the --network flag to override the default RPC config
miden init --network 18.203.155.106
# You can specify the port
miden init --network 18.203.155.106:8080
# You can also specify the protocol (http/https)
miden init --network https://18.203.155.106
# You can specify both
miden init --network https://18.203.155.106:1234
# You can use the --store_path flag to override the default store config
miden init --store_path db/store.sqlite3
# You can provide both flags
miden init --network 18.203.155.106 --store_path db/store.sqlite3
account
Inspect account details.
Action Flags
Flags | Description | Short Flag |
---|---|---|
--list | List all accounts monitored by this client | -l |
--show <ID> | Show details of the account for the specified ID | -s |
--default <ID> | Manage the setting for the default account | -d |
The --show
flag also accepts a partial ID instead of the full ID. For example, instead of:
miden account --show 0x8fd4b86a6387f8d8
You can call:
miden account --show 0x8fd4b86
For the --default
flag, if <ID>
is "none" then the previous default account is cleared. If no <ID>
is specified then the default account is shown.
new-wallet
Creates a new wallet account.
This command has three optional flags:
--storage-mode <TYPE>
: Used to select the storage mode of the account (private if not specified). It may receive "private" or "public".--mutable
: Makes the account code mutable (it's immutable by default).--extra-components <TEMPLATE_FILES_LIST>
: Allows to pass a list of account component template files which can be added to the account. If the templates contain placeholders, the CLI will prompt the user to enter the required data for instantiating storage appropriately.
After creating an account with the new-wallet
command, it is automatically stored and tracked by the client. This means the client can execute transactions that modify the state of accounts and track related changes by synchronizing with the Miden node.
new-faucet
Creates a new faucet account.
This command has three optional flags:
--storage-mode <TYPE>
: Used to select the storage mode of the account (private if not specified). It may receive "private" or "public".--non-fungible
: Makes the faucet asset non-fungible (it's fungible by default).--extra-components <TEMPLATE_FILES_LIST>
: Allows to pass a list of account component template files which can be added to the account. If the templates contain placeholders, the CLI will prompt the user to enter the required data for instantiating storage appropriately.
After creating an account with the new-faucet
command, it is automatically stored and tracked by the client. This means the client can execute transactions that modify the state of accounts and track related changes by synchronizing with the Miden node.
Examples
# Create a new wallet with default settings (private storage, immutable, no extra components)
miden new-wallet
# Create a new wallet with public storage and a mutable code
miden new-wallet --storage-mode public --mutable
# Create a new wallet that includes extra components from local templates
miden new-wallet --extra-components template1,template2
# Create a fungible faucet
miden new-faucet --token-symbol TST --decimals 10 --max-supply 100000 --storage-mode private
info
View a summary of the current client state.
notes
View and manage notes.
Action Flags
Flags | Description | Short Flag |
---|---|---|
--list [<filter>] | List input notes | -l |
--show <ID> | Show details of the input note for the specified note ID | -s |
The --list
flag receives an optional filter:
- expected: Only lists expected notes.
- committed: Only lists committed notes.
- consumed: Only lists consumed notes.
- processing: Only lists processing notes.
- consumable: Only lists consumable notes. An additional --account-id <ID>
flag may be added to only show notes consumable by the specified account.
If no filter is specified then all notes are listed.
The --show
flag also accepts a partial ID instead of the full ID. For example, instead of:
miden notes --show 0x70b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0
You can call:
miden notes --show 0x70b7ec
sync
Sync the client with the latest state of the Miden network. Shows a brief summary at the end.
tags
View and add tags.
Action Flags
Flag | Description | Aliases |
---|---|---|
--list | List all tags monitored by this client | -l |
--add <tag> | Add a new tag to the list of tags monitored by this client | -a |
--remove <tag> | Remove a tag from the list of tags monitored by this client | -r |
tx
View transactions.
Action Flags
Command | Description | Aliases |
---|---|---|
--list | List tracked transactions | -l |
After a transaction gets executed, two entities start being tracked:
- The transaction itself: It follows a lifecycle from
Pending
(initial state) andCommitted
(after the node receives it). It may also beDiscarded
if the transaction was not included in a block. - Output notes that might have been created as part of the transaction (for example, when executing a pay-to-id transaction).
Transaction creation commands
mint
Creates a note that contains a specific amount tokens minted by a faucet, that the target Account ID can consume.
Usage: miden mint --target <TARGET ACCOUNT ID> --asset <AMOUNT>::<FAUCET ID> --note-type <NOTE_TYPE>
consume-notes
Account ID consumes a list of notes, specified by their Note ID.
Usage: miden consume-notes --account <ACCOUNT ID> [NOTES]
For this command, you can also provide a partial ID instead of the full ID for each note. So instead of
miden consume-notes --account <some-account-id> 0x70b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0 0x80b7ecba1db44c3aa75e87a3394de95463cc094d7794b706e02a9228342faeb0
You can do:
miden consume-notes --account <some-account-id> 0x70b7ecb 0x80b7ecb
Additionally, you can optionally not specify note IDs, in which case any note that is known to be consumable by the executor account ID will be consumed.
Either Expected
or Committed
notes may be consumed by this command, changing their state to Processing
. It's state will be updated to Consumed
after the next sync.
send
Sends assets to another account. Sender Account creates a note that a target Account ID can consume. The asset is identified by the tuple (FAUCET ID, AMOUNT)
. The note can be configured to be recallable making the sender able to consume it after a height is reached.
Usage: miden send --sender <SENDER ACCOUNT ID> --target <TARGET ACCOUNT ID> --asset <AMOUNT>::<FAUCET ID> --note-type <NOTE_TYPE> <RECALL_HEIGHT>
swap
The source account creates a Swap note that offers some asset in exchange for some other asset. When another account consumes that note, it'll receive the offered amount and it'll have the requested amount removed from its assets (and put into a new note which the first account can then consume). Consuming the note will fail if the account doesn't have enough of the requested asset.
Usage: miden swap --source <SOURCE ACCOUNT ID> --offered-asset <OFFERED AMOUNT>::<OFFERED FAUCET ID> --requested-asset <REQUESTED AMOUNT>::<REQUESTED FAUCET ID> --note-type <NOTE_TYPE>
Tips
For send
and consume-notes
, you can omit the --sender
and --account
flags to use the default account defined in the config. If you omit the flag but have no default account defined in the config, you'll get an error instead.
For every command which needs an account ID (either wallet or faucet), you can also provide a partial ID instead of the full ID for each account. So instead of
miden send --sender 0x80519a1c5e3680fc --target 0x8fd4b86a6387f8d8 --asset 100::0xa99c5c8764d4e011
You can do:
miden send --sender 0x80519 --target 0x8fd4b --asset 100::0xa99c5c8764d4e011
!!! note The only exception is for using IDs as part of the asset, those should have the full faucet's account ID.
Transaction confirmation
When creating a new transaction, a summary of the transaction updates will be shown and confirmation for those updates will be prompted:
miden <tx command> ...
TX Summary:
...
Continue with proving and submission? Changes will be irreversible once the proof is finalized on the rollup (Y/N)
This confirmation can be skipped in non-interactive environments by providing the --force
flag (miden send --force ...
):
Importing and exporting
export
Export input note data to a binary file .
Flag | Description | Aliases |
---|---|---|
--filename <FILENAME> | Desired filename for the binary file. | -f |
--export-type <EXPORT_TYPE> | Exported note type. | -e |
Export type
The user needs to specify how the note should be exported via the --export-type
flag. The following options are available:
id
: Only the note ID is exported. When importing, if the note ID is already tracked by the client, the note will be updated with missing information fetched from the node. This works for both public and private notes. If the note isn't tracked and the note is public, the whole note is fetched from the node and is stored for later use.full
: The note is exported with all of its information (metadata and inclusion proof). When importing, the note is considered unverified. The note may not be consumed directly after importing as its block header will not be stored in the client. The block header will be fetched and be used to verify the note during the next sync. At this point the note will be committed and may be consumed.partial
: The note is exported with minimal information and may be imported even if the note is not yet committed on chain. At the moment of importing the note, the client will check the state of the note by doing a note sync, using the note's tag. Depending on the response, the note will be either stored as "Expected" or "Committed".
import
Import entities managed by the client, such as accounts and notes. The type of entities is inferred.
After installation, use the client by running the following and adding the relevant commands:
miden
Info "Help" Run
miden --help
for information onmiden
commands.
Client Configuration
We configure the client using a TOML file (miden-client.toml
).
[rpc]
endpoint = { protocol = "http", host = "localhost", port = 57291 }
timeout_ms = 10000
[store]
database_filepath = "store.sqlite3"
[cli]
default_account_id = "0x012345678"
The TOML file should reside in same the directory from which you run the CLI.
In the configuration file, you will find a section for defining the node's rpc endpoint
and timeout and the store's filename database_filepath
.
By default, the node is set up to run on localhost:57291
.
Note
- Running the node locally for development is encouraged.
- However, the endpoint can point to any remote node.
There's an additional optional section used for CLI configuration. It currently contains the default account ID, which is used to execute transactions against it when the account flag is not provided.
By default none is set, but you can set and unset it with:
miden account --default <ACCOUNT_ID> #Sets default account
miden account --default none #Unsets default account
Note
- The account must be tracked by the client in order to be set as the default account.
You can also see the current default account ID with:
miden account --default
Environment variables
MIDEN_DEBUG
: When set totrue
, enables debug mode on the transaction executor and the script compiler. For any script that has been compiled and executed in this mode, debug logs will be output in order to facilitate MASM debugging (these instructions can be used to do so). This variable can be overridden by the--debug
CLI flag.
Example "Executing, proving, and submitting transactions to the Miden node" For a complete example on how to run the client and submit transactions to the Miden node, refer to the
Getting started documentation
.
Example "Miden client API docs" The latest and complete reference for the Miden client API can be found at
Miden client docs.rs
.
Miden architecture overview
Polygon Miden’s architecture departs considerably from typical blockchain designs to support privacy and parallel transaction execution.
In traditional blockchains, state and transactions must be transparent to be verifiable. This is necessary for block production and execution.
However, user generated zero-knowledge proofs allow state transitions, e.g. transactions, to be verifiable without being transparent.
Miden design goals
- High throughput: The ability to process a high number of transactions (state changes) over a given time interval.
- Privacy: The ability to keep data known to one’s self and anonymous while processing and/or storing it.
- Asset safety: Maintaining a low risk of mistakes or malicious behavior leading to asset loss.
Actor model
The actor model inspires Polygon Miden’s execution model. This is a well-known computational design paradigm in concurrent systems. In the actor model, actors are state machines responsible for maintaining their own state. In the context of Polygon Miden, each account is an actor. Actors communicate with each other by exchanging messages asynchronously. One actor can send a message to another, but it is up to the recipient to apply the requested change to their state.
Polygon Miden’s architecture takes the actor model further and combines it with zero-knowledge proofs. Now, actors not only maintain and update their own state, but they can also prove the validity of their own state transitions to the rest of the network. This ability to independently prove state transitions enables local smart contract execution, private smart contracts, and much more. And it is quite unique in the rollup space. Normally only centralized entities - sequencer or prover - create zero-knowledge proofs, not the users.
Core concepts
Miden uses accounts and notes, both of which hold assets. Accounts consume and produce notes during transactions. Transactions describe the account state changes of single accounts.
Accounts
Accounts can hold assets and define rules how assets can be transferred. Accounts can represent users or autonomous smart contracts. The accounts chapter describes the design of an account, its storage types, and creating an account.
Notes
Notes are messages that accounts send to each other. A note stores assets and a script that defines how the note can be consumed. The note chapter describes the design, the storage types, and the creation of a note.
Assets
Assets can be fungible and non-fungible. They are stored in the owner’s account itself or in a note. The assets chapter describes asset issuance, customization, and storage.
Transactions
Transactions describe the production and consumption of notes by a single account.
Executing a transaction always results in a STARK proof.
The transaction chapter describes the transaction design and implementation, including an in-depth discussion of how transaction execution happens in the transaction kernel program.
Limits
Limits topic describes limits currently enforced in miden-base
and miden-node
.
Accounts produce and consume notes to communicate
State and execution
The actor-based execution model requires a radically different approach to recording the system's state. Actors and the messages they exchange must be treated as first-class citizens. Polygon Miden addresses this by combining the state models of account-based systems like Ethereum and UTXO-based systems like Bitcoin and Zcash.
Miden's state model captures the individual states of all accounts and notes, and the execution model describes state progress in a sequence of blocks.
State model
State describes everything that is the case at a certain point in time. Individual states of accounts or notes can be stored on-chain and off-chain. This chapter describes the three different state databases in Miden.
Execution model
Execution defines how state progresses as aggregated-state-updates in batches, blocks, and epochs. The execution chapter describes the execution model and how blocks are built.
Operators capture and progress state
Account / Smart Contract
An Account
represents the primary entity of the protocol. Capable of holding assets, storing data, and executing custom code. Each Account
is a specialized smart contract providing a programmable interface for interacting with its state and assets.
What is the purpose of an account?
In Miden's hybrid UTXO- and account-based model Account
s enable the creation of expressive smart contracts via a Turing-complete language.
Account core components
An Account
is composed of several core components, illustrated below:
These components are:
ID
An immutable and unique identifier for the
Account
.
A 120-bit long number represents the Account
ID. This identifier is structured to encapsulate specific account metadata while preventing precomputed attack vectors (e.g., rainbow table attacks).
The ID is generated by hashing a user-generated random seed together with commitments to the initial code and storage of the Account
and the anchor block. The anchor block refers to specific blockchain epoch block in which the account is created. The resulting 256-bit long hash is then manipulated and shortened to 120-bit. Manipulation includes encoding the account type, account storage mode, the version of the Account
ID scheme, and the anchor block.
Account type, storage mode, and version are included in the ID, to ensure these properties can be determined without additional computation. Anyone can immediately tell those properties by just looking at the ID in bit representation.
Also, the ID generation process ensures that an attacker cannot precompute an ID before the anchor block's commitment is available. This significantly mitigates the risk of ID hijacking, where an adversary might attempt to claim assets sent to an unregistered ID. By anchoring the ID to a recent epoch block, the window for potential attacks is minimized, reinforcing the security of asset transfers and account registration.
An Account
ID is considered invalid if:
- The metadata (storage mode, type, or version) does not match any recognized values.
- The anchor epoch exceeds $2^{16}-1$.
- The least significant 8 bits of the ID are nonzero.
Code
A collection of functions defining the
Account
’s programmable interface.
Every Miden Account
is essentially a smart contract. The Code
component defines the account’s functions, which can be invoked through both Note scripts and transaction scripts. Key characteristics include:
- Mutable access: Only the
Account
’s own functions can modify its storage and vault. All state changes—such as updating storage slots, incrementing the nonce, or transferring assets—must occur through these functions. - Function commitment: Each function can be called by its MAST root. The root represents the underlying code tree as a 32-byte commitment. This ensures integrity, i.e., the caller calls what he expects.
- Note creation:
Account
functions can generate new notes.
Storage
A flexible, arbitrary data store within the
Account
.
The storage is divided into a maximum of 255 indexed storage slots. Each slot can either store a 32-byte value or serve as a pointer to a key-value store with large amounts capacity.
StorageSlot::Value
: Contains 32 bytes of arbitrary data.StorageSlot::Map
: Contains a StorageMap, a key-value store where both keys and values are 32 bytes. The slot's value is a commitment to the entire map.
Vault
A collection of assets stored by the
Account
.
Large amounts of fungible and non-fungible assets can be stored in the Account
s vault.
Nonce
A counter incremented with each state update to the
Account
.
The nonce enforces ordering and prevents replay attacks. It must strictly increase with every Account
state update. The increment must be less than $2^{32}$ but always greater than the previous nonce, ensuring a well-defined sequence of state changes.
If a smart contract function should be callable by other users, it must increment the Account
's nonce. Otherwise, only the contract owner—i.e., the party possessing the contract's key—can execute the function.
Account lifecycle
Throughout its lifetime, an Account
progresses through various phases:
- Creation and Deployment: Initialization of the
Account
on the network. - Active Operation: Continuous state updates via
Account
functions that modify the storage, nonce, and vault. - Termination or Deactivation: Optional, depending on the contract’s design and governance model.
Account creation
For an Account
to be recognized by the network, it must exist in the account database maintained by Miden node(s).
However, a user can locally create a new Account
ID before it’s recognized network-wide. The typical process might be:
- Alice generates a new
Account
ID locally (according to the desiredAccount
type) using the Miden client. - The Miden client checks with a Miden node to ensure the ID does not already exist.
- Alice shares the new ID with Bob (for example, to receive assets).
- Bob executes a transaction, creating a note containing assets for Alice.
- Alice consumes Bob’s note in her own transaction to claim the asset.
- Depending on the
Account
’s storage mode and transaction type, the operator receives the newAccount
ID and, if all conditions are met, includes it in theAccount
database.
Additional information
Account type
There are two main categories of Account
s in Miden: basic accounts and faucets.
-
Basic Accounts: Basic Accounts may be either mutable or immutable:
- Mutable: Code can be changed after deployment.
- Immutable: Code cannot be changed once deployed.
-
Faucets: Faucets are always immutable and can be specialized by the type of assets they issue:
Type and mutability are encoded in the two most significant bits of the Account
's ID.
Account storage mode
Users can choose whether their Account
s are stored publicly or privately. The preference is encoded in the third and forth most significant bits of the Account
s ID:
-
Public
Account
s: TheAccount
’s state is stored on-chain, similar to howAccount
s are stored in public blockchains like Ethereum. Contracts that rely on a shared, publicly accessible state (e.g., a DEX) should be public. -
Private
Account
s: Only a commitment (hash) to theAccount
’s state is stored on-chain. This mode is suitable for users who prioritize privacy or plan to store a large amount of data in theirAccount
. To interact with a privateAccount
, a user must have knowledge of its interface.
The storage mode is chosen during Account
creation, it cannot be changed later.
Note
A Note
is the medium through which Accounts communicate. A Note
holds assets and defines how they can be consumed.
What is the purpose of a note?
In Miden's hybrid UTXO and account-based model Note
s represent UTXO's which enable parallel transaction execution and privacy through asynchronous local Note
production and consumption.
Note core components
A Note
is composed of several core components, illustrated below:
These components are:
Assets
An asset container for a
Note
.
A Note
can contain up to 256 different assets. These assets represent fungible or non-fungible tokens, enabling flexible asset transfers.
Script
The code executed when the
Note
is consumed.
Each Note
has a script that defines the conditions under which it can be consumed. When accounts consume Note
s in transactions, Note
scripts call the account’s interface functions. This enables all sorts of operations beyond simple asset transfers. The Miden VM’s Turing completeness allows for arbitrary logic, making Note
scripts highly versatile.
Inputs
Arguments passed to the
Note
script during execution.
A Note
can have up to 128 input values, which adds up to a maximum of 1 KB of data. The Note
script can access these inputs. They can convey arbitrary parameters for Note
consumption.
Serial number
A unique and immutable identifier for the
Note
.
The serial number has two main purposes. Firstly by adding some randomness to the Note
it ensures it's uniqueness, secondly in private Note
s it helps prevent linkability between the Note
's hash and its nullifier. The serial number should be a random 32 bytes number chosen by the user. If leaked, the Note
’s nullifier can be easily computed, potentially compromising privacy.
Metadata
Additional
Note
information.
Note
s include metadata such as the sender’s account ID and a tag that aids in discovery. Regardless of storage mode, these metadata fields remain public.
Note Lifecycle
The Note
lifecycle proceeds through four primary phases: creation, validation, discovery, and consumption. Throughout this process, Note
s function as secure, privacy-preserving vehicles for asset transfers and logic execution.
Note creation
Accounts can create Note
s in a transaction. The Note
exists if it is included in the global Note
s DB.
- Users: Executing local or network transactions.
- Miden operators: Facilitating on-chain actions, e.g. such as executing user
Note
s against a DEX or other contracts.
Note storage mode
As with accounts, Note
s can be stored either publicly or privately:
- Public mode: The
Note
data is stored in the note database, making it fully visible on-chain. - Private mode: Only the
Note
’s hash is stored publicly. TheNote
’s actual data remains off-chain, enhancing privacy.
Note validation
Once created, a Note
must be validated by a Miden operator. Validation involves checking the transaction proof that produced the Note
to ensure it meets all protocol requirements.
After validation Note
s become “live” and eligible for consumption. If creation and consumption happens within the same block, there is no entry in the Notes DB. All other notes, are being added either as a commitment or fully public.
Note discovery
Clients often need to find specific Note
s of interest. Miden allows clients to query the Note
database using Note
tags. These lightweight, 32-bit data fields serve as best-effort filters, enabling quick lookups for Note
s related to particular use cases, scripts, or account prefixes.
Using Note
tags strikes a balance between privacy and efficiency. Without tags, querying a specific Note
ID reveals a user’s interest to the operator. Conversely, downloading and filtering all registered Note
s locally is highly inefficient. Tags allow users to adjust their level of privacy by choosing how broadly or narrowly they define their search criteria, letting them find the right balance between revealing too much information and incurring excessive computational overhead.
Note consumption
To consume a Note
, the consumer must know its data, including the inputs needed to compute the nullifier. Consumption occurs as part of a transaction. Upon successful consumption a nullifier is generated for the consumed Note
s.
Upon successful verification of the transaction:
- The Miden operator records the
Note
’s nullifier as “consumed” in the nullifier database. - The
Note
’s one-time claim is thus extinguished, preventing reuse.
Note recipient restricting consumption
Consumption of a Note
can be restricted to certain accounts or entities. For instance, the P2ID and P2IDR Note
scripts target a specific account ID. Alternatively, Miden defines a RECIPIENT (represented as 32 bytes) computed as:
hash(hash(hash(serial_num, [0; 4]), script_hash), input_hash)
Only those who know the RECIPIENT’s pre-image can consume the Note
. For private Note
s, this ensures an additional layer of control and privacy, as only parties with the correct data can claim the Note
.
The transaction prologue requires all necessary data to compute the Note
hash. This setup allows scenario-specific restrictions on who may consume a Note
.
For a practical example, refer to the SWAP note script, where the RECIPIENT ensures that only a defined target can consume the swapped asset.
Note nullifier ensuring private consumption
The Note
nullifier, computed as:
hash(serial_num, script_hash, input_hash, vault_hash)
This achieves the following properties:
- Every
Note
can be reduced to a single unique nullifier. - One cannot derive a
Note
's hash from its nullifier. - To compute the nullifier, one must know all components of the
Note
: serial_num, script_hash, input_hash, and vault_hash.
That means if a Note
is private and the operator stores only the Note
's hash, only those with the Note
details know if this Note
has been consumed already. Zcash first introduced this approach.
Asset
An Asset
is a unit of value that can be transferred from one account to another using notes.
What is the purpose of an asset?
In Miden, Asset
s serve as the primary means of expressing and transferring value between accounts through notes. They are designed with four key principles in mind:
-
Parallelizable exchange:
By managing ownership and transfers directly at the account level instead of relying on global structures like ERC20 contracts, accounts can exchangeAsset
s concurrently, boosting scalability and efficiency. -
Self-sovereign ownership:
Asset
s are stored in the accounts directly. This ensures that users retain complete control over theirAsset
s. -
Censorship resistance:
Users can transact freely and privately with no single contract or entity controllingAsset
transfers. This reduces the risk of censored transactions, resulting in a more open and resilient system. -
Flexible fee payment:
Unlike protocols that require a specific baseAsset
for fees, Miden allows users to pay fees in any supportedAsset
. This flexibility simplifies the user experience.
Native asset
All data structures following the Miden asset model that can be exchanged.
Native Asset
s adhere to the Miden Asset
model (encoding, issuance, storage). Every native Asset
is encoded using 32 bytes, including both the ID of the issuing account and the Asset
details.
Issuance
Info
- Only faucet accounts can issue assets.
Faucets can issue either fungible or non-fungible Asset
s as defined at account creation. The faucet's code specifies the Asset
minting conditions: i.e., how, when, and by whom these Asset
s can be minted. Once minted, they can be transferred to other accounts using notes.
Type
Fungible asset
Fungible Asset
s are encoded with the amount and the faucet_id
of the issuing faucet. The amount is always 2^{63} - 1 or smaller, representing the maximum supply for any fungible Asset
. Examples include ETH and various stablecoins (e.g., DAI, USDT, USDC).
Non-fungible asset
Non-fungible Asset
s are encoded by hashing the Asset
data into 32 bytes and placing the faucet_id
as the second element. Examples include NFTs like a DevCon ticket.
Storage
Accounts and notes have vaults used to store Asset
s. Accounts use a sparse Merkle tree as a vault while notes use a simple list. This enables an account to store a practically unlimited number of assets while a note can only store 255 assets.
Burning
Asset
s in Miden can be burned through various methods, such as rendering them unspendable by storing them in an unconsumable note, or sending them back to their original faucet for burning using it's dedicated function.
Alternative asset models
All data structures not following the Miden asset model that can be exchanged.
Miden is flexible enough to support other Asset
models. For example, developers can replicate Ethereum’s ERC20 pattern, where fungible Asset
ownership is recorded in a single account. To transact, users send a note to that account, triggering updates in the global hashmap state.
Transaction
A Transaction
in Miden is the state transition of a single account. A Transaction
takes as input a single account and zero or more notes, and outputs the same account with an updated state, together with zero or more notes. Transaction
s in Miden are Miden VM programs, their execution resulting in the generation of a zero-knowledge proof.
Miden's Transaction
model aims for the following:
- Parallel transaction execution: Accounts can update their state independently from each other and in parallel.
- Private transaction execution: Client-side
Transaction
proving allows the network to verifyTransaction
s validity with zero knowledge.
Compared to most blockchains, where a Transaction
typically involves more than one account (e.g., sender and receiver), a Transaction
in Miden involves a single account. To illustrate, Alice sends 5 ETH to Bob. In Miden, sending 5 ETH from Alice to Bob takes two Transaction
s, one in which Alice creates a note containing 5 ETH and one in which Bob consumes that note and receives the 5 ETH. This model removes the need for a global lock on the blockchain's state, enabling Miden to process Transaction
s in parallel.
Currently the protocol limits the number of notes that can be consumed and produced in a transaction to 1000 each, which means that in a single Transaction
an application could serve up to 2000 different user requests like deposits or withdrawals into/from a pool.
A simple transaction currently takes about 1-2 seconds on a MacBook Pro. It takes around 90K
cycles to create the proof, as of now the signature verification step is the dominant cost.
Transaction lifecycle
Every Transaction
describes the process of an account changing its state. This process is described as a Miden VM program, resulting in the generation of a zero-knowledge proof. Transaction
s are being executed in a specified sequence, in which several notes and a transaction script can interact with an account.
Inputs
A Transaction
requires several inputs:
-
Account: A
Transaction
is always executed against a single account. The executor must have complete knowledge of the account's state. -
Notes: A
Transaction
can consume up to1K
notes. The executor must have complete knowledge of the note data, including note inputs, before consumption. For private notes, the data cannot be fetched from the blockchain and must be received through an off-chain channel. -
Blockchain state: The current reference block and information about the notes database used to authenticate notes to be consumed must be retrieved from the Miden operator before execution. Usually, notes to be consumed in a
Transaction
must have been created before the reference block. -
Transaction script (optional):
Transaction
scripts are defined by the executor. And like note scripts, they can invoke account methods, e.g., sign a transaction. -
Transaction arguments (optional): For every note, the executor can inject transaction arguments that are present at runtime. If the note script — and therefore the note creator — allows, the note script can read those arguments to allow dynamic execution. See below for an example.
-
Foreign account data (optional): Any foreign account data accessed during a
Transaction
, whether private or public, must be available beforehand. There is no need to know the full account storage, but the data necessary for theTransaction
, e.g., the key/value pair that is read and the corresponding storage root.
Flow
-
Prologue
Executes at the beginning of a transaction. It validates on-chain commitments against the provided data. This is to ensure that the transaction executes against a valid on-chain recorded state of the account and to be consumed notes. Notes to be consumed must be registered on-chain — except for erasable notes which can be consumed without block inclusion.
-
Note processing
Notes are executed sequentially against the account, following a sequence defined by the executor. To execute a note means processing the note script that calls methods exposed on the account interface. Notes must be consumed fully, which means that all assets must be transferred into the account or into other created notes. Note scripts can invoke the account interface during execution. They can push assets into the account's vault, create new notes, set a transaction expiration, and read from or write to the account’s storage. Any method they call must be explicitly exposed by the account interface. Note scripts can also invoke methods of foreign accounts to read their state.
-
Transaction script processing
Transaction
scripts are an optional piece of code defined by the executor which interacts with account methods after all notes have been executed. For example,Transaction
scripts can be used to sign theTransaction
(e.g., sign the transaction by incrementing the nonce of the account, without which, the transaction would fail), to mint tokens from a faucet, create notes, or modify account storage.Transaction
scripts can also invoke methods of foreign accounts to read their state. -
Epilogue
Completes the execution, resulting in an updated account state and a generated zero-knowledge proof. The validity of the resulting state change is checked. The account's
Nonce
must have been incremented, which is how the entire transaction is authenticated. Also, the net sum of all involved assets must be0
(if the account is not a faucet).
The proof together with the corresponding data needed for verification and updates of the global state can then be submitted and processed by the network.
Examples
To illustrate the Transaction
protocol, we provide two examples for a basic Transaction
. We will use references to the existing Miden Transaction
kernel — the reference implementation of the protocol — and to the methods in Miden Assembly.
Creating a P2ID note
Let's assume account A wants to create a P2ID note. P2ID notes are pay-to-ID notes that can only be consumed by a specified target account ID. Note creators can provide the target account ID using the note inputs.
In this example, account A uses the basic wallet and the authentication component provided by miden-lib
. The basic wallet component defines the methods wallets::basic::create_note
and wallets::basic::move_asset_to_note
to create notes with assets, and wallets::basic::receive_asset
to receive assets. The authentication component exposes auth::basic::auth_tx_rpo_falcon512
which allows for signing a transaction. Some account methods like account::get_id
are always exposed.
The executor inputs to the Miden VM a Transaction
script in which he places on the stack the data (tag, aux, note_type, execution_hint, RECIPIENT) of the note(s) that he wants to create using wallets::basic::create_note
during the said Transaction
. The NoteRecipient
is a value that describes under which condition a note can be consumed and is built using a serial_number
, the note_script
(in this case P2ID script) and the note_inputs
. The Miden VM will execute the Transaction
script and create the note(s). After having been created, the executor can use wallets::basic::move_asset_to_note
to move assets from the account's vault to the notes vault.
After finalizing the Transaction
the updated state and created note(s) can now be submitted to the Miden operator to be recorded on-chain.
Consuming a P2ID note
Let's now assume that account A wants to consume a P2ID note to receive the assets contained in that note.
To start the transaction process, the executor fetches and prepares all the input data to the Transaction
. First, it retrieves blockchain data, like global inputs and block data of the most recent block. This information is needed to authenticate the native account's state and that the P2ID note exists on-chain. Then it loads the full account and note data, to start the Transaction
execution.
In the transaction's prologue the data is being authenticated by re-hashing the provided values and comparing them to the blockchain's data (this is how private data can be used and verified during the execution of transaction without actually revealing it to the network).
Then the P2ID note script is being executed. The script starts by reading the note inputs note::get_inputs
— in our case the account ID of the intended target account. It checks if the provided target account ID equals the account ID of the executing account. This is the first time the note invokes a method exposed by the Transaction
kernel, account::get_id
.
If the check passes, the note script pushes the assets it holds into the account's vault. For every asset the note contains, the script calls the wallets::basic::receive_asset
method exposed by the account's wallet component. The wallets::basic::receive_asset
procedure calls account::add_asset
, which cannot be called from the note itself. This allows accounts to control what functionality to expose, e.g. whether the account supports receiving assets or not, and the note cannot bypass that.
After the assets are stored in the account's vault, the transaction script is being executed. The script calls auth::basic::auth_tx_rpo_falcon512
which is explicitly exposed in the account interface. The method is used to verify a provided signature against a public key stored in the account's storage and a commitment to this specific transaction. If the signature can be verified, the method increments the nonce.
The Epilogue finalizes the transaction by computing the final account hash, asserting the nonce increment and checking that no assets were created or destroyed in the transaction — that means the net sum of all assets must stay the same.
Transaction types
There are two types of Transaction
s in Miden: local transactions and network transactions [not yet implemented].
Local transaction
Users transition their account's state locally using the Miden VM and generate a Transaction
proof that can be verified by the network, which we call client-side proving. The network then only has to verify the proof and to change the global parts of the state to apply the state transition.
They are useful, because:
- They enable privacy as neither the account state nor account code are needed to verify the zero-knowledge proof. Public inputs are only commitments and block information that are stored on-chain.
- They are cheaper (i.e., lower in fees) as the execution of the state transition and the generation of the zero-knowledge proof are already made by the users. Hence privacy is the cheaper option on Miden.
- They allow arbitrarily complex computation to be done. The proof size doesn't grow linearly with the complexity of the computation. Hence there is no gas limit for client-side proving.
Client-side proving or local transactions on low-power devices can be slow, but Miden offers a pragmatic alternative: delegated proving. Instead of waiting for complex computations to finish on your device, you can hand off proof generation to a service, ensuring a consistent 1-2 second proving time, even on mobile.
Network transaction
The Miden operator executes the Transaction
and generates the proof. Miden uses network Transaction
s for smart contracts with public shared state. This type of Transaction
is quite similar to the ones in traditional blockchains (e.g., Ethereum).
They are useful, because:
- For public shared state of smart contracts. Network
Transaction
s allow orchestrated state changes of public smart contracts without race conditions. - Smart contracts should be able to be executed autonomously, ensuring liveness. Local
Transaction
s require a user to execute and prove, but in some cases a smart contract should be able to execute when certain conditions are met. - Clients may not have sufficient resources to generate zero-knowledge proofs.
The ability to facilitate both, local and network Transaction
s, is one of the differentiating factors of Miden compared to other blockchains. Local Transaction
execution and proving can happen in parallel as for most Transaction
s there is no need for public state changes. This increases the network's throughput tremendously and provides privacy. Network Transaction
s on the other hand enable autonomous smart contracts and public shared state.
Good to know
Usually, notes that are consumed in a
Transaction
must be recorded on-chain in order for theTransaction
to succeed. However, Miden supports erasable notes which are notes that can be consumed in aTransaction
before being registered on-chain. For example, one can build a sub-second order book by allowing its traders to build faster transactions that depend on each other and are being validated or erased in batches.There is no nullifier check during a
Transaction
. Nullifiers are checked by the Miden operator duringTransaction
verification. So at the local level, there is "double spending." If a note was already spent, i.e. there exists a nullifier for that note, the block producer would never include theTransaction
as it would make the block invalid.One of the main reasons for separating execution and proving steps is to allow stateless provers; i.e., the executed
Transaction
has all the data it needs to re-execute and prove aTransaction
without database access. This supports easier proof-generation distribution.Not all
Transaction
s require notes. For example, the owner of a faucet can mint new tokens using only aTransaction
script, without interacting with external notes.In Miden executors can choose arbitrary reference blocks to execute against their state. Hence it is possible to set
Transaction
expiration heights and in doing so, to define a block height until aTransaction
should be included into a block. If theTransaction
is expired, the resulting account state change is not valid and theTransaction
cannot be verified anymore.Note and
Transaction
scripts can read the state of foreign accounts during execution. This is called foreign procedure invocation. For example, the price of an asset for the Swap script might depend on a certain value stored in the oracle account.An example of the right usage of
Transaction
arguments is the consumption of a Swap note. Those notes allow asset exchange based on predefined conditions. Example:
- The note's consumption condition is defined as "anyone can consume this note to take
X
units of asset A if they simultaneously create a note sending Y units of asset B back to the creator." If an executor wants to buy only a fraction(X-m)
of asset A, they provide this amount via transaction arguments. The executor would provide the valuem
. The note script then enforces the correct transfer:
- A new note is created returning
Y-((m*Y)/X)
of asset B to the sender.- A second note is created, holding the remaining
(X-m)
of asset A for future consumption.
State
The State
describes the current condition of all accounts, notes, nullifiers and their statuses. Reflecting the “current reality” of the protocol at any given time.
What is the purpose of the Miden state model?
By employing a concurrent State
model with local execution and proving, Miden achieves three primary properties: preserving privacy, supporting parallel transactions, and reducing state-bloat by minimizing on-chain data storage.
Miden’s State
model focuses on:
-
Concurrency:
Multiple transactions can be processed concurrently by distinct actors using local transaction execution which improves throughput and efficiency. -
Flexible data storage:
Users can store data privately on their own devices or within the network. This approach reduces reliance on the network for data availability, helps maintain user sovereignty, and minimizes unnecessary on-chain storage. -
Privacy:
By using notes and nullifiers, Miden ensures that value transfers remain confidential. Zero-knowledge proofs allow users to prove correctness without revealing sensitive information.
State model components
The Miden node maintains three databases to describe State
:
- Accounts
- Notes
- Nullifiers
Account database
The accounts database has two main purposes:
- Track state commitments of all accounts
- Store account data for public accounts
This is done using an authenticated data structure, a sparse Merkle tree.
As described in the accounts section, there are two types of accounts:
- Public accounts: where all account data is stored on-chain.
- Private accounts: where only the commitments to the account is stored on-chain.
Private accounts significantly reduce storage overhead. A private account contributes only 40 bytes to the global State
(15 bytes for the account ID + 32 bytes for the account commitment + 4 bytes for the block number). For example, 1 billion private accounts take up only 47.47 GB of State
.
The storage contribution of a public account depends on the amount of data it stores.
Warning
- In Miden, when the user is the custodian of their account
State
(in the case of a private account), losing thisState
amounts to losing their funds, similar to losing a private key.
Note database
As described in the notes section, there are two types of notes:
- Public notes: where the entire note content is stored on-chain.
- Private notes: where only the note’s commitment is stored on-chain.
Private notes greatly reduce storage requirements and thus result in lower fees. At high throughput (e.g., 1K TPS), the note database could grow by about 1TB/year. However, only unconsumed public notes and enough information to construct membership proofs must be stored explicitly. Private notes, as well as consumed public notes, can be discarded. This solves the issue of infinitely growing note databases.
Notes are recorded in an append-only accumulator, a Merkle Mountain Range.
Using a Merkle Mountain Range (append-only accumulator) is important for two reasons:
- Membership witnesses (that a note exists in the database) against such an accumulator needs to be updated very infrequently.
- Old membership witnesses can be extended to a new accumulator value, but this extension does not need to be done by the original witness holder.
Both of these properties are needed for supporting local transactions using client-side proofs and privacy. In an append-only data structure, witness data does not become stale when the data structure is updated. That means users can generate valid proofs even if they don’t have the latest State
of this database; so there is no need to query the operator on a constantly changing State
.
Nullifier database
Each note has an associated nullifier which enables the tracking of whether it's associated note has been consumed or not, preventing double-spending.
To prove that a note has not been consumed, the operator must provide a Merkle path to the corresponding node and show that the node’s value is 0. Since nullifiers are 32 bytes each, the sparse Merkle tree height must be sufficient to represent all possible nullifiers. Operators must maintain the entire nullifier set to compute the new tree root after inserting new nullifiers. For each nullifier we also record the block in which it was created. This way "unconsumed" nullifiers have block 0, but all consumed nullifiers have a non-zero block.
Note
- Nullifiers in Miden break linkability between privately stored notes and their consumption details. To know the note’s nullifier, one must know the note’s data.
Additional information
Public shared state
In most blockchains, most smart contracts and decentralized applications (e.g., AAVE, Uniswap) need public shared State
. Public shared State
is also available on Miden and can be represented as in the following example:
In this diagram, multiple participants interact with a common, publicly accessible State
. The figure illustrates how notes are created and consumed:
-
Independent Transactions Creating Notes (tx1 & tx2):
Two separate users (Acc1 and Acc2) execute transactions independently:- tx1 produces note 1
- tx2 produces note 2
These transactions occur in parallel and do not rely on each other, allowing concurrent processing without contention.
-
Sequencing and Consuming Notes (tx3):
The Miden node executes tx3 against the shared account, consuming notes 1 & 2 and producing notes 3 & 4. tx3 is a network transaction executed by the Miden operator. It merges independent contributions into a unifiedState
update. -
Further Independent Transactions (tx4 & tx5):
After the sharedState
is updated:- tx4 consumes note 4
- tx5 consumes note 5
Both users can now interact with notes generated by the public account, continuing the cycle of
State
evolution.
State bloat minimization
Miden nodes do not need to know the entire State
to verify or produce new blocks. Rather than storing the full State
data with the nodes, users keep their data locally, and the rollup stores only commitments to that data. While some contracts must remain publicly visible, this approach minimizes State
bloat. Furthermore the Miden rollup can discard non-required data after certain conditions have been met.
This ensures that the account and note databases remain manageable, even under sustained high usage.
Blockchain
The Miden blockchain protocol describes how the state progresses through Block
s, which are containers that aggregate account state changes and their proofs, together with created and consumed notes. Block
s represent the delta of the global state between two time periods, and each is accompanied by a corresponding proof that attests to the correctness of all state transitions it contains. The current global state can be derived by applying all the Block
s to the genesis state.
Miden's blockchain protocol aims for the following:
- Proven transactions: All included transactions have already been proven and verified when they reach the block.
- Fast genesis syncing: New nodes can efficiently sync to the tip of the chain.
Batch production
To reduce the required space on the blockchain, transaction proofs are not directly put into blocks. First, they are batched together by verifying them in the batch producer. The purpose of the batch producer is to generate a single proof that some number of proven transactions have been verified. This involves recursively verifying individual transaction proofs inside the Miden VM. As with any program that runs in the Miden VM, there is a proof of correct execution running the Miden verifier to verify transaction proofs. This results into a single batch proof.
The batch producer aggregates transactions sequentially by verifying that their proofs and state transitions are correct. More specifically, the batch producer ensures:
- Ordering of transactions: If several transactions within the same batch affect a single account, the correct ordering must be enforced. For example, if
Tx1
andTx2
both describe state changes of accountA
, then the batch kernel must verify them in the order:A -> Tx1 -> A' -> Tx2 -> A''
. - Uniqueness of notes in a single batch: The batch producer must ensure the uniqueness of all notes across transactions in the batch. This will prevent the situation where duplicate notes, which would share identical nullifiers, are created. Only one such duplicate note could later be consumed, as the nullifier will be marked as spent after the first consumption. It also checks for double spends in the set of consumed notes, even though the real double spent check only happens at the block production level.
- Expiration windows: It is possible to set an expiration window for transactions, which in turn sets an expiration window for the entire batch. For instance, if transaction
Tx1
expires at block8
and transactionTx2
expires at block5
, then the batch expiration will be set to the minimum of all transaction expirations, which is5
. - Note erasure of erasable notes: Erasable notes don't exist in the Notes DB. They are unauthenticated. Accounts can still consume unauthenticated notes to consume those notes faster, they don't have to wait for notes being included into a block. If creation and consumption of an erasable note happens in the same batch, the batch producer erases this note.
Block production
To create a Block
, multiple batches and their respective proofs are aggregated. Block
production is not parallelizable and must be performed by the Miden operator. In the future, several Miden operators may compete for Block
production. The schema used for Block
production is similar to that in batch production—recursive verification. Multiple batch proofs are aggregated into a single Block
proof.
The block producer ensures:
- Account DB integrity: The Block
N+1
Account DB commitment must be authenticated against all previous and resulting account commitments across transactions, ensuring valid state transitions and preventing execution on stale states. - Nullifier DB integrity: Nullifiers of newly created notes are added to the Nullifier DB. The Block
N+1
Nullifier DB commitment must be authenticated against all new nullifiers to guarantee completeness. - Block hash references: Check that all block hashes references by batches are in the chain.
- Double-spend prevention: Each consumed note’s nullifier is checked against prior consumption. The Block
N
Nullifier DB commitment is authenticated against all provided nullifiers for consumed notes, ensuring no nullifier is reused. - Global note uniqueness: All created and consumed notes must be unique across batches.
- Batch expiration: The block height of the created block must be smaller or equal than the lowest batch expiration.
- Block time increase: The block timestamp must increase monotonically from the previous block.
- Note erasure of erasable notes: If an erasable note is created and consumed in different batches, it is erased now. If, however, an erasable note is consumed but not created within the block, the batch it contains is rejected. The Miden operator's mempool should preemptively filter such transactions.
In final Block
contains:
- The commitments to the current global state.
- The newly created nullifiers.
- The commitments to newly created notes.
- The new state commitments for affected private accounts.
- The full states for all affected public accounts and newly created notes.
The Block
proof attests to the correct state transition from the previous Block
commitment to the next, and therefore to the change in Miden's global state.
Tip: Block Contents
- State updates: Contains only the hashes of updated elements. For example, for each updated account, a tuple is recorded as
([account id], [new account hash])
.- ZK Proof: This proof attests that, given a state commitment from the previous
Block
, a set of valid batches was executed that resulted in the new state commitment.- The
Block
also includes the full account and note data for public accounts and notes. For example, if account123
is a public account that has been updated, you would see a record in the state updates section as(123, 0x456..)
, and the full new state of this account (which should hash to0x456..
) would be included in a separate section.
Verifying blocks
To verify that a Block
corresponds to a valid global state transition, the following steps must be performed:
- Compute the hashes of public accounts and note states.
- Ensure that these hashes match the records in the state updates section.
- Verify the included
Block
proof using the following public inputs and output:- Input: Previous
Block
commitment. - Input: Set of batch commitments.
- Output: Current
Block
commitment.
- Input: Previous
These steps can be performed by any verifier (e.g., a contract on Ethereum, Polygon AggLayer, or a decentralized network of Miden nodes).
Syncing from genesis
Nodes can sync efficiently from genesis to the tip of the chain through a multi-step process:
- Download historical
Block
s from genesis to the present. - Verify zero-knowledge proofs for all
Block
s. - Retrieve current state data (accounts, notes, and nullifiers).
- Validate that the downloaded state matches the latest
Block
's state commitment.
This approach enables fast blockchain syncing by verifying Block
proofs rather than re-executing individual transactions, resulting in exponentially faster performance. Consequently, state sync is dominated by the time needed to download the data.
Consensus and decentralization
Miden will start as a centralized L2 on the Ethereum network. Over time, Miden will decentralize, but this part of the protocol, especially consensus is not yet set.
Introduction
Basic tutorials and examples of how to build applications on Miden.
The goal is to make getting up to speed with building on Miden as quick and simple as possible.
All of the following tutorials are accompanied by code examples in Rust and TypeScript, which can be found in the Miden Tutorials repository.
Miden Node Setup Tutorial
To run the Miden tutorial examples, you will need to set up a test enviorment and connect to a Miden node.
There are two ways to connect to a Miden node:
- Run the Miden node locally
- Connect to the Miden testnet
Prerequisites
To run miden-node
locally, you need to:
- Install the
miden-node
crate. - Provide a
genesis.toml
file. - Provide a
miden-node.toml
file.
Example genesis.toml
and miden-node.toml
files can be found in the miden-tutorials repository:
- The
genesis.toml
file defines the start timestamp for themiden-node
testnet and allows you to pre-deploy accounts and funding faucets. - The
miden-node.toml
file configures the RPC endpoint and other settings for themiden-node
.
Running the Miden node locally
Step 1: Clone the miden-tutorials repository
In a terminal window, clone the miden-tutorials repository and navigate to the root of the repository using this command:
git clone git@github.com:0xPolygonMiden/miden-tutorials.git
cd miden-tutorials
Step 2: Install the Miden node
Next, install the miden-node crate using this command:
cargo install miden-node --locked
Step 3: Initializing the node
To start the node, we first need to generate the genesis file. To do so, navigate to the /node
directory and create the genesis file using this command:
cd node
miden-node make-genesis \
--inputs-path config/genesis.toml \
--output-path storage/genesis.dat
Expected output:
Genesis input file: config/genesis.toml has successfully been loaded.
Creating fungible faucet account...
Account "faucet" has successfully been saved to: storage/accounts/faucet.mac
Miden node genesis successful: storage/genesis.dat has been created
Step 4: Starting the node
Now, to start the node, navigate to the storage directory and run this command:
cd storage
miden-node start \
--config node/config/miden-node.toml \
node
Expected output:
2025-01-17T12:14:55.432445Z INFO try_build_batches: miden-block-producer: /Users/username/.cargo/registry/src/index.crates.io-6f17d22bba15001f/miden-node-block-producer-0.6.0/src/txqueue/mod.rs:85: close, time.busy: 8.88µs, time.idle: 103µs
2025-01-17T12:14:57.433162Z INFO try_build_batches: miden-block-producer: /Users/username/.cargo/registry/src/index.crates.io-6f17d22bba15001f/miden-node-block-producer-0.6.0/src/txqueue/mod.rs:85: new
2025-01-17T12:14:57.433256Z INFO try_build_batches: miden-block-producer: /Users/username/.cargo/registry/src/index.crates.io-6f17d22bba15001f/miden-node-block-producer-0.6.0/src/txqueue/mod.rs:85: close, time.busy: 6.46µs, time.idle: 94.0µs
Congratulations, you now have a Miden node running locally. Now we can start creating a testing environment for building applications on Miden!
The endpoint of the Miden node running locally is:
http://localhost:57291
Reseting the node
If you need to reset the local state of the node and the rust-client, navigate to the root of the miden-tutorials repository and run this command:
rm -rf rust-client/store.sqlite3
rm -rf node/storage/accounts
rm -rf node/storage/blocks
Connecting to the Miden testnet
To run the tutorial examples using the Miden testnet, use this endpoint:
https://rpc.testnet.miden.io:443
Rust Client
Rust library, which can be used to programmatically interact with the Miden rollup.
The Miden Rust client can be used for a variety of things, including:
- Deploying, testing, and creating transactions to interact with accounts and notes on Miden.
- Storing the state of accounts and notes locally.
- Generating and submitting proofs of transactions.
This section of the docs is an overview of the different things one can achieve using the Rust client, and how to implement them.
Keep in mind that both the Rust client and the documentation are works-in-progress!
Creating Accounts and Faucets
Using the Miden client in Rust to create accounts and deploy faucets
Overview
In this tutorial, we will create a Miden account for Alice and deploy a fungible faucet. In the next section, we will mint tokens from the faucet to fund her account and transfer tokens from Alice's account to other Miden accounts.
What we'll cover
- Understanding the differences between public and private accounts & notes
- Instantiating the Miden client
- Creating new accounts (public or private)
- Deploying a faucet to fund an account
Prerequisites
Before you begin, ensure that a Miden node is running locally in a separate terminal window. To get the Miden node running locally, you can follow the instructions on the Miden Node Setup page.
Public vs. private accounts & notes
Before diving into coding, let's clarify the concepts of public and private accounts & notes on Miden:
- Public accounts: The account's data and code are stored on-chain and are openly visible, including its assets.
- Private accounts: The account's state and logic are off-chain, only known to its owner.
- Public notes: The note's state is visible to anyone - perfect for scenarios where transparency is desired.
- Private notes: The note's state is stored off-chain, you will need to share the note data with the relevant parties (via email or Telegram) for them to be able to consume the note.
Note: The term "account" can be used interchangeably with the term "smart contract" since account abstraction on Miden is handled natively.
It is useful to think of notes on Miden as "cryptographic cashier's checks" that allow users to send tokens. If the note is private, the note transfer is only known to the sender and receiver.
Step 1: Initialize your repository
Create a new Rust repository for your Miden project and navigate to it with the following command:
cargo new miden-rust-client
cd miden-rust-client
Add the following dependencies to your Cargo.toml
file:
[dependencies]
miden-client = { version = "0.7", features = ["testing", "concurrent", "tonic", "sqlite"] }
miden-lib = { version = "0.7", default-features = false }
miden-objects = { version = "0.7.2", default-features = false }
miden-crypto = { version = "0.13.2", features = ["executable"] }
rand = { version = "0.8" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "net", "macros"] }
rand_chacha = "0.3.1"
Step 2: Initialize the client
Before interacting with the Miden network, we must instantiate the client. In this step, we specify several parameters:
- RPC endpoint - The URL of the Miden node you will connect to.
- Client RNG - The random number generator used by the client, ensuring that the serial number of newly created notes are unique.
- SQLite Store – An SQL database used by the client to store account and note data.
- Authenticator - The component responsible for generating transaction signatures.
Copy and paste the following code into your src/main.rs
file.
use miden_client::{ account::{ component::{BasicFungibleFaucet, BasicWallet, RpoFalcon512}, AccountBuilder, AccountId, AccountStorageMode, AccountType, }, asset::{FungibleAsset, TokenSymbol}, auth::AuthSecretKey, crypto::{RpoRandomCoin, SecretKey}, note::NoteType, rpc::{Endpoint, TonicRpcClient}, store::{sqlite_store::SqliteStore, StoreAuthenticator}, transaction::{OutputNote, PaymentTransactionData, TransactionRequestBuilder}, Client, ClientError, Felt, }; use miden_lib::note::create_p2id_note; use miden_objects::account::AccountIdVersion; use rand::Rng; use std::sync::Arc; use tokio::time::Duration; pub async fn initialize_client() -> Result<Client<RpoRandomCoin>, ClientError> { // RPC endpoint and timeout let endpoint = Endpoint::new("http".to_string(), "localhost".to_string(), Some(57291)); let timeout_ms = 10_000; // Build RPC client let rpc_api = Box::new(TonicRpcClient::new(endpoint, timeout_ms)); // Seed RNG let mut seed_rng = rand::thread_rng(); let coin_seed: [u64; 4] = seed_rng.gen(); // Create random coin instance let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // SQLite path let store_path = "store.sqlite3"; // Initialize SQLite store let store = SqliteStore::new(store_path.into()) .await .map_err(ClientError::StoreError)?; let arc_store = Arc::new(store); // Create authenticator referencing the same store and RNG let authenticator = StoreAuthenticator::new_with_rng(arc_store.clone(), rng.clone()); // Instantiate the client. Toggle `in_debug_mode` as needed let client = Client::new(rpc_api, rng, arc_store, Arc::new(authenticator), true); Ok(client) } #[tokio::main] async fn main() -> Result<(), ClientError> { let mut client = initialize_client().await?; println!("Client initialized successfully."); let sync_summary = client.sync_state().await.unwrap(); let block_number = sync_summary.block_num; println!("Latest block number: {}", block_number); Ok(()) }
When running the code above, there will be some unused imports, however, we will use these imports later on in the tutorial.
In this step, we will initialize a Miden client capable of syncing with the blockchain (in this case, our local node). Run the following command to execute src/main.rs
:
cargo run --release
After the program executes, you should see the latest block number printed to the terminal, for example:
Latest block number: 3855
Step 3: Creating a wallet
Now that we've initialized the client, we can create a wallet for Alice.
To create a wallet for Alice using the Miden client, we define the account type as mutable or immutable and specify whether it is public or private. A mutable wallet means you can change the account code after deployment. A wallet on Miden is simply an account with standardized code.
In the example below we create a mutable public account for Alice.
Add this snippet to the end of your file in the main()
function:
#![allow(unused)] fn main() { //------------------------------------------------------------ // STEP 1: Create a basic wallet for Alice //------------------------------------------------------------ println!("\n[STEP 1] Creating a new account for Alice"); // Account seed let mut init_seed = [0u8; 32]; client.rng().fill_bytes(&mut init_seed); // Generate key pair let key_pair = SecretKey::with_rng(client.rng()); // Anchor block let anchor_block = client.get_latest_epoch_block().await.unwrap(); // Build the account let builder = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::RegularAccountUpdatableCode) .storage_mode(AccountStorageMode::Public) .with_component(RpoFalcon512::new(key_pair.public_key())) .with_component(BasicWallet); let (alice_account, seed) = builder.build().unwrap(); // Add the account to the client client .add_account( &alice_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair), false, ) .await?; println!("Alice's account ID: {:?}", alice_account.id().to_hex()); }
Step 4: Deploying a fungible faucet
To provide Alice with testnet assets, we must first deploy a faucet. A faucet account on Miden mints fungible tokens.
We'll create a public faucet with a token symbol, decimals, and a max supply. We will use this faucet to mint tokens to Alice's account in the next section.
Add this snippet to the end of your file in the main()
function:
#![allow(unused)] fn main() { //------------------------------------------------------------ // STEP 2: Deploy a fungible faucet //------------------------------------------------------------ println!("\n[STEP 2] Deploying a new fungible faucet."); // Faucet seed let mut init_seed = [0u8; 32]; client.rng().fill_bytes(&mut init_seed); // Faucet parameters let symbol = TokenSymbol::new("MID").unwrap(); let decimals = 8; let max_supply = Felt::new(1_000_000); // Generate key pair let key_pair = SecretKey::with_rng(client.rng()); // Build the account let builder = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::FungibleFaucet) .storage_mode(AccountStorageMode::Public) .with_component(RpoFalcon512::new(key_pair.public_key())) .with_component(BasicFungibleFaucet::new(symbol, decimals, max_supply).unwrap()); let (faucet_account, seed) = builder.build().unwrap(); // Add the faucet to the client client .add_account( &faucet_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair), false, ) .await?; println!("Faucet account ID: {:?}", faucet_account.id().to_hex()); }
When tokens are minted from this faucet, each token batch is represented as a "note" (UTXO). You can think of a Miden Note as a cryptographic cashier's check that has certain spend conditions attached to it.
Summary
Your updated main()
function in src/main.rs
should look like this:
#[tokio::main] async fn main() -> Result<(), ClientError> { let mut client = initialize_client().await?; println!("Client initialized successfully."); let sync_summary = client.sync_state().await.unwrap(); let block_number = sync_summary.block_num; println!("Latest block number: {}", block_number); //------------------------------------------------------------ // STEP 1: Create a basic wallet for Alice //------------------------------------------------------------ println!("\n[STEP 1] Creating a new account for Alice"); // Account seed let mut init_seed = [0u8; 32]; client.rng().fill_bytes(&mut init_seed); // Generate key pair let key_pair = SecretKey::with_rng(client.rng()); // Anchor block let anchor_block = client.get_latest_epoch_block().await.unwrap(); // Build the account let builder = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::RegularAccountUpdatableCode) .storage_mode(AccountStorageMode::Public) .with_component(RpoFalcon512::new(key_pair.public_key())) .with_component(BasicWallet); let (alice_account, seed) = builder.build().unwrap(); // Add the account to the client client .add_account( &alice_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair), false, ) .await?; println!("Alice's account ID: {:?}", alice_account.id().to_hex()); //------------------------------------------------------------ // STEP 2: Deploy a fungible faucet //------------------------------------------------------------ println!("\n[STEP 2] Deploying a new fungible faucet."); // Faucet seed let mut init_seed = [0u8; 32]; client.rng().fill_bytes(&mut init_seed); // Faucet parameters let symbol = TokenSymbol::new("MID").unwrap(); let decimals = 8; let max_supply = Felt::new(1_000_000); // Generate key pair let key_pair = SecretKey::with_rng(client.rng()); // Build the account let builder = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::FungibleFaucet) .storage_mode(AccountStorageMode::Public) .with_component(RpoFalcon512::new(key_pair.public_key())) .with_component(BasicFungibleFaucet::new(symbol, decimals, max_supply).unwrap()); let (faucet_account, seed) = builder.build().unwrap(); // Add the faucet to the client client .add_account( &faucet_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair), false, ) .await?; println!("Faucet account ID: {:?}", faucet_account.id().to_hex()); // Resync to show newly deployed faucet client.sync_state().await?; Ok(()) }
Let's run the src/main.rs
program again:
cargo run --release
The output will look like this:
[STEP 1] Creating a new account for Alice
Alice's account ID: "0x715abc291819b1100000e7cd88cf3e"
[STEP 2] Deploying a new fungible faucet.
Faucet account ID: "0xab5fb36dd552982000009c440264ce"
In this section we explained how to instantiate the Miden client, create a wallet account, and deploy a faucet.
In the next section we will cover how to mint tokens from the faucet, consume notes, and send tokens to other accounts.
Running the example
To run a full working example navigate to the rust-client
directory in the miden-tutorials repository and run this command:
cd rust-client
cargo run --release --bin create_mint_consume_send
Continue learning
Next tutorial: Mint, Consume, and Create Notes
Mint, Consume, and Create Notes
Using the Miden client in Rust to mint, consume, and create notes
Overview
In the previous section, we initialized our repository and covered how to create an account and deploy a faucet. In this section, we will mint tokens from the faucet for Alice, consume the newly created notes, and demonstrate how to send assets to other accounts.
What we'll cover
- Minting tokens from a faucet
- Consuming notes to fund an account
- Sending tokens to other users
Step 1: Minting tokens from the faucet
To mint notes with tokens from the faucet we created, Alice needs to call the faucet with a mint transaction request.
In essence, a transaction request is a structured template that outlines the data required to generate a zero-knowledge proof of a state change of an account. It specifies which input notes (if any) will be consumed, includes an optional transaction script to execute, and enumerates the set of notes expected to be created (if any).
Below is an example of a transaction request minting tokens from the faucet for Alice. This code snippet will create 5 transaction mint transaction requests.
Add this snippet to the end of your file in the main()
function that we created in the previous chapter:
#![allow(unused)] fn main() { //------------------------------------------------------------ // STEP 3: Mint 5 notes of 100 tokens for Alice //------------------------------------------------------------ println!("\n[STEP 3] Minting 5 notes of 100 tokens each for Alice."); let amount: u64 = 100; let fungible_asset = FungibleAsset::new(faucet_account.id(), amount).unwrap(); for i in 1..=5 { let transaction_request = TransactionRequestBuilder::mint_fungible_asset( fungible_asset.clone(), alice_account.id(), NoteType::Public, client.rng(), ) .unwrap() .build(); let tx_execution_result = client .new_transaction(faucet_account.id(), transaction_request) .await?; client.submit_transaction(tx_execution_result).await?; println!("Minted note #{} of {} tokens for Alice.", i, amount); } println!("All 5 notes minted for Alice successfully!"); // Re-sync so minted notes become visible client.sync_state().await?; }
Step 2: Identifying consumable notes
Once Alice has minted a note from the faucet, she will eventually want to spend the tokens that she received in the note created by the mint transaction.
Minting a note from a faucet on Miden means a faucet account creates a new note targeted to the requesting account. The requesting account needs to consume this new note to have the assets appear in their account.
To identify consumable notes, the Miden client provides the get_consumable_notes
function. Before calling it, ensure that the client state is synced.
Tip: If you know how many notes to expect after a transaction, use an await or loop condition to check how many notes of the type you expect are available for consumption instead of using a set timeout before calling get_consumable_notes
. This ensures your application isn't idle for longer than necessary.
Identifying which notes are available:
#![allow(unused)] fn main() { let consumable_notes = client.get_consumable_notes(Some(alice_account.id())).await?; }
Step 3: Consuming multiple notes in a single transaction:
Now that we know how to identify notes ready to consume, let's consume the notes created by the faucet in a single transaction. After consuming the notes, Alice's wallet balance will be updated.
The following code snippet identifies consumable notes and consumes them in a single transaction.
Add this snippet to the end of your file in the main()
function:
//------------------------------------------------------------
// STEP 4: Alice consumes all her notes
//------------------------------------------------------------
println!("\n[STEP 4] Alice will now consume all of her notes to consolidate them.");
// Consume all minted notes in a single transaction
loop {
// Resync to get the latest data
client.sync_state().await?;
let consumable_notes = client
.get_consumable_notes(Some(alice_account.id()))
.await?;
let list_of_note_ids: Vec<_> = consumable_notes.iter().map(|(note, _)| note.id()).collect();
if list_of_note_ids.len() == 5 {
println!("Found 5 consumable notes for Alice. Consuming them now...");
let transaction_request =
TransactionRequestBuilder::consume_notes(list_of_note_ids).build();
let tx_execution_result = client
.new_transaction(alice_account.id(), transaction_request)
.await?;
client.submit_transaction(tx_execution_result).await?;
println!("All of Alice's notes consumed successfully.");
break;
} else {
println!(
"Currently, Alice has {} consumable notes. Waiting for 5...",
list_of_note_ids.len()
);
tokio::time::sleep(Duration::from_secs(3)).await;
}
}
Step 4: Sending tokens to other accounts
After consuming the notes, Alice has tokens in her wallet. Now, she wants to send tokens to her friends. She has two options: create a separate transaction for each transfer or batch multiple transfers into a single transaction.
The standard asset transfer note on Miden is the P2ID note (Pay to Id). There is also the P2IDR (Pay to Id Reclaimable) variant which allows the creator of the note to reclaim the note after a certain block height.
In our example, Alice will now send 50 tokens to 5 different accounts.
For the sake of the example, the first four P2ID transfers are handled in a single transaction, and the fifth transfer is a standard P2ID transfer.
Output multiple P2ID notes in a single transaction
To output multiple notes in a single transaction we need to create a list of our expected output notes. The expected output notes are the notes that we expect to create in our transaction request.
In the snippet below, we create an empty vector to store five P2ID output notes, loop over five iterations (using 0..=4)
to create five unique dummy account IDs, build a P2ID note for each one, and push each note onto the vector. Finally, we build a transaction request using .with_own_output_notes()
—passing in all five notes—and submit it to the node.
Add this snippet to the end of your file in the main()
function:
//------------------------------------------------------------
// STEP 5: Alice sends 5 notes of 50 tokens to 5 users
//------------------------------------------------------------
println!("\n[STEP 5] Alice sends 5 notes of 50 tokens each to 5 different users.");
// Send 50 tokens to 4 accounts in one transaction
println!("Creating multiple P2ID notes for 4 target accounts in one transaction...");
let mut p2id_notes = vec![];
for _ in 1..=4 {
let init_seed = {
let mut seed = [0u8; 15];
rand::thread_rng().fill(&mut seed);
seed[0] = 99u8;
seed
};
let target_account_id = AccountId::dummy(
init_seed,
AccountIdVersion::Version0,
AccountType::RegularAccountUpdatableCode,
AccountStorageMode::Public,
);
let send_amount = 50;
let fungible_asset = FungibleAsset::new(faucet_account.id(), send_amount).unwrap();
let p2id_note = create_p2id_note(
alice_account.id(),
target_account_id,
vec![fungible_asset.into()],
NoteType::Public,
Felt::new(0),
client.rng(),
)?;
p2id_notes.push(p2id_note);
}
let output_notes: Vec<OutputNote> = p2id_notes.into_iter().map(OutputNote::Full).collect();
let transaction_request = TransactionRequestBuilder::new()
.with_own_output_notes(output_notes)
.unwrap()
.build();
let tx_execution_result = client
.new_transaction(alice_account.id(), transaction_request)
.await?;
client.submit_transaction(tx_execution_result).await?;
println!("Submitted a transaction with 4 P2ID notes.");
Basic P2ID transfer
Now as an example, Alice will send some tokens to an account in a single transaction.
Add this snippet to the end of your file in the main()
function:
// Send 50 tokens to 1 more account as a single P2ID transaction
println!("Submitting one more single P2ID transaction...");
let init_seed = {
let mut seed = [0u8; 15];
rand::thread_rng().fill(&mut seed);
seed[0] = 99u8;
seed
};
let target_account_id = AccountId::dummy(
init_seed,
AccountIdVersion::Version0,
AccountType::RegularAccountUpdatableCode,
AccountStorageMode::Public,
);
let send_amount = 50;
let fungible_asset = FungibleAsset::new(faucet_account.id(), send_amount).unwrap();
let payment_transaction = PaymentTransactionData::new(
vec![fungible_asset.into()],
alice_account.id(),
target_account_id,
);
let transaction_request = TransactionRequestBuilder::pay_to_id(
payment_transaction,
None, // recall_height
NoteType::Public, // note type
client.rng(), // rng
)
.unwrap()
.build();
let tx_execution_result = client
.new_transaction(alice_account.id(), transaction_request)
.await?;
client.submit_transaction(tx_execution_result).await?;
Note: In a production environment do not use AccountId::new_dummy()
, this is simply for the sake of the tutorial example.
Summary
Your src/main.rs
function should now look like this:
use miden_client::{ account::{ component::{BasicFungibleFaucet, BasicWallet, RpoFalcon512}, AccountBuilder, AccountId, AccountStorageMode, AccountType, }, asset::{FungibleAsset, TokenSymbol}, auth::AuthSecretKey, crypto::{RpoRandomCoin, SecretKey}, note::NoteType, rpc::{Endpoint, TonicRpcClient}, store::{sqlite_store::SqliteStore, StoreAuthenticator}, transaction::{OutputNote, PaymentTransactionData, TransactionRequestBuilder}, Client, ClientError, Felt, }; use miden_lib::note::create_p2id_note; use miden_objects::account::AccountIdVersion; use rand::Rng; use std::sync::Arc; use tokio::time::Duration; pub async fn initialize_client() -> Result<Client<RpoRandomCoin>, ClientError> { // RPC endpoint and timeout let endpoint = Endpoint::new("http".to_string(), "localhost".to_string(), Some(57291)); let timeout_ms = 10_000; // Build RPC client let rpc_api = Box::new(TonicRpcClient::new(endpoint, timeout_ms)); // Seed RNG let mut seed_rng = rand::thread_rng(); let coin_seed: [u64; 4] = seed_rng.gen(); // Create random coin instance let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // SQLite path let store_path = "store.sqlite3"; // Initialize SQLite store let store = SqliteStore::new(store_path.into()) .await .map_err(ClientError::StoreError)?; let arc_store = Arc::new(store); // Create authenticator referencing the same store and RNG let authenticator = StoreAuthenticator::new_with_rng(arc_store.clone(), rng.clone()); // Instantiate the client. Toggle `in_debug_mode` as needed let client = Client::new(rpc_api, rng, arc_store, Arc::new(authenticator), true); Ok(client) } #[tokio::main] async fn main() -> Result<(), ClientError> { let mut client = initialize_client().await?; println!("Client initialized successfully."); let sync_summary = client.sync_state().await.unwrap(); let block_number = sync_summary.block_num; println!("Latest block number: {}", block_number); //------------------------------------------------------------ // STEP 1: Create a basic wallet for Alice //------------------------------------------------------------ println!("\n[STEP 1] Creating a new account for Alice"); // Account seed let mut init_seed = [0u8; 32]; client.rng().fill_bytes(&mut init_seed); // Generate key pair let key_pair = SecretKey::with_rng(client.rng()); // Anchor block let anchor_block = client.get_latest_epoch_block().await.unwrap(); // Build the account let builder = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::RegularAccountUpdatableCode) .storage_mode(AccountStorageMode::Public) .with_component(RpoFalcon512::new(key_pair.public_key())) .with_component(BasicWallet); let (alice_account, seed) = builder.build().unwrap(); // Add the account to the client client .add_account( &alice_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair), false, ) .await?; println!("Alice's account ID: {:?}", alice_account.id().to_hex()); //------------------------------------------------------------ // STEP 2: Deploy a fungible faucet //------------------------------------------------------------ println!("\n[STEP 2] Deploying a new fungible faucet."); // Faucet seed let mut init_seed = [0u8; 32]; client.rng().fill_bytes(&mut init_seed); // Faucet parameters let symbol = TokenSymbol::new("MID").unwrap(); let decimals = 8; let max_supply = Felt::new(1_000_000); // Generate key pair let key_pair = SecretKey::with_rng(client.rng()); // Build the account let builder = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::FungibleFaucet) .storage_mode(AccountStorageMode::Public) .with_component(RpoFalcon512::new(key_pair.public_key())) .with_component(BasicFungibleFaucet::new(symbol, decimals, max_supply).unwrap()); let (faucet_account, seed) = builder.build().unwrap(); // Add the faucet to the client client .add_account( &faucet_account, Some(seed), &AuthSecretKey::RpoFalcon512(key_pair), false, ) .await?; println!("Faucet account ID: {:?}", faucet_account.id().to_hex()); // Resync to show newly deployed faucet client.sync_state().await?; tokio::time::sleep(Duration::from_secs(2)).await; //------------------------------------------------------------ // STEP 3: Mint 5 notes of 100 tokens for Alice //------------------------------------------------------------ println!("\n[STEP 3] Minting 5 notes of 100 tokens each for Alice."); let amount: u64 = 100; let fungible_asset = FungibleAsset::new(faucet_account.id(), amount).unwrap(); for i in 1..=5 { let transaction_request = TransactionRequestBuilder::mint_fungible_asset( fungible_asset.clone(), alice_account.id(), NoteType::Public, client.rng(), ) .unwrap() .build(); let tx_execution_result = client .new_transaction(faucet_account.id(), transaction_request) .await?; client.submit_transaction(tx_execution_result).await?; println!("Minted note #{} of {} tokens for Alice.", i, amount); } println!("All 5 notes minted for Alice successfully!"); // Re-sync so minted notes become visible client.sync_state().await?; //------------------------------------------------------------ // STEP 4: Alice consumes all her notes //------------------------------------------------------------ println!("\n[STEP 4] Alice will now consume all of her notes to consolidate them."); // Consume all minted notes in a single transaction loop { // Resync to get the latest data client.sync_state().await?; let consumable_notes = client .get_consumable_notes(Some(alice_account.id())) .await?; let list_of_note_ids: Vec<_> = consumable_notes.iter().map(|(note, _)| note.id()).collect(); if list_of_note_ids.len() == 5 { println!("Found 5 consumable notes for Alice. Consuming them now..."); let transaction_request = TransactionRequestBuilder::consume_notes(list_of_note_ids).build(); let tx_execution_result = client .new_transaction(alice_account.id(), transaction_request) .await?; client.submit_transaction(tx_execution_result).await?; println!("All of Alice's notes consumed successfully."); break; } else { println!( "Currently, Alice has {} consumable notes. Waiting for 5...", list_of_note_ids.len() ); tokio::time::sleep(Duration::from_secs(3)).await; } } //------------------------------------------------------------ // STEP 5: Alice sends 5 notes of 50 tokens to 5 users //------------------------------------------------------------ println!("\n[STEP 5] Alice sends 5 notes of 50 tokens each to 5 different users."); // Send 50 tokens to 4 accounts in one transaction println!("Creating multiple P2ID notes for 4 target accounts in one transaction..."); let mut p2id_notes = vec![]; for _ in 1..=4 { let init_seed = { let mut seed = [0u8; 15]; rand::thread_rng().fill(&mut seed); seed[0] = 99u8; seed }; let target_account_id = AccountId::dummy( init_seed, AccountIdVersion::Version0, AccountType::RegularAccountUpdatableCode, AccountStorageMode::Public, ); let send_amount = 50; let fungible_asset = FungibleAsset::new(faucet_account.id(), send_amount).unwrap(); let p2id_note = create_p2id_note( alice_account.id(), target_account_id, vec![fungible_asset.into()], NoteType::Public, Felt::new(0), client.rng(), )?; p2id_notes.push(p2id_note); } let output_notes: Vec<OutputNote> = p2id_notes.into_iter().map(OutputNote::Full).collect(); let transaction_request = TransactionRequestBuilder::new() .with_own_output_notes(output_notes) .unwrap() .build(); let tx_execution_result = client .new_transaction(alice_account.id(), transaction_request) .await?; client.submit_transaction(tx_execution_result).await?; println!("Submitted a transaction with 4 P2ID notes."); // Send 50 tokens to 1 more account as a single P2ID transaction println!("Submitting one more single P2ID transaction..."); let init_seed = { let mut seed = [0u8; 15]; rand::thread_rng().fill(&mut seed); seed[0] = 99u8; seed }; let target_account_id = AccountId::dummy( init_seed, AccountIdVersion::Version0, AccountType::RegularAccountUpdatableCode, AccountStorageMode::Public, ); let send_amount = 50; let fungible_asset = FungibleAsset::new(faucet_account.id(), send_amount).unwrap(); let payment_transaction = PaymentTransactionData::new( vec![fungible_asset.into()], alice_account.id(), target_account_id, ); let transaction_request = TransactionRequestBuilder::pay_to_id( payment_transaction, None, // recall_height NoteType::Public, // note type client.rng(), // rng ) .unwrap() .build(); let tx_execution_result = client .new_transaction(alice_account.id(), transaction_request) .await?; client.submit_transaction(tx_execution_result).await?; println!("\nAll steps completed successfully!"); println!("Alice created a wallet, a faucet was deployed,"); println!("5 notes of 100 tokens were minted to Alice, those notes were consumed,"); println!("and then Alice sent 5 separate 50-token notes to 5 different users."); Ok(()) }
Let's run the src/main.rs
program again:
cargo run --release
The output will look like this:
Client initialized successfully.
Latest block number: 1519
[STEP 1] Creating a new account for Alice
Alice's account ID: "0xd0e8ba5acf2e83100000887188d2b9"
[STEP 2] Deploying a new fungible faucet.
Faucet account ID: "0xcdf877e221333a2000002e2b7ff0b2"
[STEP 3] Minting 5 notes of 100 tokens each for Alice.
Minted note #1 of 100 tokens for Alice.
Minted note #2 of 100 tokens for Alice.
Minted note #3 of 100 tokens for Alice.
Minted note #4 of 100 tokens for Alice.
Minted note #5 of 100 tokens for Alice.
All 5 notes minted for Alice successfully!
[STEP 4] Alice will now consume all of her notes to consolidate them.
Currently, Alice has 1 consumable notes. Waiting for 5...
Currently, Alice has 4 consumable notes. Waiting for 5...
Found 5 consumable notes for Alice. Consuming them now...
one or more warnings were emitted
All of Alice's notes consumed successfully.
[STEP 5] Alice sends 5 notes of 50 tokens each to 5 different users.
Creating multiple P2ID notes for 4 target accounts in one transaction...
Submitted a transaction with 4 P2ID notes.
Submitting one more single P2ID transaction...
All steps completed successfully!
Alice created a wallet, a faucet was deployed,
5 notes of 100 tokens were minted to Alice, those notes were consumed,
and then Alice sent 5 separate 50-token notes to 5 different users.
Running the example
To run a full working example navigate to the rust-client
directory in the miden-tutorials repository and run this command:
cd rust-client
cargo run --release --bin create_mint_consume_send
Continue learning
Next tutorial: Deploying a Counter Contract
Deploying a Counter Contract
Using the Miden client in Rust to deploy and interact with a custom smart contract on Miden
Overview
In this tutorial, we will build a simple counter smart contract that maintains a count, deploy it to the Miden testnet, and interact with it by incrementing the count. You can also deploy the counter contract on a locally running Miden node, similar to previous tutorials.
Using a script, we will invoke the increment function within the counter contract to update the count. This tutorial provides a foundational understanding of developing and deploying custom smart contracts on Miden.
What we'll cover
- Deploying a custom smart contract on Miden
- Getting up to speed with the basics of Miden assembly
- Calling procedures in an account
- Pure vs state changing procedures
Prerequisites
This tutorial assumes you have a basic understanding of Miden assembly. To quickly get up to speed with Miden assembly (MASM), please play around with running Miden programs in the Miden playground.
Step 1: Initialize your repository
Create a new Rust repository for your Miden project and navigate to it with the following command:
cargo new miden-counter-contract
cd miden-counter-contract
Add the following dependencies to your Cargo.toml
file:
[dependencies]
miden-client = { version = "0.7", features = ["testing", "concurrent", "tonic", "sqlite"] }
miden-lib = { version = "0.7", default-features = false }
miden-objects = { version = "0.7.2", default-features = false }
miden-crypto = { version = "0.13.2", features = ["executable"] }
rand = { version = "0.8" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "net", "macros"] }
rand_chacha = "0.3.1"
Set up your src/main.rs
file
In the previous section, we explained how to instantiate the Miden client. We can reuse the same initialize_client
function for our counter contract.
Copy and paste the following code into your src/main.rs
file:
use std::{fs, path::Path, sync::Arc}; use rand::Rng; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; use tokio::time::Duration; use miden_client::{ account::{AccountStorageMode, AccountType}, crypto::RpoRandomCoin, rpc::{Endpoint, TonicRpcClient}, store::{sqlite_store::SqliteStore, StoreAuthenticator}, transaction::{TransactionKernel, TransactionRequestBuilder}, Client, ClientError, Felt, }; use miden_objects::{ account::{AccountBuilder, AccountComponent, AuthSecretKey, StorageSlot}, assembly::Assembler, crypto::{dsa::rpo_falcon512::SecretKey, hash::rpo::RpoDigest}, Word, }; pub async fn initialize_client() -> Result<Client<RpoRandomCoin>, ClientError> { // RPC endpoint and timeout let endpoint = Endpoint::new( "https".to_string(), "rpc.testnet.miden.io".to_string(), Some(443), ); let timeout_ms = 10_000; // Build RPC client let rpc_api = Box::new(TonicRpcClient::new(endpoint, timeout_ms)); // Seed RNG let mut seed_rng = rand::thread_rng(); let coin_seed: [u64; 4] = seed_rng.gen(); // Create random coin instance let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // SQLite path let store_path = "store.sqlite3"; // Initialize SQLite store let store = SqliteStore::new(store_path.into()) .await .map_err(ClientError::StoreError)?; let arc_store = Arc::new(store); // Create authenticator referencing the store and RNG let authenticator = StoreAuthenticator::new_with_rng(arc_store.clone(), rng.clone()); // Instantiate client (toggle debug mode as needed) let client = Client::new(rpc_api, rng, arc_store, Arc::new(authenticator), true); Ok(client) } pub fn get_new_pk_and_authenticator() -> (Word, AuthSecretKey) { // Create a deterministic RNG with zeroed seed let seed = [0_u8; 32]; let mut rng = ChaCha20Rng::from_seed(seed); // Generate Falcon-512 secret key let sec_key = SecretKey::with_rng(&mut rng); // Convert public key to `Word` (4xFelt) let pub_key: Word = sec_key.public_key().into(); // Wrap secret key in `AuthSecretKey` let auth_secret_key = AuthSecretKey::RpoFalcon512(sec_key); (pub_key, auth_secret_key) } #[tokio::main] async fn main() -> Result<(), ClientError> { let mut client = initialize_client().await?; println!("Client initialized successfully."); let sync_summary = client.sync_state().await.unwrap(); println!("Latest block: {}", sync_summary.block_num); Ok(()) }
When running the code above, there will be some unused imports, however, we will use these imports later on in the tutorial.
Step 2: Build the counter contract
For better code organization, we will separate the Miden assembly code from our Rust code.
Create a directory named masm
at the root of your miden-counter-contract
directory. This will contain our contract and script masm code.
Initialize the masm
directory:
mkdir -p masm/accounts masm/scripts
This will create:
masm/
├── accounts/
└── scripts/
Custom Miden smart contract
Below is our counter contract. It has a two exported procedures: get_count
and increment_count
.
At the beginning of the MASM file, we define our imports. In this case, we import miden::account
and std::sys
.
The import miden::account
contains useful procedures for interacting with a smart contract's state.
The import std::sys
contains a useful procedure for truncating the operand stack at the end of a procedure.
Here's a breakdown of what the get_count
procedure does:
- Pushes
0
onto the stack, representing the index of the storage slot to read. - Calls
account::get_item
with the index of0
. - Calls
sys::truncate_stack
to truncate the stack to size 16. - The value returned from
account::get_item
is still on the stack and will be returned when this procedure is called.
Here's a breakdown of what the increment_count
procedure does:
- Pushes
0
onto the stack, representing the index of the storage slot to read. - Calls
account::get_item
with the index of0
. - Pushes
1
onto the stack. - Adds
1
to the count value returned fromaccount::get_item
. - For demonstration purposes, calls
debug.stack
to see the state of the stack - Pushes
0
onto the stack, which is the index of the storage slot we want to write to. - Calls
account::set_item
which saves the incremented count to storage at index0
- Calls
sys::truncate_stack
to truncate the stack to size 16.
Inside of the masm/accounts/
directory, create the counter.masm
file:
use.miden::account
use.std::sys
export.get_count
# => []
push.0
# => [index]
exec.account::get_item
# => [count]
exec.sys::truncate_stack
end
export.increment_count
# => []
push.0
# => [index]
exec.account::get_item
# => [count]
push.1 add
# debug statement with client
debug.stack
# => [count+1]
push.0
# [index, count+1]
exec.account::set_item
# => []
push.1 exec.account::incr_nonce
# => []
exec.sys::truncate_stack
end
Note: It's a good habit to add comments above each line of MASM code with the expected stack state. This improves readability and helps with debugging.
Concept of function visibility and modifiers in Miden smart contracts
The increment_count
function in our Miden smart contract behaves like an "external" Solidity function without a modifier, meaning any user can call it to increment the contract's count. This is because it calls account::incr_nonce
during execution.
If the increment_count
procedure did not call the account::incr_nonce
procedure during its execution, only the deployer of the counter contract would be able to increment the count of the smart contract (if the RpoFalcon512 component was added to the account, in this case we didn't add it).
In essence, if a procedure performs a state change in the Miden smart contract, and does not call account::incr_nonce
at some point during its execution, this function can be equated to having an onlyOwner
Solidity modifer, meaning only the user with knowledge of the private key of the account can execute transactions that result in a state change.
Note: Adding the account::incr_nonce
to a state changing procedure allows any user to call the procedure.
Custom script
This is a Miden assembly script that will call the increment_count
procedure during the transaction.
The string {increment_count}
will be replaced with the hash of the increment_count
procedure in our rust program.
Inside of the masm/scripts/
directory, create the counter_script.masm
file:
begin
# => []
call.{increment_count}
end
Step 3: Build the counter smart contract in Rust
To build the counter contract copy and paste the following code at the end of your src/main.rs
file:
#![allow(unused)] fn main() { // ------------------------------------------------------------------------- // STEP 1: Create a basic counter contract // ------------------------------------------------------------------------- println!("\n[STEP 1] Creating counter contract."); // Load the MASM file for the counter contract let file_path = Path::new("./masm/accounts/counter.masm"); let account_code = fs::read_to_string(file_path).unwrap(); // Prepare assembler (debug mode = true) let assembler: Assembler = TransactionKernel::assembler().with_debug_mode(true); // Compile the account code into `AccountComponent` with one storage slot let counter_component = AccountComponent::compile( account_code, assembler, vec![StorageSlot::Value(Word::default())], ) .unwrap() .with_supports_all_types(); // Init seed for the counter contract let init_seed = ChaCha20Rng::from_entropy().gen(); // Anchor block of the account let anchor_block = client.get_latest_epoch_block().await.unwrap(); // Build the new `Account` with the component let (counter_contract, counter_seed) = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::RegularAccountImmutableCode) .storage_mode(AccountStorageMode::Public) .with_component(counter_component.clone()) .build() .unwrap(); println!( "counter_contract hash: {:?}", counter_contract.hash().to_hex() ); println!("contract id: {:?}", counter_contract.id().to_hex()); // Since anyone should be able to write to the counter contract, auth_secret_key is not required. // However, to import to the client, we must generate a random value. let (_counter_pub_key, auth_secret_key) = get_new_pk_and_authenticator(); client .add_account( &counter_contract.clone(), Some(counter_seed), &auth_secret_key, false, ) .await .unwrap(); }
Run the following command to execute src/main.rs:
cargo run --release
After the program executes, you should see the counter contract hash and contract id printed to the terminal, for example:
counter_contract hash: "0xd693494753f51cb73a436916077c7b71c680a6dddc64dc364c1fe68f16f0c087"
contract id: "0x082ed14c8ad9a866"
Step 4: Computing the prodedure hashes
Each Miden assembly procedure has an associated hash. When calling a procedure in a smart contract, we need to know the hash of the procedure. The hashes of the procedures form a Merkelized Abstract Syntax Tree (MAST).
To get the hash of the increment_count
procedure, add this code snippet to the end of your main()
function:
#![allow(unused)] fn main() { // Print the procedure root hash let get_increment_export = counter_component .library() .exports() .find(|export| export.name.as_str() == "increment_count") .unwrap(); let get_increment_count_mast_id = counter_component .library() .get_export_node_id(get_increment_export); let increment_count_hash = counter_component .library() .mast_forest() .get_node_by_id(get_increment_count_mast_id) .unwrap() .digest().to_hex(); println!("increment_count procedure hash: {:?}", increment_count_hash); }
Run the following command to execute src/main.rs:
cargo run --release
After the program executes, you should see the procedure hashes printed to the terminal, for example:
increment_count procedure hash: "0xecd7eb223a5524af0cc78580d96357b298bb0b3d33fe95aeb175d6dab9de2e54"
This is the hash of the increment_count
procedure.
Step 4: Incrementing the count
Now that we know the hash of the increment_count
procedure, we can call the procedure in the counter contract. In the Rust code below, we replace the {increment_count}
string with the hash of the increment_count
procedure.
Then we create a new transaction request with our custom script, and then pass the transaction request to the client.
Paste the following code at the end of your src/main.rs
file:
#![allow(unused)] fn main() { // ------------------------------------------------------------------------- // STEP 2: Call the Counter Contract with a script // ------------------------------------------------------------------------- println!("\n[STEP 2] Call Counter Contract With Script"); // Load the MASM script referencing the increment procedure let file_path = Path::new("./masm/scripts/counter_script.masm"); let original_code = fs::read_to_string(file_path).unwrap(); // Replace the placeholder with the actual procedure call let replaced_code = original_code.replace("{increment_count}", &increment_count_hash); println!("Final script:\n{}", replaced_code); // Compile the script referencing our procedure let tx_script = client.compile_tx_script(vec![], &replaced_code).unwrap(); // Build a transaction request with the custom script let tx_increment_request = TransactionRequestBuilder::new() .with_custom_script(tx_script) .unwrap() .build(); // Execute the transaction locally let tx_result = client .new_transaction(counter_contract.id(), tx_increment_request) .await .unwrap(); let tx_id = tx_result.executed_transaction().id(); println!( "View transaction on MidenScan: https://testnet.midenscan.com/tx/{:?}", tx_id ); // Submit transaction to the network let _ = client.submit_transaction(tx_result).await; // Wait, then re-sync tokio::time::sleep(Duration::from_secs(3)).await; client.sync_state().await.unwrap(); // Retrieve updated contract data to see the incremented counter let account = client.get_account(counter_contract.id()).await.unwrap(); println!( "counter contract storage: {:?}", account.unwrap().account().storage().get_item(0) ); }
Note: Once our counter contract is deployed, other users can increment the count of the smart contract simply by knowing the account id of the contract and the procedure hash of the increment_count
procedure.
Summary
The final src/main.rs
file should look like this:
use std::{fs, path::Path, sync::Arc}; use rand::Rng; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; use tokio::time::Duration; use miden_client::{ account::{AccountStorageMode, AccountType}, crypto::RpoRandomCoin, rpc::{Endpoint, TonicRpcClient}, store::{sqlite_store::SqliteStore, StoreAuthenticator}, transaction::{TransactionKernel, TransactionRequestBuilder}, Client, ClientError, Felt, }; use miden_objects::{ account::{AccountBuilder, AccountComponent, AuthSecretKey, StorageSlot}, assembly::Assembler, crypto::dsa::rpo_falcon512::SecretKey, Word, }; pub async fn initialize_client() -> Result<Client<RpoRandomCoin>, ClientError> { // RPC endpoint and timeout let endpoint = Endpoint::new( "https".to_string(), "rpc.testnet.miden.io".to_string(), Some(443), ); let timeout_ms = 10_000; // Build RPC client let rpc_api = Box::new(TonicRpcClient::new(endpoint, timeout_ms)); // Seed RNG let mut seed_rng = rand::thread_rng(); let coin_seed: [u64; 4] = seed_rng.gen(); // Create random coin instance let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // SQLite path let store_path = "store.sqlite3"; // Initialize SQLite store let store = SqliteStore::new(store_path.into()) .await .map_err(ClientError::StoreError)?; let arc_store = Arc::new(store); // Create authenticator referencing the store and RNG let authenticator = StoreAuthenticator::new_with_rng(arc_store.clone(), rng.clone()); // Instantiate client (toggle debug mode as needed) let client = Client::new(rpc_api, rng, arc_store, Arc::new(authenticator), true); Ok(client) } pub fn get_new_pk_and_authenticator() -> (Word, AuthSecretKey) { // Create a deterministic RNG with zeroed seed let seed = [0_u8; 32]; let mut rng = ChaCha20Rng::from_seed(seed); // Generate Falcon-512 secret key let sec_key = SecretKey::with_rng(&mut rng); // Convert public key to `Word` (4xFelt) let pub_key: Word = sec_key.public_key().into(); // Wrap secret key in `AuthSecretKey` let auth_secret_key = AuthSecretKey::RpoFalcon512(sec_key); (pub_key, auth_secret_key) } #[tokio::main] async fn main() -> Result<(), ClientError> { // Initialize client let mut client = initialize_client().await?; println!("Client initialized successfully."); // Fetch latest block from node let sync_summary = client.sync_state().await.unwrap(); println!("Latest block: {}", sync_summary.block_num); // ------------------------------------------------------------------------- // STEP 1: Create a basic counter contract // ------------------------------------------------------------------------- println!("\n[STEP 1] Creating counter contract."); // Load the MASM file for the counter contract let file_path = Path::new("./masm/accounts/counter.masm"); let account_code = fs::read_to_string(file_path).unwrap(); // Prepare assembler (debug mode = true) let assembler: Assembler = TransactionKernel::assembler().with_debug_mode(true); // Compile the account code into `AccountComponent` with one storage slot let counter_component = AccountComponent::compile( account_code, assembler, vec![StorageSlot::Value([ Felt::new(0), Felt::new(0), Felt::new(0), Felt::new(0), ])], ) .unwrap() .with_supports_all_types(); // Init seed for the counter contract let init_seed = ChaCha20Rng::from_entropy().gen(); // Anchor block of the account let anchor_block = client.get_latest_epoch_block().await.unwrap(); // Build the new `Account` with the component let (counter_contract, counter_seed) = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::RegularAccountImmutableCode) .storage_mode(AccountStorageMode::Public) .with_component(counter_component.clone()) .build() .unwrap(); println!( "counter_contract hash: {:?}", counter_contract.hash().to_hex() ); println!("contract id: {:?}", counter_contract.id().to_hex()); println!("account_storage: {:?}", counter_contract.storage()); // Since anyone should be able to write to the counter contract, auth_secret_key is not required. // However, to import to the client, we must generate a random value. let (_counter_pub_key, auth_secret_key) = get_new_pk_and_authenticator(); client .add_account( &counter_contract.clone(), Some(counter_seed), &auth_secret_key, false, ) .await .unwrap(); // Print the procedure root hash let get_increment_export = counter_component .library() .exports() .find(|export| export.name.as_str() == "increment_count") .unwrap(); let get_increment_count_mast_id = counter_component .library() .get_export_node_id(get_increment_export); let increment_count_hash = counter_component .library() .mast_forest() .get_node_by_id(get_increment_count_mast_id) .unwrap() .digest() .to_hex(); println!("increment_count procedure hash: {:?}", increment_count_hash); // ------------------------------------------------------------------------- // STEP 2: Call the Counter Contract with a script // ------------------------------------------------------------------------- println!("\n[STEP 2] Call Counter Contract With Script"); // Load the MASM script referencing the increment procedure let file_path = Path::new("./masm/scripts/counter_script.masm"); let original_code = fs::read_to_string(file_path).unwrap(); // Replace the placeholder with the actual procedure call let replaced_code = original_code.replace("{increment_count}", &increment_count_hash); println!("Final script:\n{}", replaced_code); // Compile the script referencing our procedure let tx_script = client.compile_tx_script(vec![], &replaced_code).unwrap(); // Build a transaction request with the custom script let tx_increment_request = TransactionRequestBuilder::new() .with_custom_script(tx_script) .unwrap() .build(); // Execute the transaction locally let tx_result = client .new_transaction(counter_contract.id(), tx_increment_request) .await .unwrap(); let tx_id = tx_result.executed_transaction().id(); println!( "View transaction on MidenScan: https://testnet.midenscan.com/tx/{:?}", tx_id ); // Submit transaction to the network let _ = client.submit_transaction(tx_result).await; // Wait, then re-sync tokio::time::sleep(Duration::from_secs(3)).await; client.sync_state().await.unwrap(); // Retrieve updated contract data to see the incremented counter let account = client.get_account(counter_contract.id()).await.unwrap(); println!( "counter contract storage: {:?}", account.unwrap().account().storage().get_item(0) ); Ok(()) }
The output of our program will look something like this:
Client initialized successfully.
Latest block: 118178
[STEP 1] Creating counter contract.
counter_contract hash: "0xa1802c8cfba2bd9c1c0f0b10b875795445566bd61864a05103bdaff167775293"
contract id: "0x4eedb9db1bdcf90000036bcebfe53a"
account_storage: AccountStorage { slots: [Value([0, 0, 0, 0])] }
increment_count procedure hash: "0xecd7eb223a5524af0cc78580d96357b298bb0b3d33fe95aeb175d6dab9de2e54"
[STEP 2] Call Counter Contract With Script
Final script:
begin
# => []
call.0xecd7eb223a5524af0cc78580d96357b298bb0b3d33fe95aeb175d6dab9de2e54
end
Stack state before step 2384:
├── 0: 1
├── 1: 0
├── 2: 0
├── 3: 0
├── 4: 0
├── 5: 0
├── 6: 0
├── 7: 0
├── 8: 0
├── 9: 0
├── 10: 0
├── 11: 0
├── 12: 0
├── 13: 0
├── 14: 0
├── 15: 0
├── 16: 0
├── 17: 0
├── 18: 0
└── 19: 0
View transaction on MidenScan: https://testnet.midenscan.com/tx/0x4384619bba7e6c959a31769a52ce8c6c081ffab00be33e85f58f62cccfd32c21
counter contract storage: Ok(RpoDigest([0, 0, 0, 1]))
The line in the output Stack state before step 2384
ouputs the stack state when we call "debug.stack" in the counter.masm
file.
To increment the count of the counter contract all you need is to know the account id of the counter and the procedure hash of the increment_count
procedure. To increment the count without deploying the counter each time, you can modify the program above to hardcode the account id of the counter and the procedure hash of the increment_count
prodedure in the masm script.
Running the example
To run the full example, navigate to the rust-client
directory in the miden-tutorials repository and run this command:
cd rust-client
cargo run --release --bin counter_contract_deploy
Continue learning
Next tutorial: Interacting with Public Smart Contracts
Interacting with Public Smart Contracts
Using the Miden client in Rust to interact with public smart contracts on Miden
Overview
In the previous tutorial, we built a simple counter contract and deployed it to the Miden testnet. However, we only covered how the contract’s deployer could interact with it. Now, let’s explore how anyone can interact with a public smart contract on Miden.
We’ll retrieve the counter contract’s state from the chain and rebuild it locally so a local transaction can be executed against it. In the near future, Miden will support network transactions, making the process of submitting transactions to public smart contracts much more like traditional blockchains.
Just like in the previous tutorial, we will use a script to invoke the increment function within the counter contract to update the count. However, this tutorial demonstrates how to call a procedure in a smart contract that was deployed by a different user on Miden.
What we'll cover
- Reading state from a public smart contract
- Interacting with public smart contracts on Miden
Prerequisites
This tutorial assumes you have a basic understanding of Miden assembly and completed the previous tutorial on deploying the counter contract. Although not a requirement, it is recommended to complete the counter contract deployment tutorial before starting this tutorial.
Step 1: Initialize your repository
Create a new Rust repository for your Miden project and navigate to it with the following command:
cargo new miden-counter-contract
cd miden-counter-contract
Add the following dependencies to your Cargo.toml
file:
[dependencies]
miden-client = { version = "0.7", features = ["testing", "concurrent", "tonic", "sqlite"] }
miden-lib = { version = "0.7", default-features = false }
miden-objects = { version = "0.7.2", default-features = false }
miden-crypto = { version = "0.13.2", features = ["executable"] }
rand = { version = "0.8" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0", features = ["raw_value"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "net", "macros"] }
rand_chacha = "0.3.1"
Step 2: Build the counter contract
For better code organization, we will separate the Miden assembly code from our Rust code.
Create a directory named masm
at the root of your miden-counter-contract
directory. This will contain our contract and script masm code.
Initialize the masm
directory:
mkdir -p masm/accounts masm/scripts
This will create:
masm/
├── accounts/
└── scripts/
Inside of the masm/accounts/
directory, create the counter.masm
file:
use.miden::account
use.std::sys
export.get_count
# => []
push.0
# => [index]
exec.account::get_item
# => [count]
exec.sys::truncate_stack
end
export.increment_count
# => []
push.0
# => [index]
exec.account::get_item
# => [count]
push.1 add
# debug statement with client
debug.stack
# => [count+1]
push.0
# [index, count+1]
exec.account::set_item
# => []
push.1 exec.account::incr_nonce
# => []
exec.sys::truncate_stack
end
Inside of the masm/scripts/
directory, create the counter_script.masm
file:
begin
# => []
call.{increment_count}
end
Note: We explained in the previous counter contract tutorial what exactly happens at each step in the increment_count
procedure.
Step 3: Set up your src/main.rs
file
Copy and paste the following code into your src/main.rs
file:
use std::{fs, path::Path, sync::Arc}; use rand::Rng; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; use tokio::time::Duration; use miden_client::{ account::{Account, AccountCode, AccountId, AccountType}, asset::AssetVault, crypto::RpoRandomCoin, rpc::{domain::account::AccountDetails, Endpoint, TonicRpcClient}, store::{sqlite_store::SqliteStore, StoreAuthenticator}, transaction::{TransactionKernel, TransactionRequestBuilder}, Client, ClientError, Felt, }; use miden_objects::{ account::{AccountComponent, AccountStorage, AuthSecretKey, StorageSlot}, assembly::Assembler, crypto::dsa::rpo_falcon512::SecretKey, Word, }; pub async fn initialize_client() -> Result<Client<RpoRandomCoin>, ClientError> { // RPC endpoint and timeout let endpoint = Endpoint::new( "https".to_string(), "rpc.testnet.miden.io".to_string(), Some(443), ); let timeout_ms = 10_000; // Build RPC client let rpc_api = Box::new(TonicRpcClient::new(endpoint, timeout_ms)); // Seed RNG let mut seed_rng = rand::thread_rng(); let coin_seed: [u64; 4] = seed_rng.gen(); // Create random coin instance let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // SQLite path let store_path = "store.sqlite3"; // Initialize SQLite store let store = SqliteStore::new(store_path.into()) .await .map_err(ClientError::StoreError)?; let arc_store = Arc::new(store); // Create authenticator referencing the store and RNG let authenticator = StoreAuthenticator::new_with_rng(arc_store.clone(), rng.clone()); // Instantiate client (toggle debug mode as needed) let client = Client::new(rpc_api, rng, arc_store, Arc::new(authenticator), true); Ok(client) } pub fn get_new_pk_and_authenticator() -> (Word, AuthSecretKey) { // Create a deterministic RNG with zeroed seed let seed = [0_u8; 32]; let mut rng = ChaCha20Rng::from_seed(seed); // Generate Falcon-512 secret key let sec_key = SecretKey::with_rng(&mut rng); // Convert public key to `Word` (4xFelt) let pub_key: Word = sec_key.public_key().into(); // Wrap secret key in `AuthSecretKey` let auth_secret_key = AuthSecretKey::RpoFalcon512(sec_key); (pub_key, auth_secret_key) } #[tokio::main] async fn main() -> Result<(), ClientError> { // Initialize client let mut client = initialize_client().await?; println!("Client initialized successfully."); // Fetch latest block from node let sync_summary = client.sync_state().await.unwrap(); println!("Latest block: {}", sync_summary.block_num); Ok(()) }
Step 4: Reading public state from a smart contract
To read the public storage state of a smart contract on Miden we either instantiate the TonicRpcClient
by itself, or use the test_rpc_api()
method on the Client
instance. In this example, we will be using the test_rpc_api()
method.
We will be reading the public storage state of the counter contract deployed on the testnet at address 0x303dd027d27adc0000012b07dbf1b4
.
Add the following code snippet to the end of your src/main.rs
function:
#![allow(unused)] fn main() { // ------------------------------------------------------------------------- // STEP 1: Read the Public State of the Counter Contract // ------------------------------------------------------------------------- println!("\n[STEP 1] Reading data from public state"); // Define the AccountId of the account to read from let counter_contract_id = AccountId::from_hex("0x4eedb9db1bdcf90000036bcebfe53a").unwrap(); let account_details = client .test_rpc_api() .get_account_update(counter_contract_id) .await .unwrap(); let AccountDetails::Public(counter_contract_details, _) = account_details else { panic!("counter contract must be public"); }; // Getting the value of the count from slot 0 and the nonce of the counter contract let count_value = counter_contract_details.storage().slots().get(0).unwrap(); let counter_nonce = counter_contract_details.nonce(); println!("count val: {:?}", count_value.value()); println!("counter nonce: {:?}", counter_nonce); }
Run the following command to execute src/main.rs:
cargo run --release
After the program executes, you should see the counter contract count value and nonce printed to the terminal, for example:
count val: [0, 0, 0, 5]
counter nonce: 5
Step 5: Building an account from parts
Now that we know the storage state of the counter contract and its nonce, we can build the account from its parts. We know the account ID, asset vault value, the storage layout, account code, and nonce. We need the full account data to interact with it locally. From these values, we can build the counter contract from scratch.
Add the following code snippet to the end of your src/main.rs
function:
#![allow(unused)] fn main() { // ------------------------------------------------------------------------- // STEP 2: Build the Counter Contract // ------------------------------------------------------------------------- println!("\n[STEP 2] Building the counter contract"); // Load the MASM file for the counter contract let file_path = Path::new("./masm/accounts/counter.masm"); let account_code = fs::read_to_string(file_path).unwrap(); // Prepare assembler (debug mode = true) let assembler: Assembler = TransactionKernel::assembler().with_debug_mode(true); // Compile the account code into `AccountComponent` with the count value returned by the node let account_component = AccountComponent::compile( account_code, assembler, vec![StorageSlot::Value(count_value.value())], ) .unwrap() .with_supports_all_types(); // Initialize the AccountStorage with the count value returned by the node let account_storage = AccountStorage::new(vec![StorageSlot::Value(count_value.value())]).unwrap(); // Build AccountCode from components let account_code = AccountCode::from_components( &[account_component], AccountType::RegularAccountImmutableCode, ) .unwrap(); // The counter contract doesn't have any assets so we pass an empty vector let vault = AssetVault::new(&[]).unwrap(); // Build the counter contract from parts let counter_contract = Account::from_parts( counter_contract_id, vault, account_storage, account_code, counter_nonce, ); // Since anyone should be able to write to the counter contract, auth_secret_key is not required. // However, to import to the client, we must generate a random value. let (_, _auth_secret_key) = get_new_pk_and_authenticator(); client .add_account(&counter_contract.clone(), None, &_auth_secret_key, true) .await .unwrap(); }
Step 6: Incrementing the count
This step is exactly the same as in the counter contract deploy tutorial, the only change being that we hardcode the increment_count
procedure hash since this value will not change.
Add the following code snippet to the end of your src/main.rs
function:
#![allow(unused)] fn main() { // ------------------------------------------------------------------------- // STEP 3: Call the Counter Contract with a script // ------------------------------------------------------------------------- println!("\n[STEP 3] Call the increment_count procedure in the counter contract"); // The increment_count procedure hash is constant let increment_procedure = "0xecd7eb223a5524af0cc78580d96357b298bb0b3d33fe95aeb175d6dab9de2e54"; // Load the MASM script referencing the increment procedure let file_path = Path::new("./masm/scripts/counter_script.masm"); let original_code = fs::read_to_string(file_path).unwrap(); // Replace the placeholder with the actual procedure call let replaced_code = original_code.replace("{increment_count}", &increment_procedure); println!("Final script:\n{}", replaced_code); // Compile the script let tx_script = client.compile_tx_script(vec![], &replaced_code).unwrap(); // Build a transaction request with the custom script let tx_increment_request = TransactionRequestBuilder::new() .with_custom_script(tx_script) .unwrap() .build(); // Execute the transaction locally let tx_result = client .new_transaction(counter_contract.id(), tx_increment_request) .await .unwrap(); let tx_id = tx_result.executed_transaction().id(); println!( "View transaction on MidenScan: https://testnet.midenscan.com/tx/{:?}", tx_id ); // Submit transaction to the network let _ = client.submit_transaction(tx_result).await; // Wait, then re-sync tokio::time::sleep(Duration::from_secs(3)).await; client.sync_state().await.unwrap(); // Retrieve updated contract data to see the incremented counter let account = client.get_account(counter_contract.id()).await.unwrap(); println!( "counter contract storage: {:?}", account.unwrap().account().storage().get_item(0) ); }
Summary
The final src/main.rs
file should look like this:
use std::{fs, path::Path, sync::Arc}; use rand::Rng; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; use tokio::time::Duration; use miden_client::{ account::{Account, AccountCode, AccountId, AccountType}, asset::AssetVault, crypto::RpoRandomCoin, rpc::{domain::account::AccountDetails, Endpoint, TonicRpcClient}, store::{sqlite_store::SqliteStore, StoreAuthenticator}, transaction::{TransactionKernel, TransactionRequestBuilder}, Client, ClientError, Felt, }; use miden_objects::{ account::{AccountComponent, AccountStorage, AuthSecretKey, StorageSlot}, assembly::Assembler, crypto::dsa::rpo_falcon512::SecretKey, Word, }; pub async fn initialize_client() -> Result<Client<RpoRandomCoin>, ClientError> { // RPC endpoint and timeout let endpoint = Endpoint::new( "https".to_string(), "rpc.testnet.miden.io".to_string(), Some(443), ); let timeout_ms = 10_000; // Build RPC client let rpc_api = Box::new(TonicRpcClient::new(endpoint, timeout_ms)); // Seed RNG let mut seed_rng = rand::thread_rng(); let coin_seed: [u64; 4] = seed_rng.gen(); // Create random coin instance let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // SQLite path let store_path = "store.sqlite3"; // Initialize SQLite store let store = SqliteStore::new(store_path.into()) .await .map_err(ClientError::StoreError)?; let arc_store = Arc::new(store); // Create authenticator referencing the store and RNG let authenticator = StoreAuthenticator::new_with_rng(arc_store.clone(), rng.clone()); // Instantiate client (toggle debug mode as needed) let client = Client::new(rpc_api, rng, arc_store, Arc::new(authenticator), true); Ok(client) } pub fn get_new_pk_and_authenticator() -> (Word, AuthSecretKey) { // Create a deterministic RNG with zeroed seed let seed = [0_u8; 32]; let mut rng = ChaCha20Rng::from_seed(seed); // Generate Falcon-512 secret key let sec_key = SecretKey::with_rng(&mut rng); // Convert public key to `Word` (4xFelt) let pub_key: Word = sec_key.public_key().into(); // Wrap secret key in `AuthSecretKey` let auth_secret_key = AuthSecretKey::RpoFalcon512(sec_key); (pub_key, auth_secret_key) } #[tokio::main] async fn main() -> Result<(), ClientError> { // Initialize client let mut client = initialize_client().await?; println!("Client initialized successfully."); // Fetch latest block from node let sync_summary = client.sync_state().await.unwrap(); println!("Latest block: {}", sync_summary.block_num); // ------------------------------------------------------------------------- // STEP 1: Read the Public State of the Counter Contract // ------------------------------------------------------------------------- println!("\n[STEP 1] Reading data from public state"); // Define the Counter Contract account id from counter contract deploy let counter_contract_id = AccountId::from_hex("0x4eedb9db1bdcf90000036bcebfe53a").unwrap(); let account_details = client .test_rpc_api() .get_account_update(counter_contract_id) .await .unwrap(); let AccountDetails::Public(counter_contract_details, _) = account_details else { panic!("counter contract must be public"); }; // Getting the value of the count from slot 0 and the nonce of the counter contract let count_value = counter_contract_details.storage().slots().get(0).unwrap(); let counter_nonce = counter_contract_details.nonce(); println!("count val: {:?}", count_value.value()); println!("counter nonce: {:?}", counter_nonce); // ------------------------------------------------------------------------- // STEP 2: Build the Counter Contract // ------------------------------------------------------------------------- println!("\n[STEP 2] Building the counter contract"); // Load the MASM file for the counter contract let file_path = Path::new("./masm/accounts/counter.masm"); let account_code = fs::read_to_string(file_path).unwrap(); // Prepare assembler (debug mode = true) let assembler: Assembler = TransactionKernel::assembler().with_debug_mode(true); // Compile the account code into `AccountComponent` with the count value returned by the node let account_component = AccountComponent::compile( account_code, assembler, vec![StorageSlot::Value(count_value.value())], ) .unwrap() .with_supports_all_types(); // Initialize the AccountStorage with the count value returned by the node let account_storage = AccountStorage::new(vec![StorageSlot::Value(count_value.value())]).unwrap(); // Build AccountCode from components let account_code = AccountCode::from_components( &[account_component], AccountType::RegularAccountImmutableCode, ) .unwrap(); // The counter contract doesn't have any assets so we pass an empty vector let vault = AssetVault::new(&[]).unwrap(); // Build the counter contract from parts let counter_contract = Account::from_parts( counter_contract_id, vault, account_storage, account_code, counter_nonce, ); // Since anyone should be able to write to the counter contract, auth_secret_key is not required. // However, to import to the client, we must generate a random value. let (_, _auth_secret_key) = get_new_pk_and_authenticator(); client .add_account(&counter_contract.clone(), None, &_auth_secret_key, true) .await .unwrap(); // ------------------------------------------------------------------------- // STEP 3: Call the Counter Contract with a script // ------------------------------------------------------------------------- println!("\n[STEP 3] Call the increment_count procedure in the counter contract"); // The increment_count procedure hash is constant let increment_procedure = "0xecd7eb223a5524af0cc78580d96357b298bb0b3d33fe95aeb175d6dab9de2e54"; // Load the MASM script referencing the increment procedure let file_path = Path::new("./masm/scripts/counter_script.masm"); let original_code = fs::read_to_string(file_path).unwrap(); // Replace the placeholder with the actual procedure call let replaced_code = original_code.replace("{increment_count}", &increment_procedure); println!("Final script:\n{}", replaced_code); // Compile the script let tx_script = client.compile_tx_script(vec![], &replaced_code).unwrap(); // Build a transaction request with the custom script let tx_increment_request = TransactionRequestBuilder::new() .with_custom_script(tx_script) .unwrap() .build(); // Execute the transaction locally let tx_result = client .new_transaction(counter_contract.id(), tx_increment_request) .await .unwrap(); let tx_id = tx_result.executed_transaction().id(); println!( "View transaction on MidenScan: https://testnet.midenscan.com/tx/{:?}", tx_id ); // Submit transaction to the network let _ = client.submit_transaction(tx_result).await; // Wait, then re-sync tokio::time::sleep(Duration::from_secs(3)).await; client.sync_state().await.unwrap(); // Retrieve updated contract data to see the incremented counter let account = client.get_account(counter_contract.id()).await.unwrap(); println!( "counter contract storage: {:?}", account.unwrap().account().storage().get_item(0) ); Ok(()) }
Run the following command to execute src/main.rs:
cargo run --release
The output of our program will look something like this depending on the current count value in the smart contract:
Client initialized successfully.
Latest block: 242342
[STEP 1] Building counter contract from public state
count val: [0, 0, 0, 1]
counter nonce: 1
[STEP 2] Call the increment_count procedure in the counter contract
Procedure 1: "0x92495ca54d519eb5e4ba22350f837904d3895e48d74d8079450f19574bb84cb6"
Procedure 2: "0xecd7eb223a5524af0cc78580d96357b298bb0b3d33fe95aeb175d6dab9de2e54"
number of procedures: 2
Final script:
begin
# => []
call.0xecd7eb223a5524af0cc78580d96357b298bb0b3d33fe95aeb175d6dab9de2e54
end
Stack state before step 1812:
├── 0: 2
├── 1: 0
├── 2: 0
├── 3: 0
├── 4: 0
├── 5: 0
├── 6: 0
├── 7: 0
├── 8: 0
├── 9: 0
├── 10: 0
├── 11: 0
├── 12: 0
├── 13: 0
├── 14: 0
├── 15: 0
├── 16: 0
├── 17: 0
├── 18: 0
└── 19: 0
View transaction on MidenScan: https://testnet.midenscan.com/tx/0x8183aed150f20b9c26d4cb7840bfc92571ea45ece31116170b11cdff2649eb5c
counter contract storage: Ok(RpoDigest([0, 0, 0, 2]))
Running the example
To run the full example, navigate to the rust-client
directory in the miden-tutorials repository and run this command:
cd rust-client
cargo run --release --bin counter_contract_increment
Continue learning
Next tutorial: Foreign Procedure Invocation
Foreign Procedure Invocation Tutorial
Using foreign procedure invocation to craft read-only cross-contract calls in the Miden VM
Overview
In previous tutorials we deployed a public counter contract and incremented the count from a different client instance.
In this tutorial we will cover the basics of "foreign procedure invocation" (FPI) in the Miden VM, by building a "Count Copy" smart contract that reads the count from our previously deployed counter contract and copies the count to its own local storage.
Foreign procedure invocation (FPI) is a powerful tool for building smart contracts in the Miden VM. FPI allows one smart contract to call "read-only" procedures in other smart contracts.
The term "foreign procedure invocation" might sound a bit verbose, but it is as simple as one smart contract calling a non-state modifying procedure in another smart contract. The "EVM equivalent" of foreign procedure invocation would be a smart contract calling a read-only function in another contract.
FPI is useful for developing smart contracts that extend the functionality of existing contracts on Miden. FPI is the core primitive used by price oracles on Miden.
What we'll cover
- Foreign Procedure Invocation (FPI)
- Building a "Count Copy" Smart Contract
Prerequisites
This tutorial assumes you have a basic understanding of Miden assembly and completed the previous tutorial on deploying the counter contract. We will be working within the same miden-counter-contract
repository that we created in the Interacting with Public Smart Contracts tutorial.
Step 1: Set up your repository
We will be using the same repository used in the "Interacting with Public Smart Contracts" tutorial. To set up your repository for this tutorial, first follow up until step two here.
Step 2: Set up the "count reader" contract
Inside of the masm/accounts/
directory, create the count_reader.masm
file. This is the smart contract that will read the "count" value from the counter contract.
masm/accounts/count_reader.masm
:
use.miden::account
use.miden::tx
use.std::sys
# Reads the count from the counter contract
# and then copies the value to storage
export.copy_count
# => []
push.{get_count_proc_hash}
# => [GET_COUNT_HASH]
push.{account_id_suffix}
# => [account_id_suffix]
push.{account_id_prefix}
# => [account_id_prefix, account_id_suffix, GET_COUNT_HASH]
exec.tx::execute_foreign_procedure
# => [count]
debug.stack
# => [count]
push.0
# [index, count]
exec.account::set_item
# => []
push.1 exec.account::incr_nonce
# => []
exec.sys::truncate_stack
end
In the count reader smart contract we have a copy_count
procedure that uses tx::execute_foreign_procedure
to call the get_count
procedure in the counter contract.
To call the get_count
procedure, we push its hash along with the counter contract's ID suffix and prefix.
This is what the stack state should look like before we call tx::execute_foreign_procedure
:
# => [account_id_prefix, account_id_suffix, GET_COUNT_HASH]
After calling the get_count
procedure in the counter contract, we call debug.stack
and then save the count of the counter contract to index 0 in storage.
Note: The bracket symbols used in the count copy contract are not valid MASM syntax. These are simply placeholder elements that we will replace with the actual values before compilation.
Inside the masm/scripts/
directory, create the reader_script.masm
file:
begin
# => []
call.{copy_count}
end
Step 3: Set up your src/main.rs
file:
use std::{fs, path::Path, sync::Arc}; use rand::Rng; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; use tokio::time::Duration; use miden_client::{ account::{Account, AccountCode, AccountId, AccountStorageMode, AccountType}, asset::AssetVault, crypto::RpoRandomCoin, rpc::{ domain::account::{AccountDetails, AccountStorageRequirements}, Endpoint, TonicRpcClient, }, store::{sqlite_store::SqliteStore, StoreAuthenticator}, transaction::{ForeignAccount, TransactionKernel, TransactionRequestBuilder}, Client, ClientError, Felt, }; use miden_objects::{ account::{AccountBuilder, AccountComponent, AccountStorage, AuthSecretKey, StorageSlot}, assembly::Assembler, crypto::dsa::rpo_falcon512::SecretKey, Word, }; pub async fn initialize_client() -> Result<Client<RpoRandomCoin>, ClientError> { // RPC endpoint and timeout let endpoint = Endpoint::new( "https".to_string(), "rpc.testnet.miden.io".to_string(), Some(443), ); let timeout_ms = 10_000; // Build RPC client let rpc_api = Box::new(TonicRpcClient::new(endpoint, timeout_ms)); // Seed RNG let mut seed_rng = rand::thread_rng(); let coin_seed: [u64; 4] = seed_rng.gen(); // Create random coin instance let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // SQLite path let store_path = "store.sqlite3"; // Initialize SQLite store let store = SqliteStore::new(store_path.into()) .await .map_err(ClientError::StoreError)?; let arc_store = Arc::new(store); // Create authenticator referencing the store and RNG let authenticator = StoreAuthenticator::new_with_rng(arc_store.clone(), rng.clone()); // Instantiate client (toggle debug mode as needed) let client = Client::new(rpc_api, rng, arc_store, Arc::new(authenticator), true); Ok(client) } pub fn get_new_pk_and_authenticator() -> (Word, AuthSecretKey) { // Create a deterministic RNG with zeroed seed let seed = [0_u8; 32]; let mut rng = ChaCha20Rng::from_seed(seed); // Generate Falcon-512 secret key let sec_key = SecretKey::with_rng(&mut rng); // Convert public key to `Word` (4xFelt) let pub_key: Word = sec_key.public_key().into(); // Wrap secret key in `AuthSecretKey` let auth_secret_key = AuthSecretKey::RpoFalcon512(sec_key); (pub_key, auth_secret_key) } #[tokio::main] async fn main() -> Result<(), ClientError> { // Initialize client let mut client = initialize_client().await?; println!("Client initialized successfully."); // Fetch latest block from node let sync_summary = client.sync_state().await.unwrap(); println!("Latest block: {}", sync_summary.block_num); // ------------------------------------------------------------------------- // STEP 1: Create the Count Reader Contract // ------------------------------------------------------------------------- println!("\n[STEP 1] Creating count reader contract."); // Load the MASM file for the counter contract let file_path = Path::new("./masm/accounts/count_reader.masm"); let raw_account_code = fs::read_to_string(file_path).unwrap(); // Define the counter contract account id and `get_count` procedure hash let counter_contract_id = AccountId::from_hex("0x4eedb9db1bdcf90000036bcebfe53a").unwrap(); let get_count_hash = "0x92495ca54d519eb5e4ba22350f837904d3895e48d74d8079450f19574bb84cb6"; let count_reader_code = raw_account_code .replace("{get_count_proc_hash}", &get_count_hash) .replace( "{account_id_prefix}", &counter_contract_id.prefix().to_string(), ) .replace( "{account_id_suffix}", &counter_contract_id.suffix().to_string(), ); // Initialize assembler (debug mode = true) let assembler: Assembler = TransactionKernel::assembler().with_debug_mode(true); // Compile the account code into `AccountComponent` with one storage slot let count_reader_component = AccountComponent::compile( count_reader_code, assembler, vec![StorageSlot::Value(Word::default())], ) .unwrap() .with_supports_all_types(); // Init seed for the count reader contract let init_seed = ChaCha20Rng::from_entropy().gen(); // Using latest block as the anchor block let anchor_block = client.get_latest_epoch_block().await.unwrap(); // Build the count reader contract with the component let (count_reader_contract, counter_seed) = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::RegularAccountImmutableCode) .storage_mode(AccountStorageMode::Public) .with_component(count_reader_component.clone()) .build() .unwrap(); println!( "count reader contract id: {:?}", count_reader_contract.id().to_hex() ); println!( "count reader storage: {:?}", count_reader_contract.storage() ); // Since anyone should be able to write to the counter contract, auth_secret_key is not required. // However, to import to the client, we must generate a random value. let (_counter_pub_key, auth_secret_key) = get_new_pk_and_authenticator(); client .add_account( &count_reader_contract.clone(), Some(counter_seed), &auth_secret_key, false, ) .await .unwrap(); // Getting the hash of the `copy_count` procedure let get_proc_export = count_reader_component .library() .exports() .find(|export| export.name.as_str() == "copy_count") .unwrap(); let get_proc_mast_id = count_reader_component .library() .get_export_node_id(get_proc_export); let copy_count_proc_hash = count_reader_component .library() .mast_forest() .get_node_by_id(get_proc_mast_id) .unwrap() .digest() .to_hex(); println!("copy_count procedure hash: {:?}", copy_count_proc_hash); Ok(()) }
Run the following command to execute src/main.rs:
cargo run --release
The output of our program will look something like this:
Client initialized successfully.
Latest block: 243826
[STEP 1] Creating count reader contract.
count reader contract id: "0xa47d7e5d8b1b90000003cd45a45a78"
count reader storage: AccountStorage { slots: [Value([0, 0, 0, 0])] }
copy_count procedure hash: "0xa2ab9f6a150e9c598699741187589d0c61de12c35c1bbe591d658950f44ab743"
Step 4: Build and read the state of the counter contract deployed on testnet
Add this snippet to the end of your file in the main()
function that we created in the previous step:
#![allow(unused)] fn main() { // ------------------------------------------------------------------------- // STEP 2: Build & Get State of the Counter Contract // ------------------------------------------------------------------------- println!("\n[STEP 2] Building counter contract from public state"); // Define the Counter Contract account id from counter contract deploy let account_details = client .test_rpc_api() .get_account_update(counter_contract_id) .await .unwrap(); let AccountDetails::Public(counter_contract_details, _) = account_details else { panic!("counter contract must be public"); }; // Getting the value of the count from slot 0 and the nonce of the counter contract let count_value = counter_contract_details.storage().slots().get(0).unwrap(); let counter_nonce = counter_contract_details.nonce(); println!("count val: {:?}", count_value.value()); println!("counter nonce: {:?}", counter_nonce); // Load the MASM file for the counter contract let file_path = Path::new("./masm/accounts/counter.masm"); let account_code = fs::read_to_string(file_path).unwrap(); // Prepare assembler (debug mode = true) let assembler: Assembler = TransactionKernel::assembler().with_debug_mode(true); // Compile the account code into `AccountComponent` with the count value returned by the node let counter_component = AccountComponent::compile( account_code, assembler, vec![StorageSlot::Value(count_value.value())], ) .unwrap() .with_supports_all_types(); // Initialize the AccountStorage with the count value returned by the node let counter_storage = AccountStorage::new(vec![StorageSlot::Value(count_value.value())]).unwrap(); // Build AccountCode from components let counter_code = AccountCode::from_components( &[counter_component.clone()], AccountType::RegularAccountImmutableCode, ) .unwrap(); // The counter contract doesn't have any assets so we pass an empty vector let vault = AssetVault::new(&[]).unwrap(); // Build the counter contract from parts let counter_contract = Account::from_parts( counter_contract_id, vault, counter_storage, counter_code, counter_nonce, ); // Since anyone should be able to write to the counter contract, auth_secret_key is not required. // However, to import to the client, we must generate a random value. let (_, auth_secret_key) = get_new_pk_and_authenticator(); client .add_account(&counter_contract.clone(), None, &auth_secret_key, true) .await .unwrap(); }
This step uses the logic we explained in the Public Account Interaction Tutorial to read the state of the Counter contract and import it to the client locally.
Step 5: Call the counter contract via foreign procedure invocation
Add this snippet to the end of your file in the main()
function:
#![allow(unused)] fn main() { // ------------------------------------------------------------------------- // STEP 3: Call the Counter Contract via Foreign Procedure Invocation (FPI) // ------------------------------------------------------------------------- println!("\n[STEP 3] Call Counter Contract with FPI from Count Copy Contract"); // Load the MASM script referencing the increment procedure let file_path = Path::new("./masm/scripts/reader_script.masm"); let original_code = fs::read_to_string(file_path).unwrap(); // Replace {get_count} and {account_id} let replaced_code = original_code.replace("{copy_count}", ©_count_proc_hash); // Compile the script referencing our procedure let tx_script = client.compile_tx_script(vec![], &replaced_code).unwrap(); let foreign_account = ForeignAccount::public(counter_contract_id, AccountStorageRequirements::default()).unwrap(); // Build a transaction request with the custom script let tx_request = TransactionRequestBuilder::new() .with_foreign_accounts([foreign_account]) .with_custom_script(tx_script) .unwrap() .build(); // Execute the transaction locally let tx_result = client .new_transaction(count_reader_contract.id(), tx_request) .await .unwrap(); let tx_id = tx_result.executed_transaction().id(); println!( "View transaction on MidenScan: https://testnet.midenscan.com/tx/{:?}", tx_id ); // Submit transaction to the network let _ = client.submit_transaction(tx_result).await; // Wait, then re-sync tokio::time::sleep(Duration::from_secs(3)).await; client.sync_state().await.unwrap(); // Retrieve updated contract data to see the incremented counter let account_1 = client.get_account(counter_contract.id()).await.unwrap(); println!( "counter contract storage: {:?}", account_1.unwrap().account().storage().get_item(0) ); let account_2 = client .get_account(count_reader_contract.id()) .await .unwrap(); println!( "count reader contract storage: {:?}", account_2.unwrap().account().storage().get_item(0) ); }
The key here is the use of the .with_foreign_accounts()
method on the TransactionRequestBuilder
. Using this method, it is possible to create transactions with multiple foreign procedure calls.
Summary
In this tutorial created a smart contract that calls the get_count
procedure in the counter contract using foreign procedure invocation, and then saves the returned value to its local storage.
The final src/main.rs
file should look like this:
use std::{fs, path::Path, sync::Arc}; use rand::Rng; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; use tokio::time::Duration; use miden_client::{ account::{Account, AccountCode, AccountId, AccountStorageMode, AccountType}, asset::AssetVault, crypto::RpoRandomCoin, rpc::{ domain::account::{AccountDetails, AccountStorageRequirements}, Endpoint, TonicRpcClient, }, store::{sqlite_store::SqliteStore, StoreAuthenticator}, transaction::{ForeignAccount, TransactionKernel, TransactionRequestBuilder}, Client, ClientError, Felt, }; use miden_objects::{ account::{AccountBuilder, AccountComponent, AccountStorage, AuthSecretKey, StorageSlot}, assembly::Assembler, crypto::dsa::rpo_falcon512::SecretKey, Word, }; pub async fn initialize_client() -> Result<Client<RpoRandomCoin>, ClientError> { // RPC endpoint and timeout let endpoint = Endpoint::new( "https".to_string(), "rpc.testnet.miden.io".to_string(), Some(443), ); let timeout_ms = 10_000; // Build RPC client let rpc_api = Box::new(TonicRpcClient::new(endpoint, timeout_ms)); // Seed RNG let mut seed_rng = rand::thread_rng(); let coin_seed: [u64; 4] = seed_rng.gen(); // Create random coin instance let rng = RpoRandomCoin::new(coin_seed.map(Felt::new)); // SQLite path let store_path = "store.sqlite3"; // Initialize SQLite store let store = SqliteStore::new(store_path.into()) .await .map_err(ClientError::StoreError)?; let arc_store = Arc::new(store); // Create authenticator referencing the store and RNG let authenticator = StoreAuthenticator::new_with_rng(arc_store.clone(), rng.clone()); // Instantiate client (toggle debug mode as needed) let client = Client::new(rpc_api, rng, arc_store, Arc::new(authenticator), true); Ok(client) } pub fn get_new_pk_and_authenticator() -> (Word, AuthSecretKey) { // Create a deterministic RNG with zeroed seed let seed = [0_u8; 32]; let mut rng = ChaCha20Rng::from_seed(seed); // Generate Falcon-512 secret key let sec_key = SecretKey::with_rng(&mut rng); // Convert public key to `Word` (4xFelt) let pub_key: Word = sec_key.public_key().into(); // Wrap secret key in `AuthSecretKey` let auth_secret_key = AuthSecretKey::RpoFalcon512(sec_key); (pub_key, auth_secret_key) } #[tokio::main] async fn main() -> Result<(), ClientError> { // Initialize client let mut client = initialize_client().await?; println!("Client initialized successfully."); // Fetch latest block from node let sync_summary = client.sync_state().await.unwrap(); println!("Latest block: {}", sync_summary.block_num); // ------------------------------------------------------------------------- // STEP 1: Create the Count Reader Contract // ------------------------------------------------------------------------- println!("\n[STEP 1] Creating count reader contract."); // Load the MASM file for the counter contract let file_path = Path::new("./masm/accounts/count_reader.masm"); let raw_account_code = fs::read_to_string(file_path).unwrap(); // Define the counter contract account id and `get_count` procedure hash let counter_contract_id = AccountId::from_hex("0x4eedb9db1bdcf90000036bcebfe53a").unwrap(); let get_count_hash = "0x92495ca54d519eb5e4ba22350f837904d3895e48d74d8079450f19574bb84cb6"; let count_reader_code = raw_account_code .replace("{get_count_proc_hash}", &get_count_hash) .replace( "{account_id_prefix}", &counter_contract_id.prefix().to_string(), ) .replace( "{account_id_suffix}", &counter_contract_id.suffix().to_string(), ); // Initialize assembler (debug mode = true) let assembler: Assembler = TransactionKernel::assembler().with_debug_mode(true); // Compile the account code into `AccountComponent` with one storage slot let count_reader_component = AccountComponent::compile( count_reader_code, assembler, vec![StorageSlot::Value(Word::default())], ) .unwrap() .with_supports_all_types(); // Init seed for the count reader contract let init_seed = ChaCha20Rng::from_entropy().gen(); // Using latest block as the anchor block let anchor_block = client.get_latest_epoch_block().await.unwrap(); // Build the count reader contract with the component let (count_reader_contract, counter_seed) = AccountBuilder::new(init_seed) .anchor((&anchor_block).try_into().unwrap()) .account_type(AccountType::RegularAccountImmutableCode) .storage_mode(AccountStorageMode::Public) .with_component(count_reader_component.clone()) .build() .unwrap(); println!( "count reader contract id: {:?}", count_reader_contract.id().to_hex() ); println!( "count reader storage: {:?}", count_reader_contract.storage() ); // Since anyone should be able to write to the counter contract, auth_secret_key is not required. // However, to import to the client, we must generate a random value. let (_counter_pub_key, auth_secret_key) = get_new_pk_and_authenticator(); client .add_account( &count_reader_contract.clone(), Some(counter_seed), &auth_secret_key, false, ) .await .unwrap(); // Getting the hash of the `copy_count` procedure let get_proc_export = count_reader_component .library() .exports() .find(|export| export.name.as_str() == "copy_count") .unwrap(); let get_proc_mast_id = count_reader_component .library() .get_export_node_id(get_proc_export); let copy_count_proc_hash = count_reader_component .library() .mast_forest() .get_node_by_id(get_proc_mast_id) .unwrap() .digest() .to_hex(); println!("copy_count procedure hash: {:?}", copy_count_proc_hash); // ------------------------------------------------------------------------- // STEP 2: Build & Get State of the Counter Contract // ------------------------------------------------------------------------- println!("\n[STEP 2] Building counter contract from public state"); // Define the Counter Contract account id from counter contract deploy let account_details = client .test_rpc_api() .get_account_update(counter_contract_id) .await .unwrap(); let AccountDetails::Public(counter_contract_details, _) = account_details else { panic!("counter contract must be public"); }; // Getting the value of the count from slot 0 and the nonce of the counter contract let count_value = counter_contract_details.storage().slots().get(0).unwrap(); let counter_nonce = counter_contract_details.nonce(); println!("count val: {:?}", count_value.value()); println!("counter nonce: {:?}", counter_nonce); // Load the MASM file for the counter contract let file_path = Path::new("./masm/accounts/counter.masm"); let account_code = fs::read_to_string(file_path).unwrap(); // Prepare assembler (debug mode = true) let assembler: Assembler = TransactionKernel::assembler().with_debug_mode(true); // Compile the account code into `AccountComponent` with the count value returned by the node let counter_component = AccountComponent::compile( account_code, assembler, vec![StorageSlot::Value(count_value.value())], ) .unwrap() .with_supports_all_types(); // Initialize the AccountStorage with the count value returned by the node let counter_storage = AccountStorage::new(vec![StorageSlot::Value(count_value.value())]).unwrap(); // Build AccountCode from components let counter_code = AccountCode::from_components( &[counter_component.clone()], AccountType::RegularAccountImmutableCode, ) .unwrap(); // The counter contract doesn't have any assets so we pass an empty vector let vault = AssetVault::new(&[]).unwrap(); // Build the counter contract from parts let counter_contract = Account::from_parts( counter_contract_id, vault, counter_storage, counter_code, counter_nonce, ); // Since anyone should be able to write to the counter contract, auth_secret_key is not required. // However, to import to the client, we must generate a random value. let (_, auth_secret_key) = get_new_pk_and_authenticator(); client .add_account(&counter_contract.clone(), None, &auth_secret_key, true) .await .unwrap(); // ------------------------------------------------------------------------- // STEP 3: Call the Counter Contract via Foreign Procedure Invocation (FPI) // ------------------------------------------------------------------------- println!("\n[STEP 3] Call Counter Contract with FPI from Count Copy Contract"); // Load the MASM script referencing the increment procedure let file_path = Path::new("./masm/scripts/reader_script.masm"); let original_code = fs::read_to_string(file_path).unwrap(); // Replace {get_count} and {account_id} let replaced_code = original_code.replace("{copy_count}", ©_count_proc_hash); // Compile the script referencing our procedure let tx_script = client.compile_tx_script(vec![], &replaced_code).unwrap(); let foreign_account = ForeignAccount::public(counter_contract_id, AccountStorageRequirements::default()).unwrap(); // Build a transaction request with the custom script let tx_request = TransactionRequestBuilder::new() .with_foreign_accounts([foreign_account]) .with_custom_script(tx_script) .unwrap() .build(); // Execute the transaction locally let tx_result = client .new_transaction(count_reader_contract.id(), tx_request) .await .unwrap(); let tx_id = tx_result.executed_transaction().id(); println!( "View transaction on MidenScan: https://testnet.midenscan.com/tx/{:?}", tx_id ); // Submit transaction to the network let _ = client.submit_transaction(tx_result).await; // Wait, then re-sync tokio::time::sleep(Duration::from_secs(3)).await; client.sync_state().await.unwrap(); // Retrieve updated contract data to see the incremented counter let account_1 = client.get_account(counter_contract.id()).await.unwrap(); println!( "counter contract storage: {:?}", account_1.unwrap().account().storage().get_item(0) ); let account_2 = client .get_account(count_reader_contract.id()) .await .unwrap(); println!( "count reader contract storage: {:?}", account_2.unwrap().account().storage().get_item(0) ); Ok(()) }
The output of our program will look something like this:
Client initialized successfully.
Latest block: 242367
[STEP 1] Creating count reader contract.
count reader contract id: "0x95b00b4f410f5000000383ca114c9a"
count reader storage: AccountStorage { slots: [Value([0, 0, 0, 0])] }
copy_count procedure hash: "0xa2ab9f6a150e9c598699741187589d0c61de12c35c1bbe591d658950f44ab743"
[STEP 2] Building counter contract from public state
count val: [0, 0, 0, 2]
counter nonce: 2
[STEP 3] Call Counter Contract with FPI from Count Copy Contract
Stack state before step 3351:
├── 0: 2
├── 1: 0
├── 2: 0
├── 3: 0
├── 4: 0
├── 5: 0
├── 6: 0
├── 7: 0
├── 8: 0
├── 9: 0
├── 10: 0
├── 11: 0
├── 12: 0
├── 13: 0
├── 14: 0
└── 15: 0
View transaction on MidenScan: https://testnet.midenscan.com/tx/0xe2fdb53926e7a11548863c2a85d81127094d6fe38f60509b4ef8ea38994f8cec
counter contract storage: Ok(RpoDigest([0, 0, 0, 2]))
count reader contract storage: Ok(RpoDigest([0, 0, 0, 2]))
Running the example
To run the full example, navigate to the rust-client
directory in the miden-tutorials repository and run this command:
cd rust-client
cargo run --release --bin counter_contract_fpi
WebClient
TypeScript library, which can be used to programmatically interact with the Miden rollup.
The Miden WebClient can be used for a variety of things, including:
- Deploying and creating transactions to interact with accounts and notes on Miden.
- Storing the state of accounts and notes in the browser.
- Generating and submitting proofs of transactions.
- Submitting transactions to delegated proving services.
This section of the docs is an overview of the different things one can achieve using the WebClient, and how to implement them.
Keep in mind that both the WebClient and the documentation are works-in-progress!
Creating Accounts and Deploying Faucets
Using the Miden WebClient in TypeScript to create accounts and deploy faucets
Overview
In this tutorial, we will create a basic web application that interacts with Miden using the Miden WebClient.
Our web application will create a Miden account for Alice and then deploy a fungible faucet. In the next section we will mint tokens from the faucet to fund her account, and then send the tokens from Alice's account to other Miden accounts.
What we'll cover
- Understanding the difference between public and private accounts & notes
- Instantiating the Miden client
- Creating new accounts (public or private)
- Deploying a faucet to fund an account
Prerequisites
To begin, make sure you have a miden-node running locally in a separate terminal window. To get the Miden node running locally, you can follow the instructions on the Miden Node Setup page.
Note: In this tutorial we use pnpm which is a drop in replacement for npm.
Public vs. private accounts & notes
Before we dive into the coding, let's clarify the concepts of public and private accounts and notes on Miden:
- Public accounts: The account's data and code are stored on-chain and are openly visible, including its assets.
- Private accounts: The account's state and logic are off-chain, only known to its owner.
- Public notes: The note's state is visible to anyone - perfect for scenarios where transparency is desired.
- Private notes: The note's state is stored off-chain, you will need to share the note data with the relevant parties (via email or Telegram) for them to be able to consume the note.
Note: The term "account" can be used interchangeably with the term "smart contract" since account abstraction on Miden is handled natively.
It is useful to think of notes on Miden as "cryptographic cashier's checks" that allow users to send tokens. If the note is private, the note transfer is only known to the sender and receiver.
Step 1: Initialize your repository
Create a new React TypeScript repository for your Miden web application, navigate to it, and install the Miden WebClient using this command:
pnpm create vite miden-app --template react-ts
Navigate to the new repository:
cd miden-app
Install dependencies:
pnpm install
Install the Miden WebClient SDK:
pnpm i @demox-labs/miden-sdk@0.6.1-next.4
Save this as your vite.config.ts
file:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
target: 'esnext',
},
optimizeDeps: {
exclude: ['@demox-labs/miden-sdk'], // Exclude the SDK from optimization
},
});
Note: ensure you are using Node version v20.12.0
Step 2: Initialize the client
Before we can interact with the Miden network, we need to instantiate the WebClient. In this step, we specify two parameters:
- RPC endpoint - The URL of the Miden node to which we connect.
- Delegated Prover Endpoint (optional) – The URL of the delegated prover which the client can connect to.
Create a webClient.ts
file:
To instantiate the WebClient, pass in the endpoint of the Miden node. You can also instantiate the client with a delegated prover to speed up the proof generation time, however, in this example we will be instantiating the WebClient only with the endpoint of the Miden node since we will be handling proof generation locally within the browser.
Since we will be handling proof generation in the computationally constrained environment of the browser, it will be slower than proof generation handled by the Rust client. Currently, the Miden WebClient is thread-blocking when not used within a web worker.
Example of instantiating the WebClient with a delegated prover:
const nodeEndpoint = "http://localhost:57291";
const delegatedProver = 'http://18.118.151.210:8082'
let client = new WebClient();
await client.create_client(nodeEndpoint, delegatedProver);
In the src/
directory create a file named webClient.ts
and paste the following into it:
// src/webClient.ts
import { WebClient } from "@demox-labs/miden-sdk";
const nodeEndpoint = "http://localhost:57291";
export async function webClient(): Promise<void> {
try {
// 1. Create client
const client = new WebClient();
await client.create_client(nodeEndpoint);
// 2. Sync and log block
const state = await client.sync_state();
console.log("Latest block number:", state.block_num());
} catch (error) {
console.error("Error", error);
throw error;
}
}
Edit your App.tsx
file:
Set this as your App.tsx
file.
// src/App.tsx
import { useState } from "react";
import "./App.css";
import { webClient } from "./webClient";
function App() {
const [clientStarted, setClientStarted] = useState(false);
const handleClick = () => {
webClient();
setClientStarted(true);
};
return (
<div className="App">
<h1>Miden Web App</h1>
<p>Open the console to view logs</p>
{!clientStarted && <button onClick={handleClick}>Start WebClient</button>}
</div>
);
}
export default App;
Starting the frontend:
pnpm run dev
Open the frontend at:
http://localhost:5173/
Now open the browser console. Click the "Start the WebClient" button. Then in the console, you should see something like:
Latest block number: 123
Step 3: Creating a wallet
Now that we've initialized the WebClient, we can create a wallet for Alice.
To create a wallet for Alice using the Miden WebClient, we specify the account type by specifying if the account code is mutable or immutable and whether the account is public or private. A mutable wallet means you can change the account code after deployment.
A wallet on Miden is simply an account with standardized code.
In the example below we create a mutable public account for Alice.
Our src/webClient.ts
file should now look something like this:
// src/webClient.ts
import {
WebClient,
AccountStorageMode,
AccountId,
NoteType,
} from "@demox-labs/miden-sdk";
const nodeEndpoint = "http://localhost:57291";
export async function webClient(): Promise<void> {
try {
// 1. Create client
const client = new WebClient();
await client.create_client(nodeEndpoint);
// 2. Sync and log block
const state = await client.sync_state();
console.log("Latest block number:", state.block_num());
// 3. Create Alice account (public, updatable)
console.log("Creating account for Alice");
const aliceAccount = await client.new_wallet(
AccountStorageMode.public(), // account type
true, // mutability
);
const aliceIdHex = aliceAccount.id().to_string();
console.log("Alice's account ID:", aliceIdHex);
await client.sync_state();
} catch (error) {
console.error("Error:", error);
throw error;
}
}
Step 4: Deploying a fungible faucet
For Alice to receive testnet assets, we first need to deploy a faucet. A faucet account on Miden mints fungible tokens.
We'll create a public faucet with a token symbol, decimals, and a max supply. We will use this faucet to mint tokens to Alice's account in the next section.
Add this snippet to the end of the webClient()
function:
// 4. Create faucet
console.log("Creating faucet...");
const faucetAccount = await client.new_faucet(
AccountStorageMode.public(), // account type
false, // is fungible
"MID", // symbol
8, // decimals
BigInt(1_000_000) // max supply
);
const faucetIdHex = faucetAccount.id().to_string();
console.log("Faucet account ID:", faucetIdHex);
await client.sync_state();
When tokens are minted from this faucet, each token batch is represented as a "note" (UTXO). You can think of a Miden Note as a cryptographic cashier's check that has certain spend conditions attached to it.
Summary
Our new src/webClient.ts
file should look something like this:
// src/webClient.ts
import {
WebClient,
AccountStorageMode,
AccountId,
NoteType,
} from "@demox-labs/miden-sdk";
const nodeEndpoint = "http://localhost:57291";
export async function webClient(): Promise<void> {
try {
// 1. Create client
const client = new WebClient();
await client.create_client(nodeEndpoint);
// 2. Sync and log block
const state = await client.sync_state();
console.log("Latest block number:", state.block_num());
// 3. Create Alice account (public, updatable)
console.log("Creating account for Alice");
const aliceAccount = await client.new_wallet(
AccountStorageMode.public(),
true,
);
const aliceIdHex = aliceAccount.id().to_string();
console.log("Alice's account ID:", aliceIdHex);
// 4. Create faucet
console.log("Creating faucet...");
const faucetAccount = await client.new_faucet(
AccountStorageMode.public(), // account type
false, // is fungible
"MID", // symbol
8, // decimals
BigInt(1_000_000) // max supply
);
const faucetIdHex = faucetAccount.id().to_string();
console.log("Faucet account ID:", faucetIdHex);
await client.sync_state();
} catch (error) {
console.error("Error", error);
throw error;
}
}
Let's run the src/main.rs
program again:
pnpm run dev
The output will look like this:
Latest block number: 2247
Alice's account ID: 0xd70b2072c6495d100000869a8bacf2
Faucet account ID: 0x2d7e506fb88dde200000a1386efec8
In this section, we explained how to instantiate the Miden client, create a wallet, and deploy a faucet.
In the next section we will cover how to mint tokens from the faucet, consume notes, and send tokens to other accounts.
Running the example
To run a full working example navigate to the web-client
directory in the miden-tutorials repository and run the web application example:
cd web-client
pnpm i
pnpm run dev
Mint, Consume, and Create Notes
Using the Miden WebClient in TypeScript to mint, consume, and create notes
Overview
In the previous section, we initialized our repository and covered how to create an account and deploy a faucet. In this section, we will mint tokens from the faucet for Alice, consume the newly created notes, and demonstrate how to send assets to other accounts.
What we'll cover
- Minting assets from a faucet
- Consuming notes to fund an account
- Sending tokens to other users
Step 1: Minting tokens from the faucet
To mint notes with tokens from the faucet we created, Alice can use the WebClient's new_mint_transaction()
function.
Below is an example of a transaction request minting tokens from the faucet for Alice.
Add this snippet to the end of the webClient
function in the src/webClient.ts
file that we created in the previous chapter:
await client.fetch_and_cache_account_auth_by_pub_key(
AccountId.from_hex(faucetIdHex),
);
await client.sync_state();
console.log("Minting tokens to Alice...");
await client.new_mint_transaction(
AccountId.from_hex(aliceIdHex), // target wallet id
AccountId.from_hex(faucetIdHex), // faucet id
NoteType.public(), // note type
BigInt(1000), // amount
);
console.log("Waiting 15 seconds for transaction confirmation...");
await new Promise((resolve) => setTimeout(resolve, 15000));
await client.sync_state();
Step 2: Identifying consumable notes
Once Alice has minted a note from the faucet, she will eventually want to spend the tokens that she received in the note created by the mint transaction.
Minting a note from a faucet on Miden means a faucet account creates a new note targeted to the requesting account. The requesting account must consume this note for the assets to appear in their account.
To identify notes that are ready to consume, the Miden WebClient has a useful function get_consumable_notes
. It is also important to sync the state of the client before calling the get_consumable_notes
function.
Tip: If you know the expected number of notes after a transaction, use await
or a loop condition to verify their availability before calling get_consumable_notes
. This prevents unnecessary application idling.
Identifying which notes are available:
consumable_notes = await client.get_consumable_notes(accountId);
Step 3: Consuming multiple notes in a single transaction:
Now that we know how to identify notes ready to consume, let's consume the notes created by the faucet in a single transaction. After consuming the notes, Alice's wallet balance will be updated.
The following code snippet identifies and consumes notes in a single transaction.
Add this snippet to the end of the webClient
function in the src/webClient.ts
file:
await client.fetch_and_cache_account_auth_by_pub_key(
AccountId.from_hex(aliceIdHex),
);
const mintedNotes = await client.get_consumable_notes(
AccountId.from_hex(aliceIdHex),
);
const mintedNoteIds = mintedNotes.map((n) =>
n.input_note_record().id().to_string(),
);
console.log("Minted note IDs:", mintedNoteIds);
console.log("Consuming minted notes...");
await client.new_consume_transaction(
AccountId.from_hex(aliceIdHex), // account id
mintedNoteIds, // array of note ids to consume
);
await client.sync_state();
console.log("Notes consumed.");
Step 4: Sending tokens to other accounts
After consuming the notes, Alice has tokens in her wallet. Now, she wants to send tokens to her friends. She has two options: create a separate transaction for each transfer or batch multiple notes in a single transaction.
The standard asset transfer note on Miden is the P2ID note (Pay to Id). There is also the P2IDR (Pay to Id Reclaimable) variant which allows the creator of the note to reclaim the note after a certain block height.
In our example, Alice will now send 50 tokens to a different account.
Basic P2ID transfer
Now as an example, Alice will send some tokens to an account in a single transaction.
Add this snippet to the end of your file in the main()
function:
// send single P2ID note
const dummyIdHex = "0x599a54603f0cf9000000ed7a11e379";
console.log("Sending tokens to dummy account...");
await client.new_send_transaction(
AccountId.from_hex(aliceIdHex), // sender account id
AccountId.from_hex(dummyIdHex), // receiver account id
AccountId.from_hex(faucetIdHex), // faucet account id
NoteType.public(), // note type
BigInt(100), // amount
);
await client.sync_state();
Summary
Your src/webClient.ts
function should now look like this:
import {
WebClient,
AccountStorageMode,
AccountId,
NoteType,
} from "@demox-labs/miden-sdk";
const nodeEndpoint = "http://localhost:57291";
export async function webClient(): Promise<void> {
try {
// 1. Create client
const client = new WebClient();
await client.create_client(nodeEndpoint);
// 2. Sync and log block
const state = await client.sync_state();
console.log("Latest block number:", state.block_num());
// 3. Create Alice account (public, updatable)
console.log("Creating account for Alice");
const aliceAccount = await client.new_wallet(
AccountStorageMode.public(), // account type
true // mutability
);
const aliceIdHex = aliceAccount.id().to_string();
console.log("Alice's account ID:", aliceIdHex);
// 4. Create faucet
console.log("Creating faucet...");
const faucetAccount = await client.new_faucet(
AccountStorageMode.public(), // account type
false, // fungible
"MID", // symbol
8, // decimals
BigInt(1_000_000) // max supply
);
const faucetIdHex = faucetAccount.id().to_string();
console.log("Faucet account ID:", faucetIdHex);
// 5. Mint tokens to Alice
await client.fetch_and_cache_account_auth_by_pub_key(
AccountId.from_hex(faucetIdHex),
);
await client.sync_state();
console.log("Minting tokens to Alice...");
await client.new_mint_transaction(
AccountId.from_hex(aliceIdHex), // target wallet id
AccountId.from_hex(faucetIdHex), // faucet id
NoteType.public(), // note type
BigInt(1000), // amount
);
console.log("Waiting 15 seconds for transaction confirmation...");
await new Promise((resolve) => setTimeout(resolve, 15000));
await client.sync_state();
// 6. Fetch minted notes
await client.fetch_and_cache_account_auth_by_pub_key(
AccountId.from_hex(aliceIdHex),
);
const mintedNotes = await client.get_consumable_notes(
AccountId.from_hex(aliceIdHex),
);
const mintedNoteIds = mintedNotes.map((n) =>
n.input_note_record().id().to_string(),
);
console.log("Minted note IDs:", mintedNoteIds);
// 7. Consume minted notes
console.log("Consuming minted notes...");
await client.new_consume_transaction(
AccountId.from_hex(aliceIdHex), // account id
mintedNoteIds, // array of note ids to consume
);
await client.sync_state();
console.log("Notes consumed.");
// 8. Send tokens to a dummy account
const dummyIdHex = "0x599a54603f0cf9000000ed7a11e379";
console.log("Sending tokens to dummy account...");
await client.new_send_transaction(
AccountId.from_hex(aliceIdHex), // sender account id
AccountId.from_hex(dummyIdHex), // receiver account id
AccountId.from_hex(faucetIdHex), // faucet account id
NoteType.public(), // note type
BigInt(100), // amount
);
await client.sync_state();
console.log("Tokens sent.");
} catch (error) {
console.error("Error:", error);
throw error;
}
}
Let's run the src/webClient.ts
function again. Reload the page and click "Start WebClient".
Note: Currently there is a minor bug in the WebClient that produces a warning message, "Error inserting code with root" when creating multiple accounts. This is currently being fixed.
The output will look like this:
Latest block number: 4807
Alice's account ID: 0x1a20f4d1321e681000005020e69b1a
Creating faucet...
Faucet account ID: 0xaa86a6f05ae40b2000000f26054d5d
Minting tokens to Alice...
Waiting 15 seconds for transaction confirmation...
Minted note IDs: ['0x4edbb3d5dbdf6944f229a4711533114e0602ad48b70cda400993925c61f5bfaa']
Consuming minted notes...
Notes consumed.
Sending tokens to dummy account...
Tokens sent.
Resetting the MidenClientDB
The Miden webclient stores account and note data in the browser. To clear the account and node data in the browser, paste this code snippet into the browser console:
(async () => {
const dbs = await indexedDB.databases(); // Get all database names
for (const db of dbs) {
await indexedDB.deleteDatabase(db.name);
console.log(`Deleted database: ${db.name}`);
}
console.log("All databases deleted.");
})();
Running the example
To run a full working example navigate to the web-client
directory in the miden-tutorials repository and run the web application example:
cd web-client
pnpm i
pnpm run dev