Chromia
Start Building

14 / 19Module 2 – DappAdvanced

Dapp operations overview

This guide walks through each operation in the zkp-demo workspace. The workspace shows how to build a private token transfer system on Chromia with zero-knowledge proofs.

Overview

With the ZKP demo, you can:

  • Shield tokens, turning public tokens into private ones
  • Send private tokens without revealing who sent them
  • Unshield tokens, turning private tokens back into public ones
  • Keep a registry of private addresses

Core operations

Private transfer operation

rell/src/zkp_demo/operations.rell
operation transfer_private_tokens(    encrypted_sender_note: byte_array,    encrypted_recipient_note: byte_array) {    val public_signals = zkp.extract_signals_from_preceeding_proof_op("private_transfer");    zkp.check_plonk_proof("private_transfer", public_signals);    // Extract public signals    require(public_signals.size() == 5, "Invalid public signals size");    val commitment_input = public_signals[0];    val commitment_output_sender = public_signals[1];    val commitment_output_recipient = public_signals[2];    val recipient_private_address = public_signals[3];    val nullifier = public_signals[4];        // Check input commitment exists    val sender_unspent_commitment = require(        unspent_commitment @? { .commitment_hash == commitment_input },        "Input commitment not found in unspent list"    );        // Check nullifier has not been spent    require(        spent_nullifier @? { .nullifier_hash == nullifier } == null,        "Nullifier already spent"    );        // Update the blockchain state    create spent_nullifier ( nullifier_hash = nullifier );    delete sender_unspent_commitment;    create unspent_commitment ( commitment_hash = commitment_output_sender );    create unspent_commitment ( commitment_hash = commitment_output_recipient );        // Log the transfer event with encrypted notes    create private_transfer_event (        commitment_input,        commitment_output_sender,        commitment_output_recipient,        recipient_private_address,        nullifier,        encrypted_sender_note,        encrypted_recipient_note    );}

Purpose: Performs the main private transfer, backed by a zero-knowledge proof.

How it works:

  1. ZKP verification: Reads the public signals from a "private_transfer" proof and validates them.

  2. Input validation: Requires exactly 5 public signals:

    • commitment_input: the input commitment you are spending
    • commitment_output_sender: the sender's new output commitment (the change)
    • commitment_output_recipient: the recipient's output commitment
    • recipient_private_address: the recipient's private address
    • nullifier: blocks double-spending
  3. Security checks:

    • Confirms the input commitment is in the unspent list
    • Confirms the nullifier has never been used, so the same funds can't be spent twice
  4. State updates:

    • Records the nullifier as spent
    • Deletes the input commitment from the unspent list
    • Inserts two output commitments: the sender's change and the recipient's share
  5. Event logging: Logs the transfer together with encrypted notes that wallets use to sync.

Shield operation

rell/src/zkp_demo/private_token.rell
operation shield_tokens(    encrypted_note: byte_array) {    val account = auth.authenticate();    val public_signals = zkp.extract_signals_from_preceeding_proof_op("shield_operation");    // Extract public signals    val commitment = public_signals[0];    val amount = public_signals[2];        // Verify the ZKP    zkp.check_plonk_proof("shield_operation", public_signals);        // Subtract amount from user's public balance    assets.deduct_balance(account, zkp_demo.init.test_asset, amount);        // Add commitment to unspent commitments    create zkp_demo.unspent_commitment(commitment);        // Log the shield operation    create shield_log(        account_id = account.id,        commitment = commitment,        amount = amount,        encrypted_note = encrypted_note    );}

Purpose: Turns public FT4 tokens into private tokens.

Process:

  1. Authenticates your account
  2. Reads the public signals from the "shield_operation" ZKP:
    • commitment: the new private commitment
    • amount: how much you are shielding
  3. Verifies the zero-knowledge proof
  4. Subtracts the tokens from your public FT4 balance
  5. Adds a new unspent commitment to the private system
  6. Logs the shield operation along with an encrypted note

Unshield operation

rell/src/zkp_demo/operations.rell
operation unshield_tokens() {    val account = auth.authenticate();    val public_signals = zkp.extract_signals_from_preceeding_proof_op("unshield_operation");    // Extract public signals    val commitment = public_signals[0];    val nullifier = public_signals[1];    val amount = public_signals[3];        // Verify the ZKP    zkp.check_plonk_proof("unshield_operation", public_signals);        // Check if commitment exists    val unspent_commitment = require(zkp_demo.unspent_commitment @? { commitment }, "Commitment does not exist");    // Check if nullifier has been spent    require(zkp_demo.spent_nullifier @? { nullifier } == null, "Nullifier already spent");        // Mark nullifier as spent    create zkp_demo.spent_nullifier(nullifier);        // Remove commitment from unspent list    delete unspent_commitment;        // Add amount to user's public balance    assets.increase_balance(account, zkp_demo.init.test_asset, amount);        // Log the unshield operation    create unshield_log(        account_id = account.id,        nullifier = nullifier,        commitment = commitment,        amount = amount    );}

Purpose: Turns private tokens back into public FT4 tokens.

Process:

  1. Authenticates your account
  2. Reads the public signals from the "unshield_operation" ZKP:
    • commitment: the commitment you are unshielding
    • nullifier: blocks double-spending
    • amount: how much you are unshielding
  3. Verifies the zero-knowledge proof
  4. Checks that the commitment exists and that the nullifier is still unspent
  5. Records the nullifier as spent and deletes the commitment
  6. Credits the tokens to your public FT4 balance
  7. Logs the unshield operation

Private address registration

rell/src/zkp_demo/private_address_registry.rell
operation register_private_address(private_address: big_integer, public_encryption_key: text) {    val account = auth.authenticate();        // Check if already registered    val existing = private_address_registry @? { account.id };        if (existing != null) {        // Update existing registration        update existing (             private_address = private_address,             public_encryption_key = public_encryption_key,            registered_at = op_context.last_block_time         );    } else {        // Create new registration        create private_address_registry(            account_id = account.id,            private_address = private_address,            public_encryption_key = public_encryption_key,            registered_at = op_context.last_block_time        );    }}

Purpose: Links an FT4 account to a private address and an encryption key.

Features:

  • Maps each FT4 account ID to a private address
  • Saves public encryption keys so notes can be encrypted securely
  • Lets you update a registration that already exists
  • Allows users to communicate privately with each other
Written for this site from Chromia’s public course. Code samples are the course’s own.