Skip to main content

Authenticate Group Members with Zero-Knowledge Proofs

Account Abstraction Beta

Account Abstraction is currently in beta and therefore only available on Devnet.
If you test it out, please share your feedback with us in the IOTA Builders Discord.

This how-to demonstrates how to create an account controlled by a group of members, where any member can authenticate by proving they belong to the group using a Groth16 zero-knowledge proof — without revealing other members or the full member list.

Recommended reading

This guide builds on the Authenticate an Account with a Public Key how-to. Reviewing that one first will help you understand the public key verification used here.

Why is this possible?

Account Abstraction allows the authenticator function to receive arbitrary byte arguments and call any available Move primitive. This means any cryptographic proof system expressible in Move can serve as the authentication mechanism — including Groth16 zk-SNARKs on BN254, which IOTA provides natively via iota::groth16.

Example Code

  1. Define the two authenticators. The account offers two modes: secret mode, where the user provides a separate signing key and an opaque leaf (preserving anonymity); and public key mode, where the signing key is the same key that was inserted into the tree (simpler, but discloses identity). Both receive a Groth16 proof and a verifying key as arguments.
#[authenticator]
public fun secret_ed25519_authenticator(
account: &LeanIMTAccount,
signature: vector<u8>,
signing_public_key: vector<u8>,
leaf: vector<u8>,
pvk: vector<u8>,
proof_points: vector<u8>,
_: &AuthContext,
ctx: &TxContext,
) {
authenticate_with_secret_ed25519(
account,
signature,
signing_public_key,
leaf,
pvk,
proof_points,
ctx,
);
}

/// If the user wants to disclose their public key, a different signing key is not necessary. In this case,
/// the `leaf` can be computed from the public key and passed to the proof verification.
#[authenticator]
public fun public_key_ed25519_authenticator(
account: &LeanIMTAccount,
signature: vector<u8>,
public_key: vector<u8>,
pvk: vector<u8>,
proof_points: vector<u8>,
_: &AuthContext,
ctx: &TxContext,
) {
authenticate_with_public_key_ed25519(
account,
signature,
public_key,
pvk,
proof_points,
ctx,
);
}
  1. The authentication helpers called by each authenticator. Both verify the Ed25519 signature over the transaction digest first, then call verify_proof. In public key mode, the leaf is derived from the public key via a double Poseidon hash before proof verification.
public fun authenticate_with_secret_ed25519(
account: &LeanIMTAccount,
signature: vector<u8>,
signing_public_key: vector<u8>,
leaf: vector<u8>,
pvk: vector<u8>,
proof_points: vector<u8>,
ctx: &TxContext,
) {
check_tx_digest_ed25519_signature(signature, signing_public_key, ctx);

lean_imt::verify_proof(pvk, proof_points, account.root, leaf);
}

// This function performs the actual authentication logic for `authenticate_with_public_key`.
// It checks the signature, then it derives the leaf from the public key and then verifies that
// the leaf is part of the lean IMT with the given root using the provided proof.
public fun authenticate_with_public_key_ed25519(
account: &LeanIMTAccount,
signature: vector<u8>,
public_key: vector<u8>,
pvk: vector<u8>,
proof_points: vector<u8>,
ctx: &TxContext,
) {
check_tx_digest_ed25519_signature(signature, public_key, ctx);

let leaf = lean_imt::derive_leaf_from_public_key(public_key);

lean_imt::verify_proof(pvk, proof_points, account.root, leaf);
}
  1. The Groth16 membership proof verifier. It combines the stored tree root and the submitted leaf as the public inputs, then verifies the proof against the prepared verifying key on BN254. If the proof is invalid — meaning the leaf is not in the tree with that root — the call aborts.
public(package) fun verify_proof(
pvk: vector<u8>,
proof_points: vector<u8>,
root: vector<u8>,
leaf: vector<u8>,
) {
let pvk = groth16::prepare_verifying_key(&groth16::bn254(), &pvk);

let mut public_input_bytes = root;
public_input_bytes.append(leaf);

let proof_points = groth16::proof_points_from_bytes(proof_points);
let public_inputs = groth16::public_proof_inputs_from_bytes(public_input_bytes);
assert!(groth16::verify_groth16_proof(&groth16::bn254(), &pvk, &public_inputs, &proof_points));
}
  1. Create the account with the Merkle tree root representing the initial set of authorized members.
/// Creates a new `LeanIMTAccount` as a shared object with the given authenticator
/// and sets a root.
///
/// The `AuthenticatorFunctionRef` will be attached to the account being built.
public fun create(
root: vector<u8>,
authenticator: AuthenticatorFunctionRefV1<LeanIMTAccount>,
ctx: &mut TxContext,
) {
let account = LeanIMTAccount { id: object::new(ctx), root };
account::create_account_v1(account, authenticator);
}
note

To add or remove members, call rotate_root with a new Merkle tree root. This function requires the transaction sender to be the account itself. In a production deployment, consider protecting this with an admin capability to prevent unauthorized membership changes.

Expected Behavior

  • Any member whose leaf is included in the tree can authenticate by providing a valid Groth16 proof and a signature over the transaction digest.
  • In secret mode, the submitted leaf is opaque — membership is proven without revealing the user's public key.
  • In public key mode, the leaf is derived on-chain from the disclosed public key.
  • A proof that does not correspond to the current tree root is rejected.
  • Rotating the root immediately changes which members can authenticate.

Full Example Code

// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

module lean_imt_account::lean_imt_account;

use iota::account;
use iota::authenticator_function::AuthenticatorFunctionRefV1;
use iota::ed25519;
use lean_imt_account::lean_imt;

// === Errors ===

#[error(code = 0)]
const ETransactionSenderIsNotTheAccount: vector<u8> = b"Transaction must be signed by the account.";
#[error(code = 1)]
const EEd25519VerificationFailed: vector<u8> = b"Ed25519 authenticator verification failed.";

// === Structs ===

/// This struct represents an abstract IOTA account.
/// This account stores a root of a Merkle tree used to store eligible users.
///
/// It holds all the related data as dynamic fields to simplify updates, migrations and extensions.
/// Arbitrary dynamic fields may be added and removed as necessary.
///
/// An `LeanIMTAccount` cannot be constructed directly. To create an `LeanIMTAccount` use `LeanIMTAccountBuilder`.
public struct LeanIMTAccount has key {
id: UID,
root: vector<u8>,
}

// === LeanIMTAccount Handling ===

/// Creates a new `LeanIMTAccount` as a shared object with the given authenticator
/// and sets a root.
///
/// The `AuthenticatorFunctionRef` will be attached to the account being built.
public fun create(
root: vector<u8>,
authenticator: AuthenticatorFunctionRefV1<LeanIMTAccount>,
ctx: &mut TxContext,
) {
let account = LeanIMTAccount { id: object::new(ctx), root };
account::create_account_v1(account, authenticator);
}

/// Rotates the account root to a new one.
/// This is unsafe as anyone with access to the account could call this function and change
/// the root to an arbitrary value, potentially locking all the users out of the account.
/// An admin cap could be used to limit this.
public fun rotate_root(self: &mut LeanIMTAccount, root: vector<u8>, ctx: &TxContext) {
// Check that the sender of this transaction is the account.
ensure_tx_sender_is_account(self, ctx);

self.root = root;
}

// === Authenticators ===

/// The lean-IMT's leaves are hashes of public keys, so a user can just pass a `leaf` in order to not disclose their
/// main public key. This means that `signing_public_key` is different from the user's main public key and it is
/// only used for securing the MoveAuthenticator (by signing the TX digest).
#[authenticator]
public fun secret_ed25519_authenticator(
account: &LeanIMTAccount,
signature: vector<u8>,
signing_public_key: vector<u8>,
leaf: vector<u8>,
pvk: vector<u8>,
proof_points: vector<u8>,
_: &AuthContext,
ctx: &TxContext,
) {
authenticate_with_secret_ed25519(
account,
signature,
signing_public_key,
leaf,
pvk,
proof_points,
ctx,
);
}

/// If the user wants to disclose their public key, a different signing key is not necessary. In this case,
/// the `leaf` can be computed from the public key and passed to the proof verification.
#[authenticator]
public fun public_key_ed25519_authenticator(
account: &LeanIMTAccount,
signature: vector<u8>,
public_key: vector<u8>,
pvk: vector<u8>,
proof_points: vector<u8>,
_: &AuthContext,
ctx: &TxContext,
) {
authenticate_with_public_key_ed25519(
account,
signature,
public_key,
pvk,
proof_points,
ctx,
);
}

// === Public Authenticators Helpers ===

// This function performs the actual authentication logic for `authenticate_with_secret`.
// It checks the signature and then verifies that the provided leaf is part of the lean IMT
//with the given root using the provided proof.
public fun authenticate_with_secret_ed25519(
account: &LeanIMTAccount,
signature: vector<u8>,
signing_public_key: vector<u8>,
leaf: vector<u8>,
pvk: vector<u8>,
proof_points: vector<u8>,
ctx: &TxContext,
) {
check_tx_digest_ed25519_signature(signature, signing_public_key, ctx);

lean_imt::verify_proof(pvk, proof_points, account.root, leaf);
}

// This function performs the actual authentication logic for `authenticate_with_public_key`.
// It checks the signature, then it derives the leaf from the public key and then verifies that
// the leaf is part of the lean IMT with the given root using the provided proof.
public fun authenticate_with_public_key_ed25519(
account: &LeanIMTAccount,
signature: vector<u8>,
public_key: vector<u8>,
pvk: vector<u8>,
proof_points: vector<u8>,
ctx: &TxContext,
) {
check_tx_digest_ed25519_signature(signature, public_key, ctx);

let leaf = lean_imt::derive_leaf_from_public_key(public_key);

lean_imt::verify_proof(pvk, proof_points, account.root, leaf);
}

// === Public-View Functions ===

/// An utility function to borrow the account's root.
public fun root(account: &LeanIMTAccount): vector<u8> {
account.root
}

/// Return the account's address.
public fun account_address(self: &LeanIMTAccount): address {
self.id.to_address()
}

// === Admin Functions ===

/// Check that the sender of this transaction is the account.
fun ensure_tx_sender_is_account(self: &LeanIMTAccount, ctx: &TxContext) {
assert!(self.id.uid_to_address() == ctx.sender(), ETransactionSenderIsNotTheAccount);
}

// === Private Functions ===

// Checks that the signature is valid for the transaction digest and the given public key.
fun check_tx_digest_ed25519_signature(
signature: vector<u8>,
signing_public_key: vector<u8>,
ctx: &TxContext,
) {
assert!(
ed25519::ed25519_verify(&signature, &signing_public_key, ctx.digest()),
EEd25519VerificationFailed,
);
}