MultiversX Entry Point Analyzer
Identify the complete attack surface of a MultiversX smart contract by enumerating all public interaction points and classifying their risk levels. This is typically the first step in any security review.
When to Use
-
Starting a new security audit
-
Documenting contract public interface
-
Assessing access control coverage
-
Mapping data flow through the contract
-
Identifying high-risk endpoints for focused review
- Entry Point Identification
MultiversX Macros That Expose Functions
Macro Visibility Risk Level Description
#[endpoint]
Public write High State-changing public function
#[view]
Public read Low Read-only public function
#[payable("*")]
Accepts any token Critical Handles value transfers
#[payable("EGLD")]
Accepts EGLD only Critical Handles native currency
#[init]
Deploy only Medium Constructor (runs once)
#[upgrade]
Upgrade only Critical Migration logic
#[callback]
Internal High Async call response handler
#[only_owner]
Owner restricted Medium Admin functions
Scanning Commands
Find all endpoints
grep -n "#[endpoint" src/*.rs
Find all payable endpoints
grep -n "#[payable" src/*.rs
Find all views
grep -n "#[view" src/*.rs
Find callbacks
grep -n "#[callback" src/*.rs
Find init and upgrade
grep -n "#[init]|#[upgrade]" src/*.rs
- Risk Classification
Category A: Payable Endpoints (Critical Risk)
Functions receiving value require the most scrutiny.
#[payable("*")] #[endpoint] fn deposit(&self) { // MUST CHECK: // 1. Token identifier validation // 2. Amount > 0 validation // 3. Correct handling of multi-token transfers // 4. State updates before external calls
let payment = self.call_value().single_esdt();
require!(
payment.token_identifier == self.accepted_token().get(),
"Wrong token"
);
require!(payment.amount > 0, "Zero amount");
// Process deposit...
}
Checklist for Payable Endpoints:
-
Token ID validated against expected token(s)
-
Amount checked for minimum/maximum bounds
-
Multi-transfer handling if all_esdt_transfers() used
-
Nonce validation for NFT/SFT
-
Reentrancy protection (Checks-Effects-Interactions)
Category B: Non-Payable State-Changing Endpoints (High Risk)
Functions that modify state without payment.
#[endpoint] fn update_config(&self, new_value: BigUint) { // MUST CHECK: // 1. Who can call this? (access control) // 2. Input validation // 3. State transition validity
self.require_caller_is_admin();
require!(new_value > 0, "Invalid value");
self.config().set(new_value);
}
Checklist for State-Changing Endpoints:
-
Access control implemented and correct
-
Input validation for all parameters
-
State transitions are valid
-
Events emitted for important changes
-
No DoS vectors (unbounded loops, etc.)
Category C: View Functions (Low Risk)
Read-only functions, but still need review.
#[view(getBalance)] fn get_balance(&self, user: ManagedAddress) -> BigUint { // SHOULD CHECK: // 1. Does it actually modify state? (interior mutability) // 2. Does it leak sensitive information? // 3. Is the calculation expensive (DoS via gas)?
self.balances(&user).get()
}
Checklist for View Functions:
-
No state modification (verify no storage writes)
-
No sensitive data exposure
-
Bounded computation (no unbounded loops)
-
Block info usage appropriate (get_block_timestamp() may differ off-chain)
Category D: Init and Upgrade (Critical Risk)
Lifecycle functions with special considerations.
#[init] fn init(&self, admin: ManagedAddress) { // MUST CHECK: // 1. All required state initialized // 2. No way to re-initialize // 3. Admin/owner properly set
self.admin().set(admin);
}
#[upgrade] fn upgrade(&self) { // MUST CHECK: // 1. New storage mappers initialized // 2. Storage layout compatibility // 3. Migration logic correct }
Category E: Callbacks (High Risk)
Async call handlers with specific vulnerabilities.
#[callback] fn transfer_callback( &self, #[call_result] result: ManagedAsyncCallResult<()> ) { // MUST CHECK: // 1. Error handling (don't assume success) // 2. State reversion on failure // 3. Correct identification of original call
match result {
ManagedAsyncCallResult::Ok(_) => {
// Success path
},
ManagedAsyncCallResult::Err(_) => {
// CRITICAL: Must handle failure!
// Revert any state changes from original call
}
}
}
- Analysis Workflow
Step 1: List All Entry Points
Create an inventory table:
| Endpoint | Type | Payable | Access | Storage Touched | Risk |
|---|---|---|---|---|---|
| deposit | endpoint | * | Public | balances | Critical |
| withdraw | endpoint | No | Public | balances | Critical |
| setAdmin | endpoint | No | Owner | admin | High |
| getBalance | view | No | Public | balances (read) | Low |
| init | init | No | Deploy | admin, config | Medium |
Step 2: Tag Access Control
For each endpoint, document who can call it:
// Public - anyone can call #[endpoint] fn public_function(&self) { }
// Owner only - blockchain owner #[only_owner] #[endpoint] fn owner_function(&self) { }
// Admin only - custom access control #[endpoint] fn admin_function(&self) { self.require_caller_is_admin(); }
// Whitelisted - address in set #[endpoint] fn whitelist_function(&self) { let caller = self.blockchain().get_caller(); require!(self.whitelist().contains(&caller), "Not whitelisted"); }
Step 3: Tag Value Handling
Classify how each endpoint handles value:
Tag Meaning Example
Refusable Rejects payments Default (no #[payable] )
EGLD Only Accepts EGLD #[payable("EGLD")]
Token Only Specific ESDT #[payable("TOKEN-abc123")]
Any Token Any payment #[payable("*")]
Multi-Token Multiple payments Uses all_esdt_transfers()
Step 4: Graph Data Flow
Map which storage mappers each endpoint reads/writes:
deposit() ──writes──▶ balances ──writes──▶ total_deposited ──reads───▶ accepted_token
withdraw() ──reads/writes──▶ balances ──reads────────▶ withdrawal_fee
getBalance() ──reads──▶ balances
- Specific Attack Vectors
Privilege Escalation
Is a sensitive endpoint accidentally public?
// VULNERABLE: Missing access control #[endpoint] fn set_admin(&self, new_admin: ManagedAddress) { self.admin().set(new_admin); // Anyone can become admin! }
// CORRECT: Protected #[only_owner] #[endpoint] fn set_admin(&self, new_admin: ManagedAddress) { self.admin().set(new_admin); }
DoS via Unbounded Growth
Can public endpoints cause unbounded storage growth?
// VULNERABLE: Public endpoint adds to unbounded set #[endpoint] fn register(&self) { let caller = self.blockchain().get_caller(); self.participants().insert(caller); // Grows forever! }
// Attack: Call register() with many addresses until // any function iterating participants() runs out of gas
Missing Payment Validation
Does a payable endpoint verify what it receives?
// VULNERABLE: Accepts any token #[payable("*")] #[endpoint] fn stake(&self) { let payment = self.call_value().single_esdt(); self.staked().update(|s| *s += payment.amount); // Fake tokens accepted! }
Callback State Assumptions
Does a callback assume the async call succeeded?
// VULNERABLE: Assumes success #[callback] fn on_transfer_complete(&self) { // This runs even if transfer FAILED! self.transfer_count().update(|c| *c += 1); }
- Output Template
Entry Point Analysis: [Contract Name]
Summary
- Total Endpoints: X
- Payable Endpoints: Y (Critical)
- State-Changing: Z (High)
- Views: W (Low)
Detailed Inventory
Critical Risk (Payable)
| Endpoint | Accepts | Access | Concerns |
|---|---|---|---|
| deposit | * | Public | Token validation needed |
High Risk (State-Changing)
| Endpoint | Access | Storage Modified | Concerns |
|---|---|---|---|
| withdraw | Public | balances | Amount validation |
Medium Risk (Admin)
| Endpoint | Access | Storage Modified | Concerns |
|---|---|---|---|
| setConfig | Owner | config | Privilege escalation if misconfigured |
Low Risk (Views)
| Endpoint | Storage Read | Concerns |
|---|---|---|
| getBalance | balances | None |
Access Control Matrix
| Endpoint | Public | Owner | Admin | Whitelist |
|---|---|---|---|---|
| deposit | Yes | - | - | - |
| setAdmin | - | Yes | - | - |
Recommended Focus Areas
- [Highest priority endpoint and why]
- [Second priority]
- [Third priority]