Create a Dynamic Multisig Account
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 that requires multiple members to approve a transaction before it can execute. Each member has a voting weight, and approval only succeeds when the total accumulated weight meets or exceeds a configurable threshold.
The authenticator receives ctx.digest() from TxContext, which is the unique digest of the AA transaction being submitted. This allows the authenticator to look up pre-collected on-chain approvals for that exact transaction. Members approve in separate transactions beforehand; the authenticator checks the accumulated weight at submission time — without any off-chain coordination.
Example Code
- Define the authenticator. It reads the digest of the account abstraction transaction being submitted, looks up the stored approvals for that digest, and verifies that the accumulated member weight meets the configured threshold.
/// A DynamicMultisigAccount authenticator.
///
/// Checks that the sender of this transaction is the account.
/// The total weight of the members who approved the transaction must be greater than or equal to the threshold.
/// If the members list is changed after the transaction proposal, only the members who are still in the list
/// are considered for the approval. Their weights are taken from the current members list.
#[authenticator]
public fun approval_authenticator(self: &DynamicMultisigAccount, _: &AuthContext, ctx: &TxContext) {
assert!(
self.total_approves(*ctx.digest()) >= self.threshold(),
ETransactionDoesNotHaveSufficientApprovals,
);
}
- Propose and approve a transaction. A member proposes the AA transaction by storing its digest on-chain. They are automatically the first approver. Other members then call
approve_transactionto add their weight. Each member can only approve once.
public fun propose_transaction(
self: &mut DynamicMultisigAccount,
transaction_digest: vector<u8>,
ctx: &TxContext,
) {
// Get the member who proposed the transaction.
let member_address = *self.members().borrow(ctx.sender()).addr();
// Store the transaction.
self.transactions_mut().add(transaction_digest, member_address);
}
/// Approves a proposed transaction.
public fun approve_transaction(
self: &mut DynamicMultisigAccount,
transaction_digest: vector<u8>,
ctx: &TxContext,
) {
// Get the member who approved the transaction.
let member_address = *self.members().borrow(ctx.sender()).addr();
// Get the transaction.
let transaction = self.transactions_mut().borrow_mut(transaction_digest);
// Approve the transaction.
transaction.add_approval(member_address);
}
- Create the account with an initial member list, weights, threshold, and authenticator. The threshold must be non-zero and reachable by the total member weight.
public fun create(
members_addresses: vector<address>,
members_weights: vector<u64>,
threshold: u64,
authenticator: AuthenticatorFunctionRefV1<DynamicMultisigAccount>,
ctx: &mut TxContext,
) {
// Create a `Members` instance.
let members = members::create(members_addresses, members_weights);
// Verify the provided data consistency.
verify_threshold(&members, threshold);
// Create a UID for an account object.
let mut id = object::new(ctx);
// Add all the data as dynamic fields.
df::add(&mut id, members_field_name(), members);
df::add(&mut id, threshold_field_name(), threshold);
df::add(&mut id, transactions_field_name(), transactions::create(ctx));
let account = DynamicMultisigAccount { id };
// Create a mutable shared account object.
account::create_account_v1(account, authenticator);
}
To update the member list, weights, threshold, or authenticator, call update_account_data. Because this function requires the sender to be the account itself, it must first pass the existing authenticator — meaning a threshold of members must have approved the update transaction. Old approvals from removed members are silently ignored at execution time, not invalidated upfront.
Expected Behavior
- Members propose a transaction by submitting its digest on-chain before sending the actual AA transaction.
- Each member can approve once; duplicate approvals are rejected.
- The AA transaction executes only if the total weight of current members who approved it meets the threshold.
- If the member list is updated after approvals are collected, only the weights of members still in the list are counted.
Full Example Code
- Dynamic multisig account module
- Members module
- Transactions module
// Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
/// The DynamicMultisigAccount module defines a generic account struct that can be used to handle a dynamic multisig
/// account. The account data, such as the members information, the threshold and the proposed transactions, are
/// stored as dynamic fields of the account object. The module provides functions to create a new
/// DynamicMultisigAccount, update the account data and propose and approve transactions.
///
/// The module also defines an authenticator that checks that the sender of the transaction is the account and that
/// the total weight of the members who approved the transaction is greater than or equal to the threshold.
module dynamic_multisig_account::dynamic_multisig_account;
use dynamic_multisig_account::members::{Self, Members};
use dynamic_multisig_account::transactions::{Self, Transactions};
use iota::account;
use iota::authenticator_function::AuthenticatorFunctionRefV1;
use iota::dynamic_field as df;
// === Errors ===
#[error(code = 0)]
const ETotalMembersWeightLessThanThreshold: vector<u8> =
b"The members weight is less than the threshold.";
#[error(code = 1)]
const EThresholdIsZero: vector<u8> = b"The threshold can not be equal to 0.";
#[error(code = 2)]
const ETransactionSenderIsNotTheAccount: vector<u8> =
b"The user who signed the transaction is not the account.";
#[error(code = 3)]
const ETransactionDoesNotHaveSufficientApprovals: vector<u8> =
b"The transaction does not have sufficient approvals.";
// === Structs ===
/// A dynamic field name for the account members.
public struct MembersFieldName has copy, drop, store {}
/// A dynamic field name for the threshold.
public struct ThresholdFieldName has copy, drop, store {}
/// A dynamic field name for the transactions.
public struct TransactionsFieldName has copy, drop, store {}
/// This struct represents a dynamic multisig account.
public struct DynamicMultisigAccount has key {
id: UID,
}
// === DynamicMultisigAccount Handling ===
/// Creates a new `DynamicMultisigAccount` instance as a shared object with the given
/// members, threshold and authenticator.
public fun create(
members_addresses: vector<address>,
members_weights: vector<u64>,
threshold: u64,
authenticator: AuthenticatorFunctionRefV1<DynamicMultisigAccount>,
ctx: &mut TxContext,
) {
// Create a `Members` instance.
let members = members::create(members_addresses, members_weights);
// Verify the provided data consistency.
verify_threshold(&members, threshold);
// Create a UID for an account object.
let mut id = object::new(ctx);
// Add all the data as dynamic fields.
df::add(&mut id, members_field_name(), members);
df::add(&mut id, threshold_field_name(), threshold);
df::add(&mut id, transactions_field_name(), transactions::create(ctx));
let account = DynamicMultisigAccount { id };
// Create a mutable shared account object.
account::create_account_v1(account, authenticator);
}
/// Updates the account data: members information, threshold and authenticator.
/// Can be called only by the account itself, that means that this call must be approved by the account members.
/// The transactions that are proposed but not yet executed can have approves from members
/// who are not in the new members list. These approves will be ignored when checking if the transaction is approved.
public fun update_account_data(
self: &mut DynamicMultisigAccount,
members_addresses: vector<address>,
members_weights: vector<u64>,
threshold: u64,
authenticator: AuthenticatorFunctionRefV1<DynamicMultisigAccount>,
ctx: &TxContext,
) {
// Check that the sender of this transaction is the account.
ensure_tx_sender_is_account(self, ctx);
// Create a `Members` instance.
let members = members::create(members_addresses, members_weights);
// Verify the provided data consistency.
verify_threshold(&members, threshold);
let account_id = &mut self.id;
// Update the dynamic fields. It is expected that the fields already exist.
update_df(account_id, members_field_name(), members);
update_df(account_id, threshold_field_name(), threshold);
account::rotate_auth_function_ref_v1(self, authenticator);
}
/// Proposes a new transaction to be approved by the account members.
/// The member who proposes the transaction is added as the first approver.
public fun propose_transaction(
self: &mut DynamicMultisigAccount,
transaction_digest: vector<u8>,
ctx: &TxContext,
) {
// Get the member who proposed the transaction.
let member_address = *self.members().borrow(ctx.sender()).addr();
// Store the transaction.
self.transactions_mut().add(transaction_digest, member_address);
}
/// Approves a proposed transaction.
public fun approve_transaction(
self: &mut DynamicMultisigAccount,
transaction_digest: vector<u8>,
ctx: &TxContext,
) {
// Get the member who approved the transaction.
let member_address = *self.members().borrow(ctx.sender()).addr();
// Get the transaction.
let transaction = self.transactions_mut().borrow_mut(transaction_digest);
// Approve the transaction.
transaction.add_approval(member_address);
}
/// Removes a transaction.
/// It can be removed ether it was executed or not.
/// Can be removed only by the account itself, that means that this call must be approved by the account members.
public fun remove_transaction(
self: &mut DynamicMultisigAccount,
transaction_digest: vector<u8>,
ctx: &TxContext,
) {
// Check that the sender of this transaction is the account.
ensure_tx_sender_is_account(self, ctx);
// Remove the transaction.
self.transactions_mut().remove(transaction_digest);
}
// === Authenticators ===
/// A DynamicMultisigAccount authenticator.
///
/// Checks that the sender of this transaction is the account.
/// The total weight of the members who approved the transaction must be greater than or equal to the threshold.
/// If the members list is changed after the transaction proposal, only the members who are still in the list
/// are considered for the approval. Their weights are taken from the current members list.
#[authenticator]
public fun approval_authenticator(self: &DynamicMultisigAccount, _: &AuthContext, ctx: &TxContext) {
assert!(
self.total_approves(*ctx.digest()) >= self.threshold(),
ETransactionDoesNotHaveSufficientApprovals,
);
}
// === View Functions ===
/// Returns the account address.
public fun get_address(self: &DynamicMultisigAccount): address {
self.id.to_address()
}
/// Borrows the account threshold.
public fun threshold(self: &DynamicMultisigAccount): u64 {
*df::borrow(&self.id, threshold_field_name())
}
/// Immutably borrows the account members.
public fun members(self: &DynamicMultisigAccount): &Members {
df::borrow(&self.id, members_field_name())
}
/// Immutably borrows the account transactions.
public fun transactions(self: &DynamicMultisigAccount): &Transactions {
df::borrow(&self.id, transactions_field_name())
}
/// Returns the total weight of the members who approved the transaction with the provided digest.
public fun total_approves(self: &DynamicMultisigAccount, transaction_digest: vector<u8>): u64 {
// If the transaction does not exist, the total approves is zero.
if (!self.transactions().contains(transaction_digest)) {
return 0
};
let members = self.members();
let transaction = self.transactions().borrow(transaction_digest);
// Calculate the total weight of the members who approved the transaction.
let mut total_approves = 0;
transaction.approves().do_ref!(|addr| {
if (members.contains(*addr)) {
total_approves = total_approves + members.borrow(*addr).weight();
}
});
total_approves
}
/// Immutably borrows the account authenticator.
public fun borrow_auth_function_ref_v1(
self: &DynamicMultisigAccount,
): &AuthenticatorFunctionRefV1<DynamicMultisigAccount> {
account::borrow_auth_function_ref_v1(&self.id)
}
// === Private Functions ===
/// Checks that the sender of this transaction is the account.
fun ensure_tx_sender_is_account(self: &DynamicMultisigAccount, ctx: &TxContext) {
assert!(self.id.uid_to_address() == ctx.sender(), ETransactionSenderIsNotTheAccount);
}
/// Returns the dynamic field name used to store the members information.
fun members_field_name(): MembersFieldName {
MembersFieldName {}
}
/// Returns the dynamic field name used to store the threshold.
fun threshold_field_name(): ThresholdFieldName {
ThresholdFieldName {}
}
/// Returns the dynamic field name used to store the transactions.
fun transactions_field_name(): TransactionsFieldName {
TransactionsFieldName {}
}
/// Mutably borrows the account transactions.
fun transactions_mut(self: &mut DynamicMultisigAccount): &mut Transactions {
df::borrow_mut(&mut self.id, transactions_field_name())
}
/// Verifies the threshold.
fun verify_threshold(members: &Members, threshold: u64) {
// Check that the threshold is not zero.
assert!(threshold != 0, EThresholdIsZero);
// Check that the total members weight is greater than or equal to the threshold.
assert!(members.total_weight() >= threshold, ETotalMembersWeightLessThanThreshold);
}
/// Updates a dynamic field value and returns the previous one.
/// It is supposed that the dynamic field with the given name already exists.
fun update_df<Name: copy + drop + store, Value: store>(
account_id: &mut UID,
name: Name,
value: Value,
): Value {
let previous_value = df::remove(account_id, name);
df::add(account_id, name, value);
previous_value
}
// Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
module dynamic_multisig_account::members;
// === Errors ===
#[error(code = 0)]
const EMembersComponentsHaveDifferentLengths: vector<u8> =
b"The members components have different lengths.";
#[error(code = 1)]
const EMembersMustNotContainDuplicates: vector<u8> =
b"The list of members must not contain duplicates.";
#[error(code = 2)]
const EMemberIsNotFound: vector<u8> = b"The member with the provided address is not found.";
// === Structs ===
/// Holds the information about a member.
public struct Member has drop, store {
/// The member address.
addr: address,
/// The voting power of the member.
weight: u64,
}
/// Holds the information about the account members.
public struct Members has drop, store {
/// The members collection.
list: vector<Member>,
}
// === Public Functions ===
/// Creates a `Members` instance from the given vectors of addresses and weights.
/// The vectors must have the same length.
/// The addresses must be unique.
public(package) fun create(addresses: vector<address>, weights: vector<u64>): Members {
// Check that the provided members components are valid.
check_members(&addresses, &weights);
// Create a `Members` instance.
let list = addresses.zip_map!(weights, |addr, weight| Member { addr, weight });
Members { list }
}
/// Mutably borrows the account member with the provided address.
public(package) fun borrow_mut(self: &mut Members, addr: address): &mut Member {
let index = find_index(self, addr);
assert!(index.is_some(), EMemberIsNotFound);
self.list.borrow_mut(*index.borrow())
}
// === View Functions ===
/// Checks if the account has a member with the provided address.
public fun contains(self: &Members, addr: address): bool {
find_index(self, addr).is_some()
}
/// Immutably borrows the account member with the provided address.
public fun borrow(self: &Members, addr: address): &Member {
let index = find_index(self, addr);
assert!(index.is_some(), EMemberIsNotFound);
self.list.borrow(*index.borrow())
}
/// Returns the addresses of all the members.
public fun addresses(self: &Members): vector<address> {
let mut addresses = vector::empty<address>();
self.list.do_ref!(|m| addresses.push_back(m.addr));
addresses
}
/// Returns the weights of all the members.
public fun weights(self: &Members): vector<u64> {
let mut weights = vector::empty<u64>();
self.list.do_ref!(|m| weights.push_back(m.weight));
weights
}
/// Returns the total weight of all the members.
public fun total_weight(self: &Members): u64 {
let mut total = 0;
self.list.do_ref!(|m| total = total + m.weight);
total
}
/// Borrows the address of the member.
public fun addr(self: &Member): &address {
&self.addr
}
/// Returns the weight of the member.
public fun weight(self: &Member): u64 {
self.weight
}
// === Private Functions ===
/// Check that the provided members components are valid.
fun check_members(addresses: &vector<address>, weights: &vector<u64>) {
// Check that the lengths of the provided vectors are equal.
assert!(addresses.length() == weights.length(), EMembersComponentsHaveDifferentLengths);
// Check that the provided addresses are unique.
let mut seen = vector::empty<address>();
addresses.do_ref!(|addr| {
assert!(!seen.contains(addr), EMembersMustNotContainDuplicates);
seen.push_back(*addr);
});
}
/// Finds the index of the member with the provided address.
fun find_index(self: &Members, addr: address): Option<u64> {
self.list.find_index!(|m| m.addr == addr)
}
// Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
module dynamic_multisig_account::transactions;
use iota::table::{Self, Table};
// === Errors ===
#[error(code = 0)]
const ETransactionIsAlreadyApprovedByTheMember: vector<u8> =
b"The transaction is already approved by the member.";
#[error(code = 1)]
const ETransactionAlreadyExists: vector<u8> =
b"A transaction with the provided digest already exists.";
#[error(code = 2)]
const ETransactionDoesNotExist: vector<u8> =
b"A transaction with the provided digest does not exist.";
// === Structs ===
/// Holds the information about a transaction.
public struct Transaction has store {
/// The transaction digest.
digest: vector<u8>,
/// The members who approved the transaction.
approves: vector<address>,
}
/// Holds the information about the account transactions.
public struct Transactions has store {
/// The transactions collection.
table: Table<vector<u8>, Transaction>,
}
// === Public Functions ===
/// Creates a `Transactions` instance.
public(package) fun create(ctx: &mut TxContext): Transactions {
Transactions { table: table::new(ctx) }
}
/// Mutably borrows the account transaction with the provided digest.
public(package) fun borrow_mut(self: &mut Transactions, digest: vector<u8>): &mut Transaction {
self.table.borrow_mut(digest)
}
// === View Functions ===
/// Checks if the account has a transaction with the provided digest.
public fun contains(self: &Transactions, digest: vector<u8>): bool {
self.table.contains(digest)
}
/// Immutably borrows the account transaction with the provided digest.
public fun borrow(self: &Transactions, digest: vector<u8>): &Transaction {
self.table.borrow(digest)
}
/// Adds a new transaction to the account.
public(package) fun add(self: &mut Transactions, digest: vector<u8>, member: address) {
// Ensure that the transaction does not already exist.
assert!(!self.table.contains(digest), ETransactionAlreadyExists);
// Add the transaction.
self.table.add(digest, Transaction { digest, approves: vector[member] });
}
/// Returns the digest of the transaction.
public fun digest(self: &Transaction): vector<u8> {
self.digest
}
/// Returns the addresses of the members who approved the transaction.
public fun approves(self: &Transaction): &vector<address> {
&self.approves
}
// === Package Functions ===
/// Removes a transaction from the account.
/// Returns the digest and the addresses of the members who approved the transaction.
public(package) fun remove(
self: &mut Transactions,
digest: vector<u8>,
): (vector<u8>, vector<address>) {
// Ensure that the transaction exists.
assert!(self.table.contains(digest), ETransactionDoesNotExist);
// Remove the transaction and unpack it.
unpack(self.table.remove(digest))
}
/// Adds the approval of the member to the transaction.
public(package) fun add_approval(self: &mut Transaction, member: address) {
assert!(!self.approves.contains(&member), ETransactionIsAlreadyApprovedByTheMember);
self.approves.push_back(member);
}
// === Private Functions ===
/// Unpacks the transaction into its components and deletes it.
fun unpack(self: Transaction): (vector<u8>, vector<address>) {
let Transaction { digest, approves } = self;
(digest, approves)
}