Sponsor Transactions with a Whitelist and Per-User Gas Allowances
Account Abstraction is currently a release candidate and, therefore, only available on Testnet and Devnet.
If you test it out, please share your feedback with us in the IOTA Builders Discord.
This how-to demonstrates how to create a sponsoring account that pays gas only for transactions whose sender authenticator function is in an explicit whitelist, and only up to a per-user gas allowance — using intent-aware authentication to inspect both the sender's authenticator and the transaction's gas budget before authorizing sponsorship.
This guide builds on patterns from the Enforce a Per-Transaction Spending Limit how-to. Reviewing that one first will help you understand the PTB-scanning pattern reused here.
The authenticator receives the full AuthContext, which exposes the sender's sender_authenticator_function_info_v1() and the PTB tx_commands(). Combined with the TxContext fields sponsor() and gas_budget(), the sponsor can decide — before execution — whose transactions it is willing to pay for and how much it is willing to spend on each user.
Example Code
- Define the authenticator in
whitelist_sponsorship_authentication. It checks that the transaction is sponsored by this account, that the sender uses aMoveAuthenticatorwhose function is whitelisted, that the gas budget fits the sender's per-user allowance, and that the PTB includes adeduct_user_gas_allowancecall for this sponsor. The authenticator reads the account's policy state through the public views exposed bywhitelist_sponsorship_accountrather than reaching into its struct fields directly.
/// Authenticator for `WhitelistSponsorshipAccount`.
///
/// Aborts if:
/// - the transaction is not sponsored by this account,
/// - the sender does not use a `MoveAuthenticator`,
/// - the sender's authenticator function is not in the whitelist,
/// - the sender has no gas allowance,
/// - the transaction gas budget exceeds the sender's allowance,
/// - the PTB does not include a `deduct_user_gas_allowance` call for this sponsor.
#[authenticator]
public fun authenticator(
account: &WhitelistSponsorshipAccount,
auth_ctx: &AuthContext,
ctx: &TxContext,
) {
// Check if the transaction is sponsored by this account.
let sponsor_address = account.account_address();
assert!(ctx.sponsor() == option::some(sponsor_address), ENotASponsoredTransaction);
// Check that the sender uses a `MoveAuthenticator` whose function is in the whitelist.
let sender_info_opt = auth_ctx.sender_authenticator_function_info_v1();
assert!(sender_info_opt.is_some(), ESenderAuthenticatorFunctionMissing);
let key = key_from_info(sender_info_opt.borrow());
assert!(
account.is_authenticator_function_whitelisted(key),
EAuthenticatorFunctionNotWhitelisted,
);
// Check that the transaction gas budget fits within the sender's allowance.
// `borrow` itself aborts (with `iota::dynamic_field::EFieldDoesNotExist`) when the sender
// has no entry, so we skip an explicit `contains` check to save one `df::exists_`.
let allowances = account.borrow_user_gas_allowances();
assert!(
ctx.gas_budget() <= *allowances.borrow(ctx.sender()),
EGasBudgetExceedsAllowance,
);
// Finally, confirm the sender included a `deduct_user_gas_allowance` call in the PTB.
// The call implicitly targets `ctx.sender()` and always deducts exactly `ctx.gas_budget()`,
// so the authenticator only needs to confirm such a call exists for this sponsor — no user
// or amount argument is read here. The expected package address is read from the account's
// cached `package_addr` field, set once at `create` time via `type_name::get`, so the hot
// path doesn't pay for runtime reflection.
assert!(
has_matching_deduct_call(sponsor_address, account.borrow_package_addr(), auth_ctx),
EDeductCallMissing,
);
}
- The PTB-scan helper used by the authenticator.
has_matching_deduct_callwalksauth_ctx.tx_commands(), looks for a call todeduct_user_gas_allowancewhose sole argument is this sponsor account, and returnstrueon the first match. Because the call implicitly targetsctx.sender()and always deductsctx.gas_budget(), the scan doesn't need to read or compare any other arguments. The expected package address is passed in from the account's cachedpackage_addrfield (set once atcreatetime), and the module/function names are byte constants — so the per-command comparison reduces to cheap byte-equality checks with no runtime reflection.
/// Scans the PTB commands for a call to `deduct_user_gas_allowance` on this sponsor account,
/// returning `true` on the first match.
fun has_matching_deduct_call(
sponsor_address: address,
expected_package_addr: address,
auth_ctx: &AuthContext,
): bool {
let commands = auth_ctx.tx_commands();
let inputs = auth_ctx.tx_inputs();
let expected_module = ascii::string(DEDUCT_USER_GAS_ALLOWANCE_MODULE_NAME);
let expected_function = ascii::string(DEDUCT_USER_GAS_ALLOWANCE_FUNC_NAME);
'found: {
commands.do_ref!(|command| {
command.as_move_call().do!(|call| {
if (call.move_call_function() != &expected_function) return;
if (call.module_name() != &expected_module) return;
if (object::id_to_address(call.package()) != expected_package_addr) return;
// Args: [sponsor_account]. The sponsor account argument must be an object input
// (not a pure input) whose ID equals the sponsor address.
let args = call.arguments();
let input_ix = args[0].input_index().destroy_some() as u64;
let call_arg = &inputs[input_ix];
if (call_arg.is_pure_data()) return;
let obj_data = call_arg.as_object_data().destroy_some();
let obj_id = obj_data.object_id().destroy_some();
if (object::id_to_address(&obj_id) != sponsor_address) return;
return 'found true;
});
});
false
}
}
- Create the account with an admin address. The admin address, the per-user gas allowance
Table<address, u64>, and theBagof accepted sender authenticator functions are all inline struct fields onWhitelistSponsorshipAccountso the authenticator hot path borrows them directly without any dynamic-field hop. The bag is heterogeneous inTso different sender authenticator function types share one lookup space.
}
// === Account Helpers ===
/// Creates a new `WhitelistSponsorshipAccount` as a shared object with the given admin and the
/// given sponsor authenticator. The per-user gas allowance table and the authenticator-function
/// whitelist are initialised empty.
public fun create(
admin: address,
authenticator: AuthenticatorFunctionRefV1<WhitelistSponsorshipAccount>,
ctx: &mut TxContext,
) {
// Compute the package address once, here, via runtime reflection — the authenticator reads
// this back as `account.package_addr` on every call, avoiding the per-call cost.
let self_type = std::type_name::get<WhitelistSponsorshipAccount>();
let package_addr = iota::address::from_ascii_bytes(self_type.get_address().as_bytes());
- Pay back the allowance from inside the PTB.
The authenticator itself cannot modify state. Instead, it verifies that the transaction contains a deduct_user_gas_allowance call for this sponsor.
The actual state update — decreasing ctx.sender()'s allowance by ctx.gas_budget() — happens inside deduct_user_gas_allowance. The function takes no user or amount argument: it always targets the transaction's sender and always deducts the transaction's gas budget, which means the sponsor authenticator only needs to confirm the call exists.
let sponsorship_account = WhitelistSponsorshipAccount {
id: object::new(ctx),
admin,
user_gas_allowances: table::new<address, u64>(ctx),
authenticator_functions: bag::new(ctx),
package_addr,
};
account::create_account_v1(sponsorship_account, authenticator);
}
/// Deducts the transaction's `gas_budget` from the **sender's** gas allowance on this sponsor
/// account. Intended to be called from the sender's PTB during a sponsored transaction so the
/// sender's allowance is reduced by exactly the gas budget the sponsor will pay.
///
/// The sponsor authenticator scans the PTB for the presence of this call. Because the function
/// implicitly targets `ctx.sender()` and always deducts `ctx.gas_budget()`, the authenticator
- Administer the whitelists. The admin adds or removes accepted sender authenticator functions, sets or rotates per-user gas allowances, and can deduct out-of-band from a user via
admin_deduct_user_gas_allowance(which is not scanned by the authenticator — only the sender-sidededuct_user_gas_allowanceis).
/// Borrows the table of per-user gas allowances.
public fun borrow_user_gas_allowances(account: &WhitelistSponsorshipAccount): &Table<address, u64> {
&account.user_gas_allowances
}
/// Constructs an `AuthenticatorFunctionKey` from its components.
public fun new_authenticator_function_key(
package: ID,
module_name: ascii::String,
function_name: ascii::String,
): AuthenticatorFunctionKey {
AuthenticatorFunctionKey { package, module_name, function_name }
}
// === Admin Functions ===
/// Adds an authenticator function to the whitelist. Only the admin can call this.
public fun add_authenticator_function<T: key>(
self: &mut WhitelistSponsorshipAccount,
auth_fn: AuthenticatorFunctionRefV1<T>,
ctx: &TxContext,
) {
assert!(ctx.sender() == self.admin, ENotAdmin);
let key = key_from_ref(&auth_fn);
self.authenticator_functions.add(key, auth_fn);
}
/// Removes an authenticator function from the whitelist. Only the admin can call this.
public fun remove_authenticator_function<T: key>(
self: &mut WhitelistSponsorshipAccount,
auth_fn: &AuthenticatorFunctionRefV1<T>,
ctx: &TxContext,
) {
assert!(ctx.sender() == self.admin, ENotAdmin);
let key = key_from_ref(auth_fn);
let _: AuthenticatorFunctionRefV1<T> = self.authenticator_functions.remove(key);
}
/// Sets the maximum gas budget the sponsor will cover for `user`. Only the admin can call this.
public fun add_user_gas_allowance(
self: &mut WhitelistSponsorshipAccount,
user: address,
allowance: u64,
ctx: &TxContext,
) {
assert!(ctx.sender() == self.admin, ENotAdmin);
assert!(!self.user_gas_allowances.contains(user), EUserGasAllowanceAlreadyExists);
self.user_gas_allowances.add(user, allowance);
}
/// Updates `user`'s gas allowance and returns the previous one. Only the admin can call this.
public fun rotate_user_gas_allowance(
self: &mut WhitelistSponsorshipAccount,
user: address,
allowance: u64,
ctx: &TxContext,
): u64 {
assert!(ctx.sender() == self.admin, ENotAdmin);
assert!(self.user_gas_allowances.contains(user), EUserGasAllowanceMissing);
let prev = self.user_gas_allowances.remove(user);
self.user_gas_allowances.add(user, allowance);
prev
}
/// Removes `user`'s gas allowance and returns the previous value. Only the admin can call this.
public fun remove_user_gas_allowance(
self: &mut WhitelistSponsorshipAccount,
user: address,
ctx: &TxContext,
): u64 {
assert!(ctx.sender() == self.admin, ENotAdmin);
assert!(self.user_gas_allowances.contains(user), EUserGasAllowanceMissing);
self.user_gas_allowances.remove(user)
Expected Behavior
- The authenticator scans the full PTB before execution. If the PTB does not deduct at least the transaction's gas budget against the sender's allowance, the transaction is rejected.
- The sender's
MoveAuthenticatorfunction — identified by(package, module, function)— must be present in the whitelist. Transactions from senders using a non-whitelisted authenticator function, or from senders that do not use aMoveAuthenticatorat all, are rejected. - The transaction must be sponsored by this account:
TxContext::sponsor()must beSome(account_address). - The sender must have a gas allowance entry, and
TxContext::gas_budget()must not exceed it. - The per-user allowance is decremented at runtime inside
deduct_user_gas_allowance, which aborts on insufficient allowance as a defense-in-depth check. - Administrative mutations (
add_authenticator_function,remove_authenticator_function,add_user_gas_allowance,rotate_user_gas_allowance,remove_user_gas_allowance) abort withENotAdminwhen called by any sender other than the stored admin.
Full Example Code
- Storage and admin module
- Authentication module
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
/// This module owns the storage and admin surface of `WhitelistSponsorshipAccount` — a sponsor
/// account that gates which sender authenticator functions and which per-user gas budgets it is
/// willing to pay for.
///
/// All policy state lives as inline struct fields so the authenticator hot path (in
/// `whitelist_sponsorship_authentication`) borrows it directly without any extra hop to a
/// dynamic-field-stored container:
/// - The admin address.
/// - A `Table<address, u64>` of per-user gas allowances.
/// - A `Bag` of accepted sender authenticator functions, heterogeneous in the value's `T` so
/// different authenticator-function types share one lookup space.
/// - `package_addr` — the published address of *this* package, captured once at `create` time
/// via `type_name::get<WhitelistSponsorshipAccount>()`. The authenticator reads it on every
/// call to avoid paying for runtime reflection in the hot path.
///
/// `deduct_user_gas_allowance` is callable by the sponsored user from inside their PTB so they
/// can pay back the gas budget the sponsor will spend — the authenticator scans the PTB for
/// exactly this call.
module whitelist_sponsorship::whitelist_sponsorship_account;
use iota::account;
use iota::authenticator_function::AuthenticatorFunctionRefV1;
use iota::bag::{Self, Bag};
use iota::table::{Self, Table};
use std::ascii;
// === Errors ===
#[error(code = 0)]
const ENotAdmin: vector<u8> = b"Sender is not the admin of this account.";
#[error(code = 1)]
const EUserGasAllowanceMissing: vector<u8> = b"User gas allowance missing.";
#[error(code = 2)]
const EUserGasAllowanceAlreadyExists: vector<u8> = b"User gas allowance already exists.";
#[error(code = 3)]
const EInsufficientAllowanceForDeduction: vector<u8> =
b"Allowance is insufficient for the deducted amount.";
// === Structs ===
/// A sponsoring account whose authenticator enforces a whitelist of accepted sender
/// authenticator functions and per-user gas allowances. All policy state lives as inline
/// fields so the authenticator hot path borrows it directly: the admin, the per-user gas
/// allowance table, the `Bag` of accepted sender authenticator functions (heterogeneous in
/// `T`, keyed by `AuthenticatorFunctionKey`), and a cached `package_addr` set once at
/// `create` time via `type_name::get<WhitelistSponsorshipAccount>()`.
public struct WhitelistSponsorshipAccount has key {
id: UID,
admin: address,
user_gas_allowances: Table<address, u64>,
authenticator_functions: Bag,
package_addr: address,
}
/// A type-erased identity of an authenticator function `(package, module, function)`. Entries
/// with different `T` parameters in the source `AuthenticatorFunctionRefV1<T>` share the same
/// lookup space because the key drops the type parameter.
public struct AuthenticatorFunctionKey has copy, drop, store {
package: ID,
module_name: ascii::String,
function_name: ascii::String,
}
// === Account Helpers ===
/// Creates a new `WhitelistSponsorshipAccount` as a shared object with the given admin and the
/// given sponsor authenticator. The per-user gas allowance table and the authenticator-function
/// whitelist are initialised empty.
public fun create(
admin: address,
authenticator: AuthenticatorFunctionRefV1<WhitelistSponsorshipAccount>,
ctx: &mut TxContext,
) {
// Compute the package address once, here, via runtime reflection — the authenticator reads
// this back as `account.package_addr` on every call, avoiding the per-call cost.
let self_type = std::type_name::get<WhitelistSponsorshipAccount>();
let package_addr = iota::address::from_ascii_bytes(self_type.get_address().as_bytes());
let sponsorship_account = WhitelistSponsorshipAccount {
id: object::new(ctx),
admin,
user_gas_allowances: table::new<address, u64>(ctx),
authenticator_functions: bag::new(ctx),
package_addr,
};
account::create_account_v1(sponsorship_account, authenticator);
}
/// Deducts the transaction's `gas_budget` from the **sender's** gas allowance on this sponsor
/// account. Intended to be called from the sender's PTB during a sponsored transaction so the
/// sender's allowance is reduced by exactly the gas budget the sponsor will pay.
///
/// The sponsor authenticator scans the PTB for the presence of this call. Because the function
/// implicitly targets `ctx.sender()` and always deducts `ctx.gas_budget()`, the authenticator
/// only needs to confirm such a call exists — it doesn't have to read or compare any arguments
/// besides the sponsor account itself.
public fun deduct_user_gas_allowance(self: &mut WhitelistSponsorshipAccount, ctx: &TxContext) {
let sender = ctx.sender();
assert!(self.user_gas_allowances.contains(sender), EUserGasAllowanceMissing);
let amount = ctx.gas_budget();
let entry = self.user_gas_allowances.borrow_mut(sender);
assert!(*entry >= amount, EInsufficientAllowanceForDeduction);
*entry = *entry - amount;
}
// === View Functions ===
/// Returns the account's UID.
public fun borrow_uid(self: &WhitelistSponsorshipAccount): &UID {
&self.id
}
/// Returns the account's address.
public fun account_address(self: &WhitelistSponsorshipAccount): address {
self.id.to_address()
}
/// Returns the admin address.
public fun borrow_admin(self: &WhitelistSponsorshipAccount): address {
self.admin
}
/// Returns the cached package address (the published address of this package, captured at
/// `create` time via `type_name::get<WhitelistSponsorshipAccount>()`). The authenticator reads
/// this on every call to avoid the per-authenticator reflection cost.
public fun borrow_package_addr(self: &WhitelistSponsorshipAccount): address {
self.package_addr
}
/// Returns true if `key` names an accepted sender authenticator function for this account.
public fun is_authenticator_function_whitelisted(
account: &WhitelistSponsorshipAccount,
key: AuthenticatorFunctionKey,
): bool {
account.authenticator_functions.contains(key)
}
/// Borrows the bag of accepted sender authenticator functions.
public fun borrow_authenticator_functions(account: &WhitelistSponsorshipAccount): &Bag {
&account.authenticator_functions
}
/// Borrows the table of per-user gas allowances.
public fun borrow_user_gas_allowances(account: &WhitelistSponsorshipAccount): &Table<address, u64> {
&account.user_gas_allowances
}
/// Constructs an `AuthenticatorFunctionKey` from its components.
public fun new_authenticator_function_key(
package: ID,
module_name: ascii::String,
function_name: ascii::String,
): AuthenticatorFunctionKey {
AuthenticatorFunctionKey { package, module_name, function_name }
}
// === Admin Functions ===
/// Adds an authenticator function to the whitelist. Only the admin can call this.
public fun add_authenticator_function<T: key>(
self: &mut WhitelistSponsorshipAccount,
auth_fn: AuthenticatorFunctionRefV1<T>,
ctx: &TxContext,
) {
assert!(ctx.sender() == self.admin, ENotAdmin);
let key = key_from_ref(&auth_fn);
self.authenticator_functions.add(key, auth_fn);
}
/// Removes an authenticator function from the whitelist. Only the admin can call this.
public fun remove_authenticator_function<T: key>(
self: &mut WhitelistSponsorshipAccount,
auth_fn: &AuthenticatorFunctionRefV1<T>,
ctx: &TxContext,
) {
assert!(ctx.sender() == self.admin, ENotAdmin);
let key = key_from_ref(auth_fn);
let _: AuthenticatorFunctionRefV1<T> = self.authenticator_functions.remove(key);
}
/// Sets the maximum gas budget the sponsor will cover for `user`. Only the admin can call this.
public fun add_user_gas_allowance(
self: &mut WhitelistSponsorshipAccount,
user: address,
allowance: u64,
ctx: &TxContext,
) {
assert!(ctx.sender() == self.admin, ENotAdmin);
assert!(!self.user_gas_allowances.contains(user), EUserGasAllowanceAlreadyExists);
self.user_gas_allowances.add(user, allowance);
}
/// Updates `user`'s gas allowance and returns the previous one. Only the admin can call this.
public fun rotate_user_gas_allowance(
self: &mut WhitelistSponsorshipAccount,
user: address,
allowance: u64,
ctx: &TxContext,
): u64 {
assert!(ctx.sender() == self.admin, ENotAdmin);
assert!(self.user_gas_allowances.contains(user), EUserGasAllowanceMissing);
let prev = self.user_gas_allowances.remove(user);
self.user_gas_allowances.add(user, allowance);
prev
}
/// Removes `user`'s gas allowance and returns the previous value. Only the admin can call this.
public fun remove_user_gas_allowance(
self: &mut WhitelistSponsorshipAccount,
user: address,
ctx: &TxContext,
): u64 {
assert!(ctx.sender() == self.admin, ENotAdmin);
assert!(self.user_gas_allowances.contains(user), EUserGasAllowanceMissing);
self.user_gas_allowances.remove(user)
}
/// Admin-side deduction: subtracts `amount` from `user`'s gas allowance without going through
/// the sponsored-transaction flow. Useful for out-of-band rebalancing — not scanned by the
/// authenticator and so does not, on its own, satisfy the sponsor's PTB-deduct requirement.
public fun admin_deduct_user_gas_allowance(
self: &mut WhitelistSponsorshipAccount,
user: address,
amount: u64,
ctx: &TxContext,
) {
assert!(ctx.sender() == self.admin, ENotAdmin);
assert!(self.user_gas_allowances.contains(user), EUserGasAllowanceMissing);
let entry = self.user_gas_allowances.borrow_mut(user);
assert!(*entry >= amount, EInsufficientAllowanceForDeduction);
*entry = *entry - amount;
}
// === Private Functions ===
/// Derives an `AuthenticatorFunctionKey` from an `AuthenticatorFunctionRefV1<T>`.
fun key_from_ref<T: key>(auth_fn: &AuthenticatorFunctionRefV1<T>): AuthenticatorFunctionKey {
AuthenticatorFunctionKey {
package: auth_fn.package(),
module_name: *auth_fn.module_name(),
function_name: *auth_fn.function_name(),
}
}
// Copyright (c) 2026 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0
/// This module owns the authenticator surface of `WhitelistSponsorshipAccount`. It is split out
/// from `whitelist_sponsorship_account` so the storage/admin module stays focused on state
/// management and so the authenticator's PTB-scan helpers (which would otherwise be private
/// helpers inside the storage module) live alongside the `#[authenticator]` function they
/// support.
///
/// The authenticator reads the account's policy state through the public views exposed by
/// `whitelist_sponsorship_account` — it never reaches into the struct's fields directly.
module whitelist_sponsorship::whitelist_sponsorship_authentication;
use iota::auth_context::AuthenticatorFunctionInfoV1;
use iota::ptb_command::ProgrammableMoveCall;
use std::ascii;
use whitelist_sponsorship::whitelist_sponsorship_account::{
Self,
AuthenticatorFunctionKey,
WhitelistSponsorshipAccount
};
/// Method-syntax alias for `ptb_command::function`, which clashes with the `function_name`
/// accessor on `AuthenticatorFunctionKey`.
use fun iota::ptb_command::function as ProgrammableMoveCall.move_call_function;
// === Errors ===
#[error(code = 0)]
const ENotASponsoredTransaction: vector<u8> = b"Transaction is not sponsored by this account.";
#[error(code = 1)]
const ESenderAuthenticatorFunctionMissing: vector<u8> = b"Sender does not use a MoveAuthenticator.";
#[error(code = 2)]
const EAuthenticatorFunctionNotWhitelisted: vector<u8> = b"Authenticator function not whitelisted.";
#[error(code = 3)]
const EGasBudgetExceedsAllowance: vector<u8> =
b"Transaction gas budget exceeds the sponsored user's allowance.";
#[error(code = 4)]
const EDeductCallMissing: vector<u8> =
b"PTB does not contain a `deduct_user_gas_allowance` call for this sponsor and sender.";
// === Constants ===
/// The module name of `whitelist_sponsorship_account`, used by the PTB scan to identify calls
/// to `deduct_user_gas_allowance`.
const DEDUCT_USER_GAS_ALLOWANCE_MODULE_NAME: vector<u8> = b"whitelist_sponsorship_account";
/// The function name of `deduct_user_gas_allowance` in `whitelist_sponsorship_account`, used by
/// the PTB scan.
const DEDUCT_USER_GAS_ALLOWANCE_FUNC_NAME: vector<u8> = b"deduct_user_gas_allowance";
// === Authenticators ===
/// Authenticator for `WhitelistSponsorshipAccount`.
///
/// Aborts if:
/// - the transaction is not sponsored by this account,
/// - the sender does not use a `MoveAuthenticator`,
/// - the sender's authenticator function is not in the whitelist,
/// - the sender has no gas allowance,
/// - the transaction gas budget exceeds the sender's allowance,
/// - the PTB does not include a `deduct_user_gas_allowance` call for this sponsor.
#[authenticator]
public fun authenticator(
account: &WhitelistSponsorshipAccount,
auth_ctx: &AuthContext,
ctx: &TxContext,
) {
// Check if the transaction is sponsored by this account.
let sponsor_address = account.account_address();
assert!(ctx.sponsor() == option::some(sponsor_address), ENotASponsoredTransaction);
// Check that the sender uses a `MoveAuthenticator` whose function is in the whitelist.
let sender_info_opt = auth_ctx.sender_authenticator_function_info_v1();
assert!(sender_info_opt.is_some(), ESenderAuthenticatorFunctionMissing);
let key = key_from_info(sender_info_opt.borrow());
assert!(
account.is_authenticator_function_whitelisted(key),
EAuthenticatorFunctionNotWhitelisted,
);
// Check that the transaction gas budget fits within the sender's allowance.
// `borrow` itself aborts (with `iota::dynamic_field::EFieldDoesNotExist`) when the sender
// has no entry, so we skip an explicit `contains` check to save one `df::exists_`.
let allowances = account.borrow_user_gas_allowances();
assert!(
ctx.gas_budget() <= *allowances.borrow(ctx.sender()),
EGasBudgetExceedsAllowance,
);
// Finally, confirm the sender included a `deduct_user_gas_allowance` call in the PTB.
// The call implicitly targets `ctx.sender()` and always deducts exactly `ctx.gas_budget()`,
// so the authenticator only needs to confirm such a call exists for this sponsor — no user
// or amount argument is read here. The expected package address is read from the account's
// cached `package_addr` field, set once at `create` time via `type_name::get`, so the hot
// path doesn't pay for runtime reflection.
assert!(
has_matching_deduct_call(sponsor_address, account.borrow_package_addr(), auth_ctx),
EDeductCallMissing,
);
}
// === Private Functions ===
/// Derives an `AuthenticatorFunctionKey` from the framework's type-erased
/// `AuthenticatorFunctionInfoV1` returned by `AuthContext`.
fun key_from_info(info: &AuthenticatorFunctionInfoV1): AuthenticatorFunctionKey {
whitelist_sponsorship_account::new_authenticator_function_key(
info.package(),
*info.module_name(),
*info.function_name(),
)
}
/// Scans the PTB commands for a call to `deduct_user_gas_allowance` on this sponsor account,
/// returning `true` on the first match.
fun has_matching_deduct_call(
sponsor_address: address,
expected_package_addr: address,
auth_ctx: &AuthContext,
): bool {
let commands = auth_ctx.tx_commands();
let inputs = auth_ctx.tx_inputs();
let expected_module = ascii::string(DEDUCT_USER_GAS_ALLOWANCE_MODULE_NAME);
let expected_function = ascii::string(DEDUCT_USER_GAS_ALLOWANCE_FUNC_NAME);
'found: {
commands.do_ref!(|command| {
command.as_move_call().do!(|call| {
if (call.move_call_function() != &expected_function) return;
if (call.module_name() != &expected_module) return;
if (object::id_to_address(call.package()) != expected_package_addr) return;
// Args: [sponsor_account]. The sponsor account argument must be an object input
// (not a pure input) whose ID equals the sponsor address.
let args = call.arguments();
let input_ix = args[0].input_index().destroy_some() as u64;
let call_arg = &inputs[input_ix];
if (call_arg.is_pure_data()) return;
let obj_data = call_arg.as_object_data().destroy_some();
let obj_id = obj_data.object_id().destroy_some();
if (object::id_to_address(&obj_id) != sponsor_address) return;
return 'found true;
});
});
false
}
}