Most token standards don’t give you a clean way to run custom code on every transfer. On EVM, if you want to enforce an allowlist or take a fee on every token move, you’re overriding internal functions in your ERC-20 or reaching for ERC-777 (which most wallets and protocols don’t support well). Aptos took a different route. Their Fungible Asset standard has hooks built directly into the framework.
The feature is called Dispatchable Fungible Assets (DFA), introduced in AIP-73. You register custom withdraw and deposit functions when you create your token, and the Aptos framework calls them automatically on every transfer. No wrapper contracts. No asking users to call a special function. The normal transfer path just runs your code.
What you can hook into
DFA gives you three hook points. Two are the ones you’ll actually use, and a third that’s more niche.
The withdraw hook fires whenever tokens leave a store. This is where you’d implement things like transfer fees (skim a percentage before the tokens reach the recipient) or time-locked vesting (reject the withdrawal if a lockup period hasn’t passed). Your function receives the store, the requested amount, and a TransferRef that lets you perform the actual withdrawal:
public fun withdraw<T: key>(
store: Object<T>,
amount: u64,
transfer_ref: &TransferRef,
): FungibleAsset The deposit hook fires when tokens arrive at a store. Allowlisting lives here: check that the destination address is approved before accepting the deposit. Same idea with compliance checks or KYC gates.
public fun deposit<T: key>(
store: Object<T>,
fa: FungibleAsset,
transfer_ref: &TransferRef,
) There’s also a derived balance hook that lets you return a custom balance calculation. I haven’t needed this one in practice, but it exists:
public fun derived_balance_function<T: key>(store: Object<T>): u128 The signatures are strict. Your functions need to match these exactly or registration will fail.
Registering your hooks
Registration happens once, when you create the fungible asset. You build a FunctionInfo that points to your hook function, then pass it to register_dispatch_functions.
use aptos_framework::dispatchable_fungible_asset;
use aptos_framework::function_info;
fun init_module(deployer: &signer) {
// ... create your fungible asset, get constructor_ref ...
let withdraw_hook = function_info::new_function_info(
deployer,
string::utf8(b"my_token"), // your module name
string::utf8(b"withdraw"), // your function name
);
dispatchable_fungible_asset::register_dispatch_functions(
&constructor_ref,
option::some(withdraw_hook), // custom withdraw
option::none(), // default deposit
option::none(), // default balance
);
} Pass option::some(function_info) for hooks you want to customize, option::none() for the ones that should use default behavior. You don’t have to override all three.
One thing to note: this ties the hook to the token permanently. You’re calling this with the ConstructorRef, which is only available during object creation. There’s no “update the hook later” mechanism. Choose carefully.
A practical example
Here’s a deposit hook that enforces an allowlist. Only addresses that have been explicitly approved can receive the token:
module my_addr::gated_token {
use aptos_framework::fungible_asset::{self, FungibleAsset, TransferRef};
use aptos_framework::object::{self, Object};
use aptos_framework::dispatchable_fungible_asset;
use aptos_framework::function_info;
use aptos_framework::primary_fungible_store;
use std::option;
use std::string;
use std::smart_table::{Self, SmartTable};
use std::signer;
const E_NOT_ON_ALLOWLIST: u64 = 1;
struct AllowList has key {
addresses: SmartTable<address, bool>,
}
fun init_module(deployer: &signer) {
let constructor_ref = object::create_sticky_object(@my_addr);
primary_fungible_store::create_primary_store_enabled_fungible_asset(
&constructor_ref,
option::none(),
string::utf8(b"Gated Token"),
string::utf8(b"GATE"),
8,
string::utf8(b""),
string::utf8(b""),
);
// register custom deposit hook
let deposit_hook = function_info::new_function_info(
deployer,
string::utf8(b"gated_token"),
string::utf8(b"deposit"),
);
dispatchable_fungible_asset::register_dispatch_functions(
&constructor_ref,
option::none(),
option::some(deposit_hook),
option::none(),
);
// initialize the allowlist
move_to(deployer, AllowList {
addresses: smart_table::new(),
});
}
/// Deposit hook: only allowed addresses can receive tokens
public fun deposit<T: key>(
store: Object<T>,
fa: FungibleAsset,
transfer_ref: &TransferRef,
) acquires AllowList {
let recipient = object::owner(store);
let allowlist = borrow_global<AllowList>(@my_addr);
assert!(
smart_table::contains(&allowlist.addresses, recipient),
E_NOT_ON_ALLOWLIST,
);
fungible_asset::deposit_with_ref(transfer_ref, store, fa);
}
/// Admin function to add an address to the allowlist
public entry fun add_to_allowlist(
admin: &signer,
addr: address,
) acquires AllowList {
assert!(signer::address_of(admin) == @my_addr, 2);
let allowlist = borrow_global_mut<AllowList>(@my_addr);
smart_table::upsert(&mut allowlist.addresses, addr, true);
}
} The pattern is straightforward: check your condition, then call the _with_ref version of the operation to actually execute it.
Anyone transferring this token through the normal Aptos transfer path will hit the deposit hook automatically. They don’t need to know it exists. If the recipient isn’t on the allowlist, the whole transaction aborts.
The reentrancy trap
This is the thing that’ll cost you an afternoon if you don’t know about it upfront.
Inside your hook functions, you must use the _with_ref variants of fungible asset operations. That means fungible_asset::deposit_with_ref, fungible_asset::withdraw_with_ref, etc. If you call the regular fungible_asset::deposit or fungible_asset::withdraw, you’ll get a RUNTIME_DISPATCH_ERROR with error code 4037.
Why? Move normally guarantees that module dependencies form an acyclic graph. Module A can call module B, but B can’t call back into A. Dispatch breaks this assumption, because the framework is calling into your module, and your module might call back into the framework. To prevent reentrancy issues, the runtime checks for back-edges in the call graph at execution time.
The DFA module (dispatchable_fungible_asset) is intentionally separate from fungible_asset so your hooks can call fungible_asset utilities without triggering this check. But if you call dispatchable_fungible_asset functions or primary_fungible_store (non-inline) functions from within your hook, you’ll hit the back-edge detector. Stick to fungible_asset::*_with_ref and you’re fine.
This was actually a design decision documented in AIP-73. The Aptos team considered it a stepping stone toward proper higher-order functions in Move. They got dispatch working without any VM or compiler changes by using native functions for the targeted dispatch capability.
What callers need to know
Here’s the nice part: if your users interact with the token through primary fungible stores (which is the default for most wallets and applications), they don’t need to change anything. The framework handles dispatch transparently. A standard primary_fungible_store::transfer call just works. Your hooks fire behind the scenes.
If someone is using secondary stores (less common, but it happens), they need to swap their calls from fungible_asset::withdraw / fungible_asset::deposit to dispatchable_fungible_asset::withdraw / dispatchable_fungible_asset::deposit. These replacement functions work identically for non-dispatchable assets too, so it’s a safe default.
How this compares to other chains
On EVM, you’re working against the standard rather than with it. ERC-20 has no hook mechanism. ERC-777 added hooks but introduced reentrancy risks (the DAO hack pattern) and never saw wide adoption. Most projects end up with custom _beforeTokenTransfer or _afterTokenTransfer overrides in their own contracts, which only work if everyone calls your contract directly.
Solana has a comparable feature through Transfer Hooks in the Token Extensions program (Token-2022). The concept is similar: you register a hook program at mint creation, and the token program CPIs into it on every transfer. The key difference is that Solana’s hooks receive all accounts as read-only, so they can validate and reject transfers but can’t directly manipulate funds the way Aptos hooks can with the TransferRef. Solana also requires more setup with the ExtraAccountMetaList PDA for passing additional accounts to the hook. I wrote a separate piece on how Solana transfer hooks work if you want the full comparison.
Aptos sits in a nice middle ground here. The registration is a single function call, callers don’t need to change anything, and the TransferRef gives your hooks real power over the assets. The tradeoff is that the reentrancy constraints take some getting used to, and hooks are permanent once set.
Where to go from here
The Aptos fungible asset docs cover the full API. For a more involved example, the taxed fungible asset on Aptos Learn implements buy/sell taxes through DEX pool detection. Thala Labs’ xLPT token is a production example worth looking at: it automates staking and unstaking LP tokens on every transfer.
If you’re coming from EVM and the Move syntax feels unfamiliar, the AIP-73 spec is genuinely well-written and explains the dispatch mechanism and reentrancy protection in detail. It’s one of the better AIPs I’ve read.