Pre-Authorize Multiple Transactions with a Single Signature
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 authorize a set of transactions upfront with a single signature, so that each transaction in the set can be submitted independently without requiring a new signature.
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.
The authenticator receives ctx.digest() from TxContext, which is the unique digest of the current transaction. This allows the on-chain authenticator to verify that the transaction being submitted was part of the pre-approved set, without requiring another signature.
This example is inspired by the OneSig protocol, which proposes authorizing multiple transactions using a single signature over a Merkle root.
The implementation shown here demonstrates the core concept of verifying a transaction against a signed Merkle root. However, it is simplified for educational purposes and does not fully implement all details of the OneSig protocol.
Example Code
- Define the authenticator. It runs two checks in sequence: first verifies the signature over the Merkle root, then verifies that the current transaction's digest is a leaf in that tree.
public fun onesig_authenticator(
account: &OneSigAccount,
merkle_root: vector<u8>,
merkle_proof: vector<vector<u8>>,
signature: vector<u8>,
_: &AuthContext,
ctx: &TxContext,
) {
verify_merkle_root(account, &merkle_root, &signature);
verify_merkle_proof(&merkle_root, &merkle_proof, ctx);
}
- The two verification helpers called by the authenticator.
verify_merkle_rootchecks the Ed25519 signature against the stored public key.verify_merkle_proofhashes the transaction digest and walks up the tree using the provided sibling hashes to reconstruct the root.
/// Verify the Merkle root against the provided signature.
/// Ed25519 is used for simplicity. It can be extended to include a set of public keys to verify the signature.
fun verify_merkle_root(self: &OneSigAccount, root: &vector<u8>, signature: &vector<u8>) {
assert!(
ed25519::ed25519_verify(signature, self.public_key(), root),
EEd25519VerificationFailed,
);
}
/// Verify the Merkle proof for the transaction digest.
fun verify_merkle_proof(
merkle_root: &vector<u8>,
merkle_proof: &vector<vector<u8>>,
ctx: &TxContext,
) {
let leaf_raw = ctx.digest();
assert!(
merkle::verify_sorted_keccak_from_leaf_bytes(leaf_raw, merkle_root, merkle_proof),
EInvalidMerkleProof,
);
}
The example also creates a simple Account with a public key attached which you can check in the full example code if needed.
Expected Behavior
- A single signature over the Merkle root authorizes all transactions in the pre-approved set.
- Each transaction must be submitted with its Merkle proof; transactions not in the tree are rejected.
- Once the root is signed, any party with the transaction and its proof can submit it — no further interaction with the key is needed.
Full Example Code
- OneSig account module
- Merkle tree module
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
/// The OneSigAccount module defines an account abstraction that allows executing multiple transactions
/// with a single signature using a Merkle tree structure for transaction authorization. It includes
/// functionality for account creation, authentication, and Merkle proof verification.
///
/// The account is created with a public key and an authenticator function. To authenticate the account,
/// the authenticator verifies the provided signature against the Merkle root, which represents the set of
/// authorized transactions. It also verifies that the transaction digest is part of the authorized set
/// using the Merkle proof.
///
/// The implementation of this module is based on the OneSig protocol (https://github.com/LayerZero-Labs/OneSig)
/// and is designed for demonstration purposes only. It can be extended to support more complex authentication
/// schemes, such as multiple signatures or different types of authenticators.
module onesig::account;
use iota::account;
use iota::authenticator_function::AuthenticatorFunctionRefV1;
use iota::ed25519;
use onesig::merkle;
use public_key_authentication::public_key_authentication;
// === Errors ===
#[error(code = 0)]
const EEd25519VerificationFailed: vector<u8> = b"Ed25519 authenticator verification failed.";
#[error(code = 1)]
const EInvalidMerkleProof: vector<u8> = b"Invalid Merkle proof.";
// === Structs ===
/// This struct represents an account which allows to execute several transactions using a single signature.
public struct OneSigAccount has key {
id: UID,
}
// === OneSigAccount Handling ===
/// Creates a new `OneSigAccount` instance as a shared object with the given public key and authenticator.
public fun create(
public_key: vector<u8>,
authenticator: AuthenticatorFunctionRefV1<OneSigAccount>,
ctx: &mut TxContext,
) {
// Create the OneSig account object.
let mut account = OneSigAccount { id: object::new(ctx) };
let id = &mut account.id;
// Attach public key using the public_key_authentication module.
public_key_authentication::attach_public_key(id, public_key);
// Finalize account creation.
account::create_account_v1(account, authenticator);
}
// === Authenticators ===
/// Authenticates a transaction.
/// The signature is verified against the Merkle root, which represents the set of transactions authorized by the account.
/// The Merkle proof is verified against the transaction digest in the transaction context, ensuring that the transaction is part of the authorized set.
#[authenticator]
public fun onesig_authenticator(
account: &OneSigAccount,
merkle_root: vector<u8>,
merkle_proof: vector<vector<u8>>,
signature: vector<u8>,
_: &AuthContext,
ctx: &TxContext,
) {
verify_merkle_root(account, &merkle_root, &signature);
verify_merkle_proof(&merkle_root, &merkle_proof, ctx);
}
// === View Functions ===
/// Returns the address of the account.
public fun account_address(self: &OneSigAccount): address {
self.id.to_address()
}
/// Helper function to borrow the owner public key from the account.
public fun public_key(account: &OneSigAccount): &vector<u8> {
public_key_authentication::borrow_public_key(&account.id)
}
// === Private Functions ===
/// Verify the Merkle root against the provided signature.
/// Ed25519 is used for simplicity. It can be extended to include a set of public keys to verify the signature.
fun verify_merkle_root(self: &OneSigAccount, root: &vector<u8>, signature: &vector<u8>) {
assert!(
ed25519::ed25519_verify(signature, self.public_key(), root),
EEd25519VerificationFailed,
);
}
/// Verify the Merkle proof for the transaction digest.
fun verify_merkle_proof(
merkle_root: &vector<u8>,
merkle_proof: &vector<vector<u8>>,
ctx: &TxContext,
) {
let leaf_raw = ctx.digest();
assert!(
merkle::verify_sorted_keccak_from_leaf_bytes(leaf_raw, merkle_root, merkle_proof),
EInvalidMerkleProof,
);
}
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
//
// Portions of this file are derived from
// https://github.com/dakaii/sui-merkle-verifier
// Copyright (c) 2025 dakaii
// SPDX-License-Identifier: MIT
module onesig::merkle;
use iota::hash;
#[error(code = 0)]
const EInvalidHashLength: vector<u8> = b"A hash length is invalid.";
#[error(code = 1)]
const EInvalidPositionsLength: vector<u8> = b"A positions vector length is invalid.";
const HASH_LENGTH: u64 = 32;
/// Verify with pre-hashed 32-byte leaf and lexicographic concatenation (sorted-pair Merkle trees).
public fun verify_sorted_keccak(
leaf_hash: &vector<u8>,
root: &vector<u8>,
proof: &vector<vector<u8>>,
): bool {
assert!(root.length() == HASH_LENGTH, EInvalidHashLength);
let mut idx = 0;
let proof_len = proof.length();
while (idx < proof_len) {
let sib = proof.borrow(idx);
assert!(sib.length() == HASH_LENGTH, EInvalidHashLength);
idx = idx + 1;
};
let mut cur = *leaf_hash;
let mut i = 0;
while (i < proof_len) {
let sib = proof.borrow(i);
cur = hash_pair_sorted(&cur, sib);
i = i + 1;
};
cur.length() == HASH_LENGTH && bytes_equal(&cur, root)
}
/// Convenience: raw leaf bytes -> keccak256 before proof processing.
public fun verify_sorted_keccak_from_leaf_bytes(
leaf_raw: &vector<u8>,
root: &vector<u8>,
proof: &vector<vector<u8>>,
): bool {
let leaf_hash = hash::keccak256(leaf_raw);
verify_sorted_keccak(&leaf_hash, root, proof)
}
/// Verify using explicit left/right positions (false = sibling on left, true = sibling on right).
public fun verify_with_positions_keccak(
leaf_hash: &vector<u8>,
root: &vector<u8>,
proof: &vector<vector<u8>>,
positions: &vector<bool>,
): bool {
assert!(root.length() == HASH_LENGTH, EInvalidHashLength);
let proof_len = proof.length();
assert!(positions.length() == proof_len, EInvalidPositionsLength);
let mut cur = *leaf_hash;
let mut i = 0;
while (i < proof_len) {
let sib = proof.borrow(i);
assert!(sib.length() == HASH_LENGTH, EInvalidHashLength);
let right = *positions.borrow(i);
let pair = if (right) { concat(&cur, sib) } else { concat(sib, &cur) };
cur = hash::keccak256(&pair);
i = i + 1;
};
bytes_equal(&cur, root)
}
/// Hash a pair of byte vectors with lexicographic sorting (sorted-pair Merkle trees).
public fun hash_pair_sorted(left: &vector<u8>, right: &vector<u8>): vector<u8> {
let pair = if (bytes_lt(left, right)) {
vector::flatten(vector[*left, *right])
} else {
vector::flatten(vector[*right, *left])
};
hash::keccak256(&pair)
}
/// Lexicographic bytes compare: returns true if a < b.
public fun bytes_lt(a: &vector<u8>, b: &vector<u8>): bool {
let la = a.length();
let lb = b.length();
let mut i = 0;
let min = if (la < lb) { la } else { lb };
while (i < min) {
let a_element = *a.borrow(i);
let b_element = *b.borrow(i);
if (a_element < b_element) return true;
if (a_element > b_element) return false;
i = i + 1;
};
// all equal up to min; shorter one is "less"
la < lb
}
/// Builds a Merkle tree from an arbitrary number of leaves and returns
/// the root together with one proof per leaf.
public fun build_merkle_tree_with_proofs(
leaves: vector<vector<u8>>,
): (vector<u8>, vector<vector<vector<u8>>>) {
let n = leaves.length();
assert!(n > 0);
// Hash every leaf and initialise per-leaf bookkeeping.
let mut current_level: vector<vector<u8>> = vector[];
let mut proofs: vector<vector<vector<u8>>> = vector[];
let mut leaf_pos: vector<u64> = vector[];
let mut i = 0;
while (i < n) {
current_level.push_back(hash::keccak256(&leaves[i]));
proofs.push_back(vector[]);
leaf_pos.push_back(i);
i = i + 1;
};
// Build the tree bottom-up, collecting proof siblings along the way.
while (current_level.length() > 1) {
let level_len = current_level.length();
let mut next_level: vector<vector<u8>> = vector[];
// Pair adjacent nodes.
let mut j = 0;
while (j + 1 < level_len) {
next_level.push_back(
hash_pair_sorted(¤t_level[j], ¤t_level[j + 1]),
);
j = j + 2;
};
// Carry over an unpaired trailing node.
if (level_len % 2 == 1) {
next_level.push_back(current_level[level_len - 1]);
};
// For every original leaf, record its sibling and update its position.
i = 0;
while (i < n) {
let pos = leaf_pos[i];
if (pos % 2 == 0) {
if (pos + 1 < level_len) {
proofs[i].push_back(current_level[pos + 1]);
};
// else: unpaired last node, no sibling to record
} else {
proofs[i].push_back(current_level[pos - 1]);
};
*&mut leaf_pos[i] = pos / 2;
i = i + 1;
};
current_level = next_level;
};
(current_level[0], proofs)
}
/// Compare two byte vectors for equality.
fun bytes_equal(a: &vector<u8>, b: &vector<u8>): bool {
let la = a.length();
let lb = b.length();
if (la != lb) return false;
let mut i = 0;
while (i < la) {
if (*a.borrow(i) != *b.borrow(i)) return false;
i = i + 1;
};
true
}
/// Concatenate two borrowed byte vectors: out = a || b.
fun concat(a: &vector<u8>, b: &vector<u8>): vector<u8> {
let mut out = vector::empty<u8>();
out.append(*a);
out.append(*b);
out
}