Restrict keys to specific function calls
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 with a single unrestricted owner key and any number of limited-access keys, each restricted to a specific set of Move functions.
With native signatures, the protocol only verifies that a transaction was signed by the correct key — it has no visibility into what the transaction actually does. Account Abstraction authenticator functions receive both AuthContext and TxContext, giving them access to the full list of PTB commands via auth_ctx.tx_commands(). This makes it possible to inspect and enforce what a key is allowed to call, directly at authentication time, before any execution happens.
This guide builds on the Create an Account Using the Builder Pattern and Authenticate an Account with a Public Key how-tos. Reviewing those first will help you understand the account creation and public key verification used here.
Example Code
- Define the dual-flow authenticator. If the provided key matches the stored owner key, the transaction passes immediately with no restrictions. Otherwise the key is checked against an on-chain allow-set: the signature is verified, the transaction must contain exactly one command, and that command must call a function the key has been granted access to.
// === Authenticators ===
/// Dual-flow authenticator
///
/// **Owner flow (bypass):**
/// If `ctx.sender()` equals the account address, we verify the signature against the stored
/// owner public key. If verification succeeds, authentication passes immediately (no Function Call Keys
/// checks and no command count enforcement).
///
/// **Delegated flow (function-call-key):**
/// If `ctx.sender()` is not the account address, we treat the provided `pub_key` as a delegated key:
/// 1) Verify signature against `pub_key`.
/// 2) Require exactly one PTB command.
/// 3) Extract `FunctionRef` from that sole command.
/// 4) Assert that `function_ref` is allowed for `pub_key` in this account’s store.
///
/// Fails with:
/// - `EFunctionCallKeysNotInitialized` if the store is missing (delegated flow).
/// - `EEd25519VerificationFailed` if signature verification fails (owner or delegated flow).
/// - `EInvalidAmountOfCommands` if the PTB has ≠ 1 command (delegated flow).
/// - `EUnauthorized` if the function is not authorized for the delegated key (delegated flow).
#[authenticator]
public fun ed25519_authenticator(
account: &IOTAccount,
pub_key: vector<u8>,
signature: vector<u8>,
auth_ctx: &AuthContext,
ctx: &TxContext,
) {
// Verify against the stored owner public key.
let owner_pk = account.borrow_public_key();
let is_owner = pub_key == owner_pk;
let is_ed25519_verified = ed25519::ed25519_verify(&signature, &pub_key, ctx.digest());
if (is_owner) {
// OWNER FLOW
assert!(is_ed25519_verified, EEd25519VerificationFailed);
} else {
// FUNCTION CALL KEY FLOW
assert!(
account.has_field(function_call_keys_store_field_name()),
EFunctionCallKeysNotInitialized,
);
// Verify delegated signature against provided pub_key.
assert!(is_ed25519_verified, EEd25519VerificationFailed);
// Require exactly one command.
assert!(auth_ctx.tx_commands().length() == 1, EInvalidAmountOfCommands);
let command = &auth_ctx.tx_commands()[0];
let function_ref = command.extract_function_ref();
// Check allow-set membership.
assert!(account.has_permission(pub_key, &function_ref), EUnauthorized);
}
}
- Create the account with the owner public key and an empty
FunctionCallKeysStoreattached as a dynamic field.
/// Create an IOTAccount with a FunctionCallKeysStore.
///
/// The generated account is first protected by an
/// Ed25519 authentication and then by store of FunctionCallKeys.
/// The provided `public_key` will be used for Ed25519 authentication of the owner of the account.
/// While the `FunctionCallKeysStore` will be used to manage function-level permissions for delegated keys.
public fun create(
public_key: vector<u8>,
admin: Option<address>,
authenticator: AuthenticatorFunctionRefV1<IOTAccount>,
ctx: &mut TxContext,
) {
// Create builder and attach the public key and the FunctionCallKeysStore field to the account.
let builder = iotaccount::builder(authenticator, ctx)
.with_public_key(public_key)
.with_field(
function_call_keys_store_field_name(),
function_call_keys_store::build(ctx),
);
// Optionally attach the admin
let builder = if (admin.is_some()) {
builder.with_admin(admin.destroy_some())
} else {
builder
};
// Finally, build the account and share it.
builder.build();
}
- Grant or revoke function call access for a key. Only the account owner can modify the store.
/// Grants (allows) a `FunctionRef` under a specific `pub_key`.
/// Only the account owner can mutate this field.
public fun grant_permission(
account: &mut IOTAccount,
pub_key: vector<u8>,
function_ref: FunctionRef,
ctx: &TxContext,
) {
assert!(
account.has_field(function_call_keys_store_field_name()),
EFunctionCallKeysNotInitialized,
);
let function_call_keys_store = account.borrow_field_mut<_, FunctionCallKeysStore>(
function_call_keys_store_field_name(),
ctx,
);
function_call_keys_store.allow(pub_key, function_ref);
}
/// Revokes (disallows) a `FunctionRef` under a specific `pub_key`.
/// Only the account owner can mutate this field.
public fun revoke_permission(
account: &mut IOTAccount,
pub_key: vector<u8>,
function_ref: &FunctionRef,
ctx: &TxContext,
) {
assert!(
account.has_field(function_call_keys_store_field_name()),
EFunctionCallKeysNotInitialized,
);
let function_call_keys_store = account.borrow_field_mut<_, FunctionCallKeysStore>(
function_call_keys_store_field_name(),
ctx,
);
function_call_keys_store.disallow(pub_key, function_ref);
}
Expected Behavior
- The owner key can execute any programmable transaction without restriction.
- Any other key is limited to exactly one Move function call per transaction, and only if that function has been explicitly granted.
- Transactions are rejected during authentication if the called function is not in the allow-set, if the transaction contains more than one command, or if signature verification fails.
Full Example Code
- Function call keys
- Function call keys store
// Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
/// The IOTAccount with FunctionCallKeys defines an account that can be used to allow function-level
/// delegation through the usage of function call keys. An owner controls the account, while different
/// users can be granted permissions to call specific functions through the usage of function call keys.
///
/// This module provides:
/// - `attach` to initialize the per-account allow-set (a dynamic field).
/// - `create` to create a new `IOTAccount` with a public key and an authenticator.
/// - `grant_permission` / `revoke_permission` admin operations over a per-pubkey allow-set.
/// - `has_permission` read-only query.
/// - `authenticate` dual-flow implementation:
/// 1. OWNER FLOW (bypass): if the provided signature verifies against the account owner
/// Ed25519 public key (stored by the underlying account), authentication succeeds **without**
/// enforcing any function call key restrictions or command count checks.
/// 2. FUNCTION CALL KEY FLOW (delegated): otherwise, we treat `pub_key` as a delegated key:
/// - verify signature against `pub_key`
/// - enforce exactly one PTB command
/// - extract a `FunctionRef` from that sole command and ensure it is allowed for `pub_key`.
///
/// This allows the true account owner to perform arbitrary programmable transactions while
/// enabling granular function-level delegation to other keys.
module function_call_keys::function_call_keys;
use function_call_keys::function_call_keys_store::{
Self,
FunctionRef,
FunctionCallKeysStore,
allow,
disallow,
is_allowed,
function_call_keys_store_field_name
};
use iota::authenticator_function::AuthenticatorFunctionRefV1;
use iota::ed25519;
use iota::ptb_command::Command;
use iotaccount::iotaccount::{Self, IOTAccountBuilder, IOTAccount};
use public_key_authentication::public_key_iotaccount;
/// Allows calling `.with_public_key` on an `IOTAccountBuilder` to set a `public_key`.
use fun public_key_iotaccount::with_public_key as IOTAccountBuilder.with_public_key;
/// Allows calling `.borrow_public_key` on an `IOTAccountBuilder` to set a `public_key`.
use fun public_key_iotaccount::borrow_public_key as IOTAccount.borrow_public_key;
/// Allows calling `.has_permission` on an `IOTAccountBuilder` to set a `public_key`.
use fun has_permission as IOTAccount.has_permission;
/// Allows calling `.extract_function_ref` on a `Command` to extract a `FunctionRef`.
use fun function_call_keys_store::extract_function_ref as Command.extract_function_ref;
// === Errors ===
/// DF missing (forgot to `create`).
#[error(code = 0)]
const EFunctionCallKeysNotInitialized: vector<u8> =
b"The function call key has not been initialized";
/// PTB does not contain **exactly one** command.
#[error(code = 1)]
const EInvalidAmountOfCommands: vector<u8> = b"Invalid number of commands";
/// Called function not in the allow-set.
#[error(code = 2)]
const EUnauthorized: vector<u8> = b"Function call key is not the allowed set";
/// Ed225519 verification has failed (delegated flow).
#[error(code = 3)]
const EEd25519VerificationFailed: vector<u8> = b"Ed25519 verification has failed";
// === Structs ===
// === IOTAccount with FunctionCallKeys Handling ===
/// Create an IOTAccount with a FunctionCallKeysStore.
///
/// The generated account is first protected by an
/// Ed25519 authentication and then by store of FunctionCallKeys.
/// The provided `public_key` will be used for Ed25519 authentication of the owner of the account.
/// While the `FunctionCallKeysStore` will be used to manage function-level permissions for delegated keys.
public fun create(
public_key: vector<u8>,
admin: Option<address>,
authenticator: AuthenticatorFunctionRefV1<IOTAccount>,
ctx: &mut TxContext,
) {
// Create builder and attach the public key and the FunctionCallKeysStore field to the account.
let builder = iotaccount::builder(authenticator, ctx)
.with_public_key(public_key)
.with_field(
function_call_keys_store_field_name(),
function_call_keys_store::build(ctx),
);
// Optionally attach the admin
let builder = if (admin.is_some()) {
builder.with_admin(admin.destroy_some())
} else {
builder
};
// Finally, build the account and share it.
builder.build();
}
/// Attach a FunctionCallKeysStore as a dynamic field to the account being built.
public fun with_function_call_keys_store(
builder: IOTAccountBuilder,
function_call_keys_store: FunctionCallKeysStore,
): IOTAccountBuilder {
builder.with_field(function_call_keys_store_field_name(), function_call_keys_store)
}
// === Authenticators ===
/// Dual-flow authenticator
///
/// **Owner flow (bypass):**
/// If `ctx.sender()` equals the account address, we verify the signature against the stored
/// owner public key. If verification succeeds, authentication passes immediately (no Function Call Keys
/// checks and no command count enforcement).
///
/// **Delegated flow (function-call-key):**
/// If `ctx.sender()` is not the account address, we treat the provided `pub_key` as a delegated key:
/// 1) Verify signature against `pub_key`.
/// 2) Require exactly one PTB command.
/// 3) Extract `FunctionRef` from that sole command.
/// 4) Assert that `function_ref` is allowed for `pub_key` in this account’s store.
///
/// Fails with:
/// - `EFunctionCallKeysNotInitialized` if the store is missing (delegated flow).
/// - `EEd25519VerificationFailed` if signature verification fails (owner or delegated flow).
/// - `EInvalidAmountOfCommands` if the PTB has ≠ 1 command (delegated flow).
/// - `EUnauthorized` if the function is not authorized for the delegated key (delegated flow).
#[authenticator]
public fun ed25519_authenticator(
account: &IOTAccount,
pub_key: vector<u8>,
signature: vector<u8>,
auth_ctx: &AuthContext,
ctx: &TxContext,
) {
// Verify against the stored owner public key.
let owner_pk = account.borrow_public_key();
let is_owner = pub_key == owner_pk;
let is_ed25519_verified = ed25519::ed25519_verify(&signature, &pub_key, ctx.digest());
if (is_owner) {
// OWNER FLOW
assert!(is_ed25519_verified, EEd25519VerificationFailed);
} else {
// FUNCTION CALL KEY FLOW
assert!(
account.has_field(function_call_keys_store_field_name()),
EFunctionCallKeysNotInitialized,
);
// Verify delegated signature against provided pub_key.
assert!(is_ed25519_verified, EEd25519VerificationFailed);
// Require exactly one command.
assert!(auth_ctx.tx_commands().length() == 1, EInvalidAmountOfCommands);
let command = &auth_ctx.tx_commands()[0];
let function_ref = command.extract_function_ref();
// Check allow-set membership.
assert!(account.has_permission(pub_key, &function_ref), EUnauthorized);
}
}
// === IOTAccount FunctionCallKeysStore Modification Functions ===
/// Grants (allows) a `FunctionRef` under a specific `pub_key`.
/// Only the account owner can mutate this field.
public fun grant_permission(
account: &mut IOTAccount,
pub_key: vector<u8>,
function_ref: FunctionRef,
ctx: &TxContext,
) {
assert!(
account.has_field(function_call_keys_store_field_name()),
EFunctionCallKeysNotInitialized,
);
let function_call_keys_store = account.borrow_field_mut<_, FunctionCallKeysStore>(
function_call_keys_store_field_name(),
ctx,
);
function_call_keys_store.allow(pub_key, function_ref);
}
/// Revokes (disallows) a `FunctionRef` under a specific `pub_key`.
/// Only the account owner can mutate this field.
public fun revoke_permission(
account: &mut IOTAccount,
pub_key: vector<u8>,
function_ref: &FunctionRef,
ctx: &TxContext,
) {
assert!(
account.has_field(function_call_keys_store_field_name()),
EFunctionCallKeysNotInitialized,
);
let function_call_keys_store = account.borrow_field_mut<_, FunctionCallKeysStore>(
function_call_keys_store_field_name(),
ctx,
);
function_call_keys_store.disallow(pub_key, function_ref);
}
// === View Functions ===
/// Read-only query for membership in the per-pubkey allow-set.
public fun has_permission(
account: &IOTAccount,
pub_key: vector<u8>,
function_ref: &FunctionRef,
): bool {
if (!account.has_field(function_call_keys_store_field_name())) return false;
let function_call_keys_store = account.borrow_field<_, FunctionCallKeysStore>(
function_call_keys_store_field_name(),
);
function_call_keys_store.is_allowed(pub_key, function_ref)
}
// Copyright (c) 2025 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
/// Storage & helpers for Function Call Keys allow-set.
///
/// This module owns:
/// - The **dynamic field key** under which the store lives on an `IOTAccount`.
/// - The canonical `FunctionRef` (package, module, function) identifier.
/// - A small store type backed by `VecSet<FunctionRef>` to model an **allow-set**.
/// - Idempotent operations to **allow / disallow / query** a function call key.
/// - A helper to **extract** a `FunctionRef` from a `Command::MoveCall`
module function_call_keys::function_call_keys_store;
use iota::ptb_command::Command;
use iota::table::{Self as tbl, Table};
use iota::vec_set::{Self, VecSet};
use std::ascii;
// === Errors ===
#[error(code = 1)]
const EFunctionCallKeyAlreadyAdded: vector<u8> = b"The function call key has been added already";
#[error(code = 2)]
const EFunctionCallKeyDoesNotExist: vector<u8> = b"The function call key does not exist";
#[error(code = 3)]
const EPublicKeyNotFound: vector<u8> = b"Public key entry not found";
#[error(code = 4)]
const EProgrammableMoveCallExpected: vector<u8> = b"The command is not a programmable Move call";
// === Structs ===
/// An **exact** function identity (no wildcards, no type args in v1).
/// - `package`: on-chain address of the package containing the module
/// - `module_name`: ASCII bytes of the module name
/// - `function_name`: ASCII bytes of the function name
///
/// Doc: We keep these as raw bytes to match PTB.
public struct FunctionRef has copy, drop, store {
package: address,
module_name: ascii::String,
function_name: ascii::String,
}
/// Value stored under the `FunctionCallKeysName` dynamic field of an account.
/// A **set** of allowed function call keys modeled with `VecSet<FunctionRef>`.
public struct FunctionCallKeysStore has store {
function_keys: Table<vector<u8>, VecSet<FunctionRef>>,
}
/// Dynamic-field name for the Function Call Keys Store.
public struct FunctionCallKeysStoreFieldName has copy, drop, store {}
// === Helpers ===
public fun build(ctx: &mut TxContext): FunctionCallKeysStore {
FunctionCallKeysStore { function_keys: tbl::new<vector<u8>, VecSet<FunctionRef>>(ctx) }
}
public fun make_function_ref(
package: address,
module_name: ascii::String,
function_name: ascii::String,
): FunctionRef {
FunctionRef { package, module_name, function_name }
}
// === Per-pubkey allow-set ops ===
/// Ensure a VecSet exists for `pub_key`; if absent, create an empty set.
/// Returns a &mut to the set.
fun ensure_key_entry(
store: &mut FunctionCallKeysStore,
pub_key: vector<u8>,
): &mut VecSet<FunctionRef> {
if (!tbl::contains(&store.function_keys, pub_key)) {
tbl::add(&mut store.function_keys, pub_key, vec_set::empty());
};
tbl::borrow_mut(&mut store.function_keys, pub_key)
}
/// **Allow** a function call key for a specific public key.
public(package) fun allow(store: &mut FunctionCallKeysStore, pub_key: vector<u8>, fk: FunctionRef) {
let entry = ensure_key_entry(store, pub_key);
assert!(!entry.contains(&fk), EFunctionCallKeyAlreadyAdded);
entry.insert(fk);
}
/// **Disallow** a function call key for a specific public key.
public(package) fun disallow(
store: &mut FunctionCallKeysStore,
pub_key: vector<u8>,
fk: &FunctionRef,
) {
assert!(tbl::contains(&store.function_keys, pub_key), EPublicKeyNotFound);
let entry = tbl::borrow_mut(&mut store.function_keys, pub_key);
assert!(entry.contains(fk), EFunctionCallKeyDoesNotExist);
entry.remove(fk);
}
/// Query: is `fk` allowed for `pub_key`?
public fun is_allowed(store: &FunctionCallKeysStore, pub_key: vector<u8>, fk: &FunctionRef): bool {
if (!tbl::contains(&store.function_keys, pub_key)) return false;
let entry = tbl::borrow(&store.function_keys, pub_key);
entry.contains(fk)
}
/// Extracts a canonical `FunctionRef` from a PTB `Command::MoveCall`.
public fun extract_function_ref(cmd: &Command): FunctionRef {
assert!(cmd.is_move_call(), EProgrammableMoveCallExpected);
let mc = cmd.as_move_call().destroy_some();
let package = mc.package().to_address();
let module_name = mc.module_name();
let function_name = mc.function();
make_function_ref(package, *module_name, *function_name)
}
// === Public Package ===
/// An utility function to construct the dynamic field key for the Function Call Keys Store.
public(package) fun function_call_keys_store_field_name(): FunctionCallKeysStoreFieldName {
FunctionCallKeysStoreFieldName {}
}