How to Create a Custom Note
Creating notes with custom logic
Overview
In this guide, we will create a custom note on Miden that can only be consumed by someone who knows the preimage of the hash stored in the note. This approach securely embeds assets into the note and restricts spending to those who possess the correct secret number.
By following the steps below and using the Miden Assembly code and Rust example, you will learn how to:
- Create a note with custom logic.
- Leverage Miden’s privacy features to keep certain transaction details private.
Unlike Ethereum, where all pending transactions are publicly visible in the mempool, Miden enables you to partially or completely hide transaction details.
What we'll cover
- Writing Miden assembly for a note
- Consuming notes
Step-by-step process
1. Creating two accounts: Alice & Bob
First, we create two basic accounts for the two users:
- Alice: The account that creates and funds the custom note.
- Bob: The account that will consume the note if they know the correct secret.
2. Hashing the secret number
The security of the custom note hinges on a secret number. Here, we will:
- Choose a secret number (for example, an array of four integers).
- For simplicity, we're only hashing 4 elements. Therefore, we prepend an empty word—consisting of 4 zero integers—as a placeholder. This is required by the RPO hashing algorithm to ensure the input has the correct structure and length for proper processing.
- Compute the hash of the secret. The resulting hash will serve as the note’s input, meaning that the note can only be consumed if the secret number’s hash preimage is provided during consumption.
3. Creating the custom note
Now, combine the minted asset and the secret hash to build the custom note. The note is created using the following key steps:
- Note Inputs:
- The note is set up with the asset and the hash of the secret number as its input.
- Miden Assembly Code:
- The Miden assembly note script ensures that the note can only be consumed if the provided secret, when hashed, matches the hash stored in the note input.
Below is the Miden Assembly code for the note. Note scripts are compiled as libraries; the @note_script attribute marks the entrypoint procedure.
use miden::protocol::active_note
use miden::standards::wallets::basic->wallet
# CONSTANTS
# =================================================================================================
const EXPECTED_DIGEST_PTR=0
# ERRORS
# =================================================================================================
const ERROR_DIGEST_MISMATCH="Expected digest does not match computed digest"
#! Inputs (arguments): [HASH_PREIMAGE_SECRET]
#! Outputs: []
#!
#! Note storage is assumed to be as follows:
#! => EXPECTED_DIGEST
@note_script
pub proc main
# => HASH_PREIMAGE_SECRET
# Hashing the secret number
hash
# => [DIGEST]
# Writing the note storage to memory.
# get_storage leaves only [num_storage_items], so drop a single element
# here, not two, to keep the computed DIGEST intact.
push.EXPECTED_DIGEST_PTR exec.active_note::get_storage drop
# Pad stack and load expected digest from memory (LE: mem[addr] ends up on top)
padw push.EXPECTED_DIGEST_PTR mem_loadw_le
# => [EXPECTED_DIGEST, DIGEST]
# Assert that the note input matches the digest
# Will fail if the two hashes do not match
assert_eqw.err=ERROR_DIGEST_MISMATCH
# => []
# ---------------------------------------------------------------------------------------------
# If the check is successful, we allow for the asset to be consumed
# ---------------------------------------------------------------------------------------------
# Add all assets from the note to the account
exec.wallet::add_assets_to_account
# => []
end
How the assembly code works:
- Constants and Error Handling:
The code defines a memory pointer (EXPECTED_DIGEST_PTR) for storing the expected hash and an error message for digest mismatches. - Passing the Secret:
The secret number is passed asNote Argumentsinto the note. - Hashing the Secret:
Thehashinstruction applies a Poseidon2 hash permutation to the secret number, resulting in a digest that takes up four stack elements. - Digest Comparison:
The assembly code loads the expected digest from note storage into memory, then reads it back withmem_loadw_le(which placesmem[addr]on top, matching the hash output order) and compares with the computed hash. If they don't match, the transaction fails with a clear error message. - Asset Transfer:
If the hash matches,wallet::add_assets_to_accounttransfers all note assets into the consuming account's vault.
5. Consuming the note
With the note created, Bob can now consume it—but only if he provides the correct secret. When Bob initiates the transaction to consume the note, he must supply the same secret number used when Alice created the note. The custom note’s logic will hash the secret and compare it with its stored hash. If they match, Bob’s wallet receives the asset.
Full Rust code example
The following Rust code demonstrates how to implement the steps outlined above using the Miden client library:
use rand::RngCore;
use std::{path::PathBuf, sync::Arc};
use tokio::time::{sleep, Duration};
use miden_client::{
account::{
component::{
BasicWallet, BurnPolicyConfig, FungibleFaucet, MintPolicyConfig, PolicyRegistration,
TokenName, TokenPolicyManager,
},
Account, AccountBuilder, AccountType,
},
address::NetworkId,
asset::{AssetAmount, FungibleAsset, TokenSymbol},
auth::{AuthSchemeId, AuthSecretKey, AuthSingleSig},
builder::ClientBuilder,
crypto::FeltRng,
keystore::{FilesystemKeyStore, Keystore},
note::{Note, NoteAssets, NoteRecipient, NoteStorage, NoteTag, NoteType, PartialNoteMetadata},
rpc::{Endpoint, GrpcClient},
store::TransactionFilter,
transaction::{TransactionId, TransactionRequestBuilder, TransactionStatus},
Client, ClientError, Felt,
};
use miden_client_sqlite_store::ClientBuilderSqliteExt;
use miden_protocol::Hasher;
// Helper to create a basic account
async fn create_basic_account(
client: &mut Client<FilesystemKeyStore>,
keystore: &Arc<FilesystemKeyStore>,
) -> Result<Account, ClientError> {
let mut init_seed = [0_u8; 32];
client.rng().fill_bytes(&mut init_seed);
let key_pair = AuthSecretKey::new_falcon512_poseidon2_with_rng(client.rng());
let account = AccountBuilder::new(init_seed)
.account_type(AccountType::Public)
.with_auth_component(AuthSingleSig::new(key_pair.public_key().to_commitment(), AuthSchemeId::Falcon512Poseidon2))
.with_component(BasicWallet)
.build()
.unwrap();
client.add_account(&account, false).await?;
keystore.add_key(&key_pair, account.id()).await.unwrap();
Ok(account)
}
async fn create_basic_faucet(
client: &mut Client<FilesystemKeyStore>,
keystore: &Arc<FilesystemKeyStore>,
) -> Result<Account, ClientError> {
let mut init_seed = [0u8; 32];
client.rng().fill_bytes(&mut init_seed);
let key_pair = AuthSecretKey::new_falcon512_poseidon2_with_rng(client.rng());
let symbol = TokenSymbol::new("MID").unwrap();
let decimals = 8;
let max_supply = AssetAmount::new(1_000_000).unwrap();
let account = AccountBuilder::new(init_seed)
.account_type(AccountType::Public)
.with_auth_component(AuthSingleSig::new(key_pair.public_key().to_commitment(), AuthSchemeId::Falcon512Poseidon2))
.with_component(
FungibleFaucet::builder()
.name(TokenName::new("MID").unwrap())
.symbol(symbol)
.decimals(decimals)
.max_supply(max_supply)
.build()
.unwrap(),
)
.with_components(
TokenPolicyManager::new()
.with_mint_policy(MintPolicyConfig::AllowAll, PolicyRegistration::Active)
.unwrap()
.with_burn_policy(BurnPolicyConfig::AllowAll, PolicyRegistration::Active)
.unwrap(),
)
.build()
.unwrap();
client.add_account(&account, false).await?;
keystore.add_key(&key_pair, account.id()).await.unwrap();
Ok(account)
}
/// Waits for a specific transaction to be committed.
async fn wait_for_tx(
client: &mut Client<FilesystemKeyStore>,
tx_id: TransactionId,
) -> Result<(), ClientError> {
loop {
client.sync_state().await?;
// Check transaction status
let txs = client
.get_transactions(TransactionFilter::Ids(vec![tx_id]))
.await?;
let tx_committed = if !txs.is_empty() {
matches!(txs[0].status, TransactionStatus::Committed { .. })
} else {
false
};
if tx_committed {
println!("✅ transaction {} committed", tx_id.to_hex());
break;
}
println!(
"Transaction {} not yet committed. Waiting...",
tx_id.to_hex()
);
sleep(Duration::from_secs(2)).await;
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), ClientError> {
// Initialize client
let endpoint = Endpoint::testnet();
let timeout_ms = 10_000;
let rpc_client = Arc::new(GrpcClient::new(&endpoint, timeout_ms));
// Initialize keystore
let keystore_path = PathBuf::from("./keystore");
let keystore = Arc::new(FilesystemKeyStore::new(keystore_path).unwrap());
let store_path = PathBuf::from("./store.sqlite3");
let mut client = ClientBuilder::new()
.rpc(rpc_client)
.sqlite_store(store_path)
.authenticator(keystore.clone())
.in_debug_mode(true.into())
.build()
.await?;
let sync_summary = client.sync_state().await.unwrap();
println!("Latest block: {}", sync_summary.block_num);
// -------------------------------------------------------------------------
// STEP 1: Create accounts and deploy faucet
// -------------------------------------------------------------------------
println!("\n[STEP 1] Creating new accounts");
let alice_account = create_basic_account(&mut client, &keystore).await?;
println!(
"Alice's account ID: {:?}",
alice_account.id().to_bech32(NetworkId::Testnet)
);
let bob_account = create_basic_account(&mut client, &keystore).await?;
println!(
"Bob's account ID: {:?}",
bob_account.id().to_bech32(NetworkId::Testnet)
);
println!("\nDeploying a new fungible faucet.");
let faucet = create_basic_faucet(&mut client, &keystore).await?;
println!(
"Faucet account ID: {:?}",
faucet.id().to_bech32(NetworkId::Testnet)
);
client.sync_state().await?;
// -------------------------------------------------------------------------
// STEP 2: Mint tokens with P2ID
// -------------------------------------------------------------------------
println!("\n[STEP 2] Mint tokens with P2ID");
let faucet_id = faucet.id();
let amount: u64 = 100;
let mint_amount = FungibleAsset::new(faucet_id, amount).unwrap();
let tx_request = TransactionRequestBuilder::new()
.build_mint_fungible_asset(
mint_amount,
alice_account.id(),
NoteType::Public,
client.rng(),
)
.unwrap();
let tx_id = client
.submit_new_transaction(faucet.id(), tx_request)
.await?;
println!("Minted tokens. TX: {:?}", tx_id);
// Wait for the note to be available
client.sync_state().await?;
wait_for_tx(&mut client, tx_id).await?;
// Consume the minted note
let consumable_notes = client
.get_consumable_notes(Some(alice_account.id()))
.await?;
if let Some((note_record, _)) = consumable_notes.first() {
let note: Note = note_record.clone().try_into()?;
let consume_request = TransactionRequestBuilder::new()
.build_consume_notes(vec![note])
.unwrap();
let tx_id = client
.submit_new_transaction(alice_account.id(), consume_request)
.await?;
println!("Consumed minted note. TX: {:?}", tx_id);
}
client.sync_state().await?;
// -------------------------------------------------------------------------
// STEP 3: Create custom note
// -------------------------------------------------------------------------
println!("\n[STEP 3] Create custom note");
let secret_vals = vec![Felt::new_unchecked(1), Felt::new_unchecked(2), Felt::new_unchecked(3), Felt::new_unchecked(4)];
let digest = Hasher::hash_elements(&secret_vals);
println!("digest: {:?}", digest);
// `include_str!` resolves at compile time relative to this source file,
// so the binary is independent of the working directory it is run from.
let code = include_str!("../masm/notes/hash_preimage_note.masm");
let serial_num = client.rng().draw_word();
let note_script = client.code_builder().compile_note_script(code).unwrap();
let note_storage = NoteStorage::new(digest.to_vec()).unwrap();
let recipient = NoteRecipient::new(serial_num, note_script, note_storage);
let tag = NoteTag::new(0);
let metadata = PartialNoteMetadata::new(alice_account.id(), NoteType::Public).with_tag(tag);
let vault = NoteAssets::new(vec![mint_amount.into()])?;
let custom_note = Note::new(vault, metadata, recipient);
println!("note hash: {:?}", custom_note.id().to_hex());
let note_request = TransactionRequestBuilder::new()
.own_output_notes(vec![custom_note.clone()])
.build()
.unwrap();
let tx_id = client
.submit_new_transaction(alice_account.id(), note_request)
.await?;
println!(
"View transaction on MidenScan: https://testnet.midenscan.com/tx/{:?}",
tx_id
);
client.sync_state().await?;
// -------------------------------------------------------------------------
// STEP 4: Consume the Custom Note
// -------------------------------------------------------------------------
println!("\n[STEP 4] Bob consumes the Custom Note with Correct Secret");
let secret = [Felt::new_unchecked(1), Felt::new_unchecked(2), Felt::new_unchecked(3), Felt::new_unchecked(4)];
let consume_custom_request = TransactionRequestBuilder::new()
.input_notes([(custom_note, Some(secret.into()))])
.build()
.unwrap();
let tx_id = client
.submit_new_transaction(bob_account.id(), consume_custom_request)
.await?;
println!(
"Consumed Note Tx on MidenScan: https://testnet.midenscan.com/tx/{:?} \n",
tx_id
);
wait_for_tx(&mut client, tx_id).await?;
Ok(())
}
The output of our program will look something like this:
Latest block: 488704
[STEP 1] Creating new accounts
Alice's account ID: "<testnet_account_id>"
Bob's account ID: "<testnet_account_id>"
Deploying a new fungible faucet.
Faucet account ID: "<testnet_account_id>"
[STEP 2] Mint tokens with P2ID
Minted tokens. TX: 0x970265408eb22068b22ec677f6ad09a2524913ab11b3dbf010e4cae73587e2e3
Transaction 0x970265408eb22068b22ec677f6ad09a2524913ab11b3dbf010e4cae73587e2e3 not yet committed. Waiting...
✅ transaction 0x970265408eb22068b22ec677f6ad09a2524913ab11b3dbf010e4cae73587e2e3 committed
Consumed minted note. TX: 0x47850a0c44d9e147b8866285c24ba05a0d4bed0a99c1f25a13f32e57feecfe1b
[STEP 3] Create custom note
digest: Word([14206540680072267069, 9571949196318390099, 5950603493574130513, 3457190364553631046])
note hash: "0xf48f362f1817bbc5575e0bb8b77c496dd67e4b85d8ff45d21dff5743de2b174d"
View transaction on MidenScan: https://testnet.midenscan.com/tx/0x9911ef1b9d2b066e017de187b7c1f8d95012748366358474b11adc91e49971b5
[STEP 4] Bob consumes the Custom Note with Correct Secret
Consumed Note Tx on MidenScan: https://testnet.midenscan.com/tx/0x2a7a192b692984ae649dd1a13d15e4b79178e9e554615ffec7426e25e75193fa
Transaction 0x2a7a192b692984ae649dd1a13d15e4b79178e9e554615ffec7426e25e75193fa not yet committed. Waiting...
✅ transaction 0x2a7a192b692984ae649dd1a13d15e4b79178e9e554615ffec7426e25e75193fa committed
Conclusion
You have now seen how to create a custom note on Miden that requires a secret preimage to be consumed. We covered:
- Creating and funding accounts (Alice and Bob)
- Hashing a secret number
- Building a note with custom logic in Miden Assembly
- Consuming the note by providing the correct secret
By leveraging Miden’s privacy features, you can create customized logic for secure asset transfers that depend on keeping parts of the transaction private.
Running the example
To run the custom note example, navigate to the rust-client directory in the miden-tutorials repository and run this command:
cd rust-client
cargo run --release --bin hash_preimage_note
Continue learning
Next tutorial: How to Use Unauthenticated Notes