with one click
fungible-asset-security
// Trigger FA_STANDARD flag detected (protocol uses FungibleAsset standard) - Used by Breadth agents, depth-token-flow
// Trigger FA_STANDARD flag detected (protocol uses FungibleAsset standard) - Used by Breadth agents, depth-token-flow
[HINT] Download the complete skill directory including SKILL.md and all related files
| name | fungible-asset-security |
| description | Trigger FA_STANDARD flag detected (protocol uses FungibleAsset standard) - Used by Breadth agents, depth-token-flow |
Trigger: FA_STANDARD flag detected (protocol uses FungibleAsset standard) Used by: Breadth agents, depth-token-flow Covers: FungibleAsset metadata validation, zero-value exploitation, store ownership, dispatchable hooks, Ref safety, Coin-to-FA migration
Audit FungibleAsset standard usage for Aptos-specific vulnerabilities. The FA standard introduces object-based token management with capabilities (MintRef, BurnRef, TransferRef, FreezeRef) and optional dispatchable hooks. Incorrect usage creates counterfeit token acceptance, forced transfers, reentrancy, and accounting mismatches.
For EVERY function that accepts a FungibleAsset parameter or reads from a FungibleStore:
| # | Function | Accepts FA/Reads Store | Validates Metadata? | Expected Metadata | Bypass Possible? |
|---|---|---|---|---|---|
| 1 | {func} | FungibleAsset param | YES/NO | {expected_metadata_obj} | YES/NO |
How metadata validation works:
// CORRECT: validates the asset is the expected type
let metadata = fungible_asset::metadata(&fa);
assert!(metadata == expected_metadata, ERROR_WRONG_ASSET);
// VULNERABLE: no validation - accepts ANY FungibleAsset
public fun deposit(fa: FungibleAsset) {
// Attacker can pass a worthless FA created from their own metadata
fungible_asset::deposit(store, fa);
}
MANDATORY SEARCH: Grep all .move files for:
FungibleAsset in function signatures (parameters)fungible_asset::metadata(&fa) is called and comparedfungible_asset::amount(&fa) without metadata check -> FLAGSeverity: Accepting unvalidated FungibleAsset = accepting counterfeit tokens. If the function credits the user or modifies protocol state based on the FA amount -> HIGH/CRITICAL.
Analyze zero-value FungibleAsset paths:
| # | Zero-Value Source | Code Path Triggered | State Modified? | Cleanup Correct? |
|---|---|---|---|---|
| 1 | fungible_asset::zero(metadata) | {trace what happens} | YES/NO | YES/NO |
| 2 | Withdrawal of 0 amount | {trace} | YES/NO | YES/NO |
Check for each:
fungible_asset::zero(metadata) be used to trigger code paths that modify state? (e.g., register a user, set a flag, emit an event)fungible_asset::destroy_zero(fa) clean up properly, or does it leave dangling state?amount == 0 get explicitly checked and rejected at entry points?Pattern: Zero-value operations often bypass amount > 0 checks that were assumed but never written, allowing state modifications without economic cost.
Audit FungibleStore creation, ownership chains, and access control:
| Store Type | Created By | Creation Permissionless? | Owner | Can Attacker Create? |
|---|---|---|---|---|
| Primary store | primary_fungible_store::ensure_primary_store_exists() | YES - anyone can create for any address | Address owner | YES (for any address) |
| Custom store | fungible_asset::create_store() on ConstructorRef | Only during object construction | Object owner | Depends on who can construct |
CRITICAL: primary_fungible_store::ensure_primary_store_exists(addr, metadata) is permissionless. An attacker can create a primary store for ANY address for ANY metadata. If the protocol assumes a store's existence means the user has interacted with the protocol -> FINDING.
| Object A | Owns Object B | B Has FungibleStore | A Can Withdraw from B? |
|---|---|---|---|
| {object} | {child_object} | YES/NO | YES - via object ownership chain |
Check: If Object A owns Object B which owns a FungibleStore, the owner of Object A can withdraw from B's store through the ownership chain. Trace all object ownership hierarchies for unintended fund access paths.
| Function | Expects Store At | Actually Reads From | Match? |
|---|---|---|---|
| {func} | Protocol-controlled store | User-supplied address | VERIFY |
Pattern: Protocol calculates expected store address but user can supply a different store address. If the function doesn't verify the store belongs to the expected object/address -> FINDING.
If the protocol uses dispatchable FungibleAsset (custom withdraw, deposit, or derived_balance hooks):
| Hook Type | Registered? | Implementation Module | Can Reenter? | Can Revert? | Can Manipulate? |
|---|---|---|---|---|---|
| withdraw | YES/NO | {module::func} | ANALYZE | ANALYZE | ANALYZE |
| deposit | YES/NO | {module::func} | ANALYZE | ANALYZE | ANALYZE |
| derived_balance | YES/NO | {module::func} | ANALYZE | N/A | ANALYZE |
For each registered hook:
#[module_lock] applied to the registering module? (prevents indirect reentrancy but NOT direct)Reentrancy sequence:
Module::transfer() {
1. Read balance (CHECK)
2. Deduct from source store → triggers withdraw hook (INTERACTION before EFFECT completion)
3. Withdraw hook reenters Module::another_function()
4. another_function() sees partially-updated state
// ...
}
Can a deposit hook unconditionally revert to prevent deposits into a specific store?
If derived_balance hook is registered:
fungible_asset::balance(store) expecting the real balance?balance() calls derived_balance hook if registered - the returned value may differ from actual stored amountAudit the lifecycle and access control of FungibleAsset capability references:
| Ref Type | Stored Where | Who Has Access | Can Be Extracted? | Impact If Leaked |
|---|---|---|---|---|
| MintRef | {object/resource} | {module/address} | YES/NO | Infinite token minting |
| BurnRef | {object/resource} | {module/address} | YES/NO | Destroy any user's tokens |
| TransferRef | {object/resource} | {module/address} | YES/NO | Bypass freeze, forced transfers |
| FreezeRef | {object/resource} | {module/address} | YES/NO | Freeze any user's store |
MANDATORY CHECK for each Ref:
key only? (safe - not extractable)store ability? (dangerous - can be moved out)TransferRef allows transfers that bypass freeze status:
fungible_asset::transfer_with_ref(ref, from_store, to_store, amount))| Ref Type | Can Be Destroyed? | Destruction Function | Consequences of Destruction |
|---|---|---|---|
| MintRef | NO (no destroy function) | N/A | Permanent minting capability |
| BurnRef | YES (burn_ref::destroy) | {if exists} | Cannot burn tokens anymore |
| TransferRef | {check} | {if exists} | Cannot force-transfer anymore |
If the protocol handles both Coin<T> and FungibleAsset:
| # | Check | Status | Impact |
|---|---|---|---|
| 1 | Are Coin and FA treated equivalently in balance accounting? | YES/NO | {if NO: describe discrepancy} |
| 2 | Does total_supply track both representations? | YES/NO | {if NO: supply tracking broken} |
| 3 | Can user deposit as Coin, then withdraw as FA (or vice versa), exploiting accounting difference? | YES/NO | {describe path} |
| 4 | Are there functions that only accept Coin but credit FA internally (or vice versa)? | YES/NO | {conversion correct?} |
| 5 | If protocol converts Coin to FA: does coin::coin_to_fungible_asset() preserve exact amount? | VERIFY | {check for fees or rounding} |
Pattern: When a protocol accepts both Coin and FungibleAsset for the same underlying token, internal accounting that tracks only one representation can be exploited by depositing in one form and withdrawing in the other.
primary_fungible_store_address(owner, metadata)) - "unexpected address" may be intentionaldeposit) may already reject zero amounts internally - verify## Finding [FA-N]: Title
**Verdict**: CONFIRMED / PARTIAL / REFUTED / CONTESTED
**Step Execution**: ✓1,2,3,4,5,6 | ✗N(reason) | ?N(uncertain)
**Rules Applied**: [R1:✓/✗, R4:✓/✗, R10:✓/✗, R11:✓/✗]
**Severity**: Critical/High/Medium/Low/Info
**Location**: module_name.move:LineN
**FA Component**: {metadata/store/hook/ref/accounting}
**Attack Vector**: {counterfeit deposit / reentrancy via hook / forced transfer via TransferRef / ...}
**Description**: What's wrong
**Impact**: What can happen (fund theft, accounting mismatch, DoS)
**Evidence**: Code snippets showing the vulnerability
**Recommendation**: How to fix
### Precondition Analysis (if PARTIAL/REFUTED)
**Missing Precondition**: [What blocks exploitation]
**Precondition Type**: STATE / ACCESS / TIMING / EXTERNAL / BALANCE
### Postcondition Analysis (if CONFIRMED/PARTIAL)
**Postconditions Created**: [What conditions this creates]
**Postcondition Types**: [List applicable types]
**Who Benefits**: [Who can use these]
| Step | Required | Completed? | Notes |
|---|---|---|---|
| 1. Metadata Validation Audit | YES | ✓/✗/? | Every FA-accepting function checked |
| 2. Zero-Value Exploitation | YES | ✓/✗/? | |
| 3. Store Creation and Ownership | YES | ✓/✗/? | Primary store permissionless creation checked |
| 3b. Transitive Ownership | YES | ✓/✗/? | Object ownership chains traced |
| 4. Dispatchable Hook Analysis | IF dispatchable FA used | ✓/✗(N/A)/? | |
| 4b. Reentrancy via Hooks | IF hooks registered | ✓/✗(N/A)/? | |
| 4c. Deposit Hook Blocking | IF deposit hook registered | ✓/✗(N/A)/? | |
| 4d. Derived Balance Manipulation | IF derived_balance hook | ✓/✗(N/A)/? | |
| 5. Ref Safety Analysis | YES | ✓/✗/? | All 4 Ref types located and access traced |
| 5b. TransferRef Bypass | IF TransferRef exists | ✓/✗(N/A)/? | |
| 6. Coin-to-FA Migration Accounting | IF both Coin and FA supported | ✓/✗(N/A)/? |
If any step skipped, document valid reason (N/A, no dispatchable hooks, no Coin support, no TransferRef).