CosmWasm is not Solidity with a different syntax. The execution model, the way contracts communicate, and the threat surface are fundamentally different from the EVM. Auditors who approach CosmWasm contracts with an EVM mental model will miss entire categories of bugs while spending time looking for issues that the runtime or the language already prevent by construction.

This article builds a complete picture of CosmWasm security from the ground up: the execution model, Rust’s guarantees and their limits, the specific vulnerability classes that emerge from submessages and reply handling, access control patterns, IBC integration risks, and how to structure an audit of a CosmWasm codebase.


The CosmWasm Execution Model and the Actor Pattern

CosmWasm contracts execute inside a WebAssembly sandbox. Each contract is a stateless Wasm binary that receives a message, reads and writes to an isolated key-value store, and returns a Response object containing zero or more outgoing messages and attribute events. The host chain (implemented in Go via the x/wasm module) executes those outgoing messages after the contract returns.

This is the actor model: contracts are actors that communicate exclusively through message passing. There is no shared mutable state between contracts. There is no way for contract A to call a function pointer inside contract B and get a synchronous return value mid-execution.

Contract A executes
  → returns Response { messages: [MsgB, MsgC], ... }
    → host executes MsgB (Contract B runs to completion)
    → host executes MsgC (Contract C runs to completion)
      → if A registered a reply, host calls A.reply(result)

Understanding this dispatch loop is the foundation of every CosmWasm security analysis. The key property is atomicity at the transaction level but not at the contract level. A transaction that calls contract A, which sends a submessage to contract B, which fails, will roll back the entire transaction if the failure propagates. But if contract A catches the reply and handles the error, contract A’s state changes are committed while contract B’s changes are not—creating a partial-execution surface that is the source of several vulnerability classes.

The Response Object

Every entry point returns a Response:

use cosmwasm_std::{Response, StdResult, DepsMut, Env, MessageInfo};

pub fn execute(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    msg: ExecuteMsg,
) -> StdResult<Response> {
    // ... state mutations ...
    Ok(Response::new()
        .add_attribute("action", "transfer")
        .add_message(some_bank_send_msg)
        .add_submessage(some_submessage))
}

The distinction between add_message and add_submessage is security-critical and is discussed in depth below.


Rust’s Ownership Model: What It Prevents and What It Does Not

Rust’s borrow checker eliminates entire vulnerability classes at compile time:

  • Buffer overflows and out-of-bounds reads — Rust’s slice indexing either panics or uses safe get() returning an Option. There is no undefined behavior.
  • Use-after-free and dangling pointers — The ownership system prevents a value from being accessed after it has been moved or dropped.
  • Data races — The Send/Sync trait system prevents unsafe sharing of mutable state across threads. In a single-threaded Wasm context this is less relevant, but it means ported code cannot accidentally introduce a race.
  • Integer overflow in debug builds — Rust panics on integer overflow in debug mode. In release builds, wrapping is explicit. CosmWasm contracts should use checked_add, checked_sub, or the Uint128/Uint256 types from cosmwasm-std, which panic on overflow rather than wrap silently.
// Dangerous: wrapping subtraction in release mode
let bad = amount - fee; // can underflow if fee > amount

// Safe: explicit checked arithmetic
let safe = amount.checked_sub(fee)
    .ok_or(ContractError::InsufficientFunds {})?;

What Rust Does Not Prevent

Rust’s guarantees are about memory safety and data races, not about business logic correctness. The following classes of bugs are entirely within Rust’s type system but still represent exploitable vulnerabilities:

  • Incorrect access control checks
  • Missing state updates before dispatching messages
  • Incorrect assumptions about message ordering
  • Trusting info.sender in the wrong context
  • Logic errors in reply handling
  • IBC packet data that is not validated before use

The mental model to carry forward: Rust is a memory-safe language, not a logic-safe language.


Reentrancy in CosmWasm: Submessages and Reply Handling

Classical EVM reentrancy exploits the fact that a contract can call back into the original contract before its state is updated. The CosmWasm actor model appears to prevent this: contracts do not receive execution control back during a message dispatch. But the reply entry point reintroduces a structurally similar risk.

How Submessages Work

When a contract adds a SubMsg to its Response, it can request a reply callback:

use cosmwasm_std::{SubMsg, ReplyOn, WasmMsg, to_binary};

let submsg = SubMsg {
    id: REPLY_ID_TRANSFER,
    msg: WasmMsg::Execute {
        contract_addr: token_contract.to_string(),
        msg: to_binary(&transfer_msg)?,
        funds: vec![],
    }.into(),
    gas_limit: None,
    reply_on: ReplyOn::Always, // or Success, Error
};

After the submessage executes, the host calls back into the originating contract’s reply entry point with the submessage result. At the point reply is called, the state changes from the original execute call have already been committed to the in-memory store. They are not yet persisted to the chain (the whole transaction is still atomic), but they are visible to subsequent contract calls within the same transaction.

The Reentrancy-Equivalent Pattern

Consider a vault contract that processes a withdrawal:

// VULNERABLE: state update happens after message dispatch
pub fn execute_withdraw(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    amount: Uint128,
) -> Result<Response, ContractError> {
    let balance = BALANCES.load(deps.storage, &info.sender)?;
    if balance < amount {
        return Err(ContractError::InsufficientFunds {});
    }

    // Send tokens BEFORE updating balance
    let send_msg = SubMsg::reply_on_success(
        BankMsg::Send {
            to_address: info.sender.to_string(),
            amount: coins(amount.u128(), "uatom"),
        },
        REPLY_WITHDRAW_ID,
    );

    // Balance is updated in reply handler
    Ok(Response::new().add_submessage(send_msg))
}

pub fn reply(
    deps: DepsMut,
    _env: Env,
    msg: Reply,
) -> Result<Response, ContractError> {
    match msg.id {
        REPLY_WITHDRAW_ID => {
            // By the time we get here, tokens have already been sent
            // The state update is late
            BALANCES.update(deps.storage, &sender, |b| {
                b.unwrap_or_default().checked_sub(amount)
                    .map_err(|_| ContractError::InsufficientFunds {})
            })?;
            Ok(Response::default())
        }
        _ => Err(ContractError::UnknownReplyId {}),
    }
}

The above pattern is vulnerable because if the reply handler can be manipulated to fail or be bypassed, the balance deduction may never occur. More subtly, if the contract dispatches multiple withdrawals in the same transaction through a different path (e.g., via a governance execution that sends messages in bulk), the in-flight state before reply is resolved can be read by those subsequent calls.

Correct Pattern: Checks-Effects-Interactions in CosmWasm

Apply the same principle as in Solidity: update state first, dispatch messages second.

// SAFE: deduct balance before dispatching the send
pub fn execute_withdraw(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    amount: Uint128,
) -> Result<Response, ContractError> {
    BALANCES.update(deps.storage, &info.sender, |b| -> Result<_, ContractError> {
        b.unwrap_or_default()
            .checked_sub(amount)
            .map_err(|_| ContractError::InsufficientFunds {})
    })?;

    let send_msg = BankMsg::Send {
        to_address: info.sender.to_string(),
        amount: coins(amount.u128(), "uatom"),
    };

    Ok(Response::new()
        .add_attribute("action", "withdraw")
        .add_attribute("amount", amount)
        .add_message(send_msg))
}

No reply handler needed. Balance is deducted atomically before the bank send is dispatched.

Storing Context for Reply Handlers

A common mistake is to store intermediate state in a temporary storage slot so the reply handler can read it, without properly cleaning that slot:

const PENDING_SENDER: Item<Addr> = Item::new("pending_sender");
const PENDING_AMOUNT: Item<Uint128> = Item::new("pending_amount");

// In execute:
PENDING_SENDER.save(deps.storage, &info.sender)?;
PENDING_AMOUNT.save(deps.storage, &amount)?;

// In reply:
let sender = PENDING_SENDER.load(deps.storage)?;
let amount = PENDING_AMOUNT.load(deps.storage)?;
PENDING_SENDER.remove(deps.storage);
PENDING_AMOUNT.remove(deps.storage);

If the reply handler fails and the removal does not execute, stale context persists. A subsequent unrelated transaction can trigger the reply handler with recycled context. Always clear pending state at the start of the reply handler, not the end, and validate that the context is consistent with the incoming reply ID.


Instantiate, Execute, Query: Message Model and Access Control

CosmWasm contracts expose three primary entry points plus reply, migrate, and IBC hooks.

Sender vs. Origin

In CosmWasm, info.sender is the immediate caller—the address that dispatched the message. It is not the transaction signer. When contract A calls contract B via a WasmMsg::Execute, contract B sees info.sender == contract_A_address. This is correct and safe, but contracts must not assume info.sender is always a human wallet. Any address, including another contract, can be a sender.

// Check that the caller is a specific authorized contract, not a human
pub fn assert_authorized_router(
    deps: Deps,
    info: &MessageInfo,
) -> Result<(), ContractError> {
    let config = CONFIG.load(deps.storage)?;
    if info.sender != config.router_contract {
        return Err(ContractError::Unauthorized {});
    }
    Ok(())
}

Access Control Patterns

Owner/admin pattern using cw-ownable or manual admin storage:

use cw_ownable::{assert_owner, initialize_owner};

pub fn instantiate(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
    msg: InstantiateMsg,
) -> Result<Response, ContractError> {
    initialize_owner(
        deps.storage,
        deps.api,
        Some(info.sender.as_str()),
    )?;
    Ok(Response::default())
}

pub fn execute_privileged(
    deps: DepsMut,
    _env: Env,
    info: MessageInfo,
) -> Result<Response, ContractError> {
    assert_owner(deps.storage, &info.sender)?;
    // privileged logic
    Ok(Response::default())
}

Role-based access for multi-role contracts:

#[cw_serde]
pub enum Role {
    Admin,
    Operator,
    Pauser,
}

pub const ROLES: Map<(&Addr, &str), bool> = Map::new("roles");

pub fn has_role(deps: Deps, addr: &Addr, role: Role) -> bool {
    let key = format!("{:?}", role).to_lowercase();
    ROLES.may_load(deps.storage, (addr, &key))
        .unwrap_or(None)
        .unwrap_or(false)
}

The Missing Authentication Anti-Pattern

A surprisingly common bug is an execute handler that dispatches a privileged action based on a field in the message body rather than on info.sender:

// VULNERABLE: anyone can claim to be the admin
ExecuteMsg::AdminAction { caller, .. } => {
    let config = CONFIG.load(deps.storage)?;
    if caller == config.admin {
        // This checks a MESSAGE FIELD, not info.sender
        return execute_admin_action(deps, env, info, msg);
    }
    Err(ContractError::Unauthorized {})
}

// CORRECT: check info.sender
ExecuteMsg::AdminAction { .. } => {
    if info.sender != CONFIG.load(deps.storage)?.admin {
        return Err(ContractError::Unauthorized {});
    }
    execute_admin_action(deps, env, info, msg)
}

Query Security

Queries in CosmWasm are read-only and do not have info (no sender, no funds). They cannot mutate state. However, sensitive data exposed via queries can leak information used in front-running or griefing attacks. Querying another contract from within an execute handler using deps.querier is safe from a state-mutation standpoint, but the returned data must be validated—it reflects state at the beginning of the block, not the current in-transaction state.


Admin Key Management and Migration Security

CosmWasm contracts are uploaded as code and instantiated. The instantiation records an optional admin address in the chain state. This admin can:

  1. Migrate the contract to a new code ID
  2. Clear the admin (making the contract immutable)
  3. Transfer admin rights to a new address

Migration Attack Surface

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn migrate(
    deps: DepsMut,
    _env: Env,
    msg: MigrateMsg,
) -> Result<Response, ContractError> {
    let ver = cw2::get_contract_version(deps.storage)?;
    if ver.contract != CONTRACT_NAME {
        return Err(ContractError::InvalidMigration {});
    }
    // Validate that the new version is forward-compatible
    cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
    Ok(Response::default())
}

Security considerations for migration:

  • Admin is a single point of failure. If the admin key is compromised, the attacker can migrate to a malicious implementation that drains funds or changes logic. Use a multisig or a DAO-controlled address as the admin.
  • Migration can change storage layout. If the new code reads storage keys written by the old code with different type assumptions, it can misinterpret data. Always write explicit migration logic that transforms state.
  • Removing the admin does not freeze funds. An immutable contract can still be called normally. It simply cannot be migrated or have its admin changed.
  • The migrate entry point is itself callable by the admin without user consent. Users who interact with a contract with a mutable admin are trusting that admin implicitly.

Timelock Pattern for Migrations

For high-value contracts, consider requiring a timelock on admin actions:

pub const PENDING_MIGRATION: Item<PendingMigration> = Item::new("pending_migration");

#[cw_serde]
pub struct PendingMigration {
    pub new_code_id: u64,
    pub scheduled_at: u64, // block height or timestamp
}

pub fn propose_migration(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    new_code_id: u64,
) -> Result<Response, ContractError> {
    assert_owner(deps.storage, &info.sender)?;
    PENDING_MIGRATION.save(deps.storage, &PendingMigration {
        new_code_id,
        scheduled_at: env.block.time.seconds() + TIMELOCK_DURATION,
    })?;
    Ok(Response::new().add_attribute("action", "propose_migration"))
}

CosmWasm IBC Integration and Security Implications

IBC (Inter-Blockchain Communication) is first-class in CosmWasm. Contracts can open channels, send packets, and receive packets via six IBC entry points: ibc_channel_open, ibc_channel_connect, ibc_channel_close, ibc_packet_receive, ibc_packet_ack, and ibc_packet_timeout.

The IBC Entry Points

#[cfg_attr(not(feature = "library"), entry_point)]
pub fn ibc_packet_receive(
    deps: DepsMut,
    _env: Env,
    msg: IbcPacketReceiveMsg,
) -> Result<IbcReceiveResponse, Never> {
    // This entry point MUST NOT return an error variant.
    // Errors must be encoded in the acknowledgement.
    let result = do_ibc_packet_receive(deps, msg);
    match result {
        Ok(response) => Ok(response),
        Err(e) => Ok(IbcReceiveResponse::new()
            .set_ack(make_error_ack(e))
            .add_attribute("error", e.to_string())),
    }
}

The Never return type in ibc_packet_receive is a critical design constraint: if this function returns an Err, the relayer cannot deliver the packet and it is stuck. The correct pattern is to always return Ok and encode success or failure in the acknowledgement bytes.

IBC Security Vulnerabilities

1. Trusting packet data without validation

Packet data arrives as raw bytes and is deserialized by the receiving contract. The sender is the counterparty contract on the remote chain, which may itself be compromised or may be a forged channel.

pub fn do_ibc_packet_receive(
    deps: DepsMut,
    msg: IbcPacketReceiveMsg,
) -> Result<IbcReceiveResponse, ContractError> {
    // Always validate the channel is one you approved
    let channel = msg.packet.dest.channel_id.clone();
    let allowed = ALLOWED_CHANNELS.may_load(deps.storage, &channel)?;
    if allowed.is_none() {
        return Err(ContractError::UnauthorizedChannel {});
    }

    let packet_data: MyPacketData = from_binary(&msg.packet.data)?;
    // Validate all fields of packet_data
    validate_packet_data(&packet_data)?;
    // ...
}

2. Channel handshake validation

The ibc_channel_open handler should enforce the protocol version and ordering:

pub fn ibc_channel_open(
    _deps: DepsMut,
    _env: Env,
    msg: IbcChannelOpenMsg,
) -> Result<IbcChannelOpenResponse, ContractError> {
    let channel = msg.channel();
    if channel.order != IbcOrder::Unordered {
        return Err(ContractError::InvalidChannelOrder {});
    }
    if channel.version != IBC_APP_VERSION {
        return Err(ContractError::InvalidChannelVersion {});
    }
    Ok(Some(Ibc3ChannelOpenResponse {
        version: IBC_APP_VERSION.to_string(),
    }))
}

Failing to validate the channel order or version allows a counterparty to open a channel with unexpected semantics, potentially causing the receiving contract to misprocess packets.

3. Ack and timeout symmetry

Every sent packet must have corresponding ibc_packet_ack and ibc_packet_timeout handlers that reverse or confirm the state change initiated when the packet was sent. A common bug is updating state when sending a packet but only handling the acknowledgement, leaving funds locked if the packet times out.

// When sending: record the pending state
PENDING_TRANSFERS.save(deps.storage, &packet_sequence, &transfer_record)?;

// In ibc_packet_ack: remove pending and finalize
// In ibc_packet_timeout: remove pending and REFUND
pub fn ibc_packet_timeout(
    deps: DepsMut,
    _env: Env,
    msg: IbcPacketTimeoutMsg,
) -> Result<IbcBasicResponse, ContractError> {
    let seq = msg.packet.sequence;
    let record = PENDING_TRANSFERS.load(deps.storage, &seq)?;
    PENDING_TRANSFERS.remove(deps.storage, &seq);

    // Refund the original sender
    let refund = BankMsg::Send {
        to_address: record.sender.to_string(),
        amount: record.funds,
    };
    Ok(IbcBasicResponse::new().add_message(refund))
}

Common CosmWasm Vulnerability Patterns

1. Reply ID Collision

Reply IDs are u64 constants chosen by the developer. If two code paths use the same reply ID, the reply handler cannot distinguish which submessage triggered it:

// DANGEROUS: same ID used for two different submessages
const REPLY_ID: u64 = 1;

// In execute_transfer:
SubMsg::reply_on_success(transfer_msg, REPLY_ID)

// In execute_stake:
SubMsg::reply_on_success(stake_msg, REPLY_ID) // collision!

Use distinct constants and match exhaustively:

const REPLY_TRANSFER: u64 = 1;
const REPLY_STAKE:    u64 = 2;
const REPLY_UNSTAKE:  u64 = 3;

pub fn reply(deps: DepsMut, env: Env, msg: Reply) -> Result<Response, ContractError> {
    match msg.id {
        REPLY_TRANSFER => handle_transfer_reply(deps, env, msg),
        REPLY_STAKE    => handle_stake_reply(deps, env, msg),
        REPLY_UNSTAKE  => handle_unstake_reply(deps, env, msg),
        id => Err(ContractError::UnknownReplyId { id }),
    }
}

2. Submessage Ordering and State Visibility

Within a single transaction, submessages execute in sequence. Contract A dispatches submessage 1 (to contract B) and submessage 2 (to contract C). Contract B’s state changes are committed before contract C executes, and C can query B. If C’s logic depends on B’s state being unchanged, the ordering creates an implicit dependency that can be exploited if an attacker can influence what B does.

Always reason about what state is visible to each actor at each step of dispatch, and never assume that two submessages execute in isolation from each other’s effects.

3. State Inconsistency Across Message Boundaries

This is the most subtle class. A contract that processes a message may leave storage in a partially-updated state if any subsequent message in the same response fails and the failure is caught by a reply handler:

// execute_complex:
//   1. Update internal accounting (storage write A)
//   2. Dispatch SubMsg to external contract (may fail)
//   3. In reply handler on error: ... does NOT undo write A

If write A should only be committed when the submessage succeeds, either:

  • Use ReplyOn::Success and perform write A inside the success reply handler, or
  • Use a two-phase approach with a pending state slot

4. Integer Arithmetic in Token Math

Token amounts in Cosmos are Uint128. Silent overflow is not possible, but precision errors in intermediate calculations are:

// DANGEROUS: intermediate multiplication overflows Uint128
let fee = amount * fee_rate / FEE_DENOMINATOR;
// If amount is large and fee_rate is large, amount * fee_rate overflows

// SAFE: use Uint256 for intermediates
use cosmwasm_std::Uint256;
let fee = Uint256::from(amount)
    .checked_mul(Uint256::from(fee_rate))?
    .checked_div(Uint256::from(FEE_DENOMINATOR))?;
let fee_128 = Uint128::try_from(fee)?;

5. Unvalidated Instantiate Parameters

The InstantiateMsg sets up the initial state of a contract. If critical parameters are not validated—addresses that are not validated with deps.api.addr_validate(), fee percentages that can be set to 100%, zero durations—an attacker who controls instantiation can create a contract instance that behaves maliciously from birth.

pub fn instantiate(
    deps: DepsMut,
    _env: Env,
    _info: MessageInfo,
    msg: InstantiateMsg,
) -> Result<Response, ContractError> {
    // Validate all addresses
    let admin = deps.api.addr_validate(&msg.admin)?;
    let treasury = deps.api.addr_validate(&msg.treasury)?;

    // Validate numeric parameters
    if msg.fee_bps > 10_000 {
        return Err(ContractError::InvalidFee {});
    }
    if msg.unbonding_period.is_zero() {
        return Err(ContractError::InvalidUnbondingPeriod {});
    }

    CONFIG.save(deps.storage, &Config { admin, treasury, fee_bps: msg.fee_bps, ... })?;
    Ok(Response::default())
}

6. Querier TOCTOU

When a contract queries another contract or the bank module to make a decision, the queried state is snapshotted at the start of the block. If the contract then dispatches a message that assumes that state is still current at execution time, it may be acting on stale information. This is a time-of-check to time-of-use issue at the block level.


How Auditing CosmWasm Differs from EVM Auditing

Tooling Differences

EVM auditors rely on a rich ecosystem: Slither, Mythril, Echidna, Foundry fuzz testing, and a large library of known Solidity patterns. CosmWasm auditing is more manual. Rust’s type system and the cosmwasm-std abstractions eliminate entire classes of bugs, but the tooling for automated vulnerability detection is less mature. cargo-audit checks for known vulnerabilities in dependencies. cargo clippy catches common logic mistakes. Fuzz testing with cargo-fuzz is possible but requires more setup.

Mental Model Differences

DimensionEVM / SolidityCosmWasm / Rust
ReentrancyClassic call stack reentrancyReply-based partial reentrancy
Integer safetyOverflow wraps (pre-0.8) or revertsPanic on overflow; use checked math
Storage layoutSlot-based, manual packingKey-value store, type-safe with cw-storage-plus
UpgradeabilityProxy patterns, delegatecallNative migration via admin
Cross-contract callsSynchronous, mid-executionAsync actor model, reply callbacks
Sending tokenstransfer(), call{value:}()BankMsg, Cw20ExecuteMsg::Transfer
Access to tx contexttx.origin, msg.senderinfo.sender only (no origin)

The absence of tx.origin in CosmWasm is a security improvement—the classic tx.origin phishing attack has no direct equivalent. But the reply-based execution model introduces its own form of control-flow complexity.

What to Focus on in a CosmWasm Audit

  1. Every SubMsg dispatch: What state has been modified before this point? What does the reply handler do? Is the reply ID unique? Is error handling correct?
  2. Every IBC entry point: Are channels validated? Is the packet data deserialized and validated? Are timeouts handled symmetrically with acks?
  3. Every info.sender check: Is the sender the expected type (human wallet vs. contract)? Is there a path where the check is bypassed?
  4. The migrate entry point: Who is the admin? Is migration guarded by a timelock or multisig? Does the migration correctly transform storage?
  5. All arithmetic on token amounts: Are Uint128 overflow risks handled? Are intermediate calculations promoted to Uint256?
  6. Storage key collisions: cw-storage-plus uses string prefixes for namespaces. If two Map or Item instances share a prefix, they collide. Verify all storage keys are unique.
  7. Dependency versions: Review Cargo.lock for outdated or vulnerable dependencies. The cosmwasm-std version affects available features and security properties.

Storage Key Collision Example

// DANGEROUS: same storage key "b" used for two different items
const BALANCE: Item<Uint128> = Item::new("b");
const BIDS: Map<&Addr, Uint128> = Map::new("b"); // Collision!

// SAFE: use descriptive, unique keys
const TOTAL_BALANCE: Item<Uint128> = Item::new("total_balance");
const USER_BIDS: Map<&Addr, Uint128> = Map::new("user_bids");

CosmWasm Security Checklist

Execution Model and Message Handling

  • All state mutations follow checks-effects-interactions: storage is updated before outgoing messages are dispatched
  • Every SubMsg reply ID is a unique named constant; no two code paths share an ID
  • Reply handlers validate the incoming msg.id exhaustively and return an error for unknown IDs
  • Temporary state stored for reply context is cleared at the start of the reply handler, not the end
  • Submessage ordering dependencies are documented and intentional; no implicit cross-submessage state assumptions
  • ReplyOn::Always is only used when both success and error paths are explicitly handled

Access Control

  • Every privileged ExecuteMsg variant checks info.sender, not a field in the message body
  • Admin/owner is a multisig or DAO-controlled address, not a single EOA, for production deployments
  • There is no path where info.sender is assumed to be a human wallet when it could be a contract
  • Role-based access storage is never readable as writable via an unguarded execute variant

Arithmetic and Validation

  • All arithmetic on Uint128 values uses checked_* methods or panicking arithmetic where panic is acceptable
  • Intermediate calculations that may exceed Uint128::MAX are promoted to Uint256
  • All addresses in InstantiateMsg and ExecuteMsg are validated with deps.api.addr_validate()
  • Numeric parameters (fees, percentages, durations) are validated for reasonable bounds at instantiation
  • Division by zero is impossible: denominators are validated to be nonzero

Migration and Admin Security

  • The contract admin is documented and intentionally set or explicitly set to None for immutable contracts
  • The migrate entry point validates the source contract name and version before proceeding
  • Migration correctly handles storage layout changes without misinterpreting existing data
  • High-value contracts use a timelock or proposal mechanism before migration takes effect
  • Admin transfer logic requires acceptance from the new admin to prevent accidental transfer to an invalid address

IBC Integration

  • ibc_channel_open validates the channel order and protocol version
  • ibc_packet_receive always returns Ok and encodes errors in the acknowledgement
  • Packet data is deserialized and fully validated before any state changes
  • Only packets from previously approved channels are processed
  • Every packet send has a corresponding ibc_packet_timeout handler that reverses state changes
  • ibc_packet_ack handles both success and error acknowledgements

Storage

  • All Item, Map, or IndexedMap is accessed with a validated key — no user-supplied key reaches storage without bounds or type checking
  • Storage migrations are versioned and guarded with a migrate entrypoint
  • No sensitive data is stored in plaintext in contract state

IBC

  • ibc_channel_open validates the counterparty channel and port
  • ibc_packet_receive is idempotent — replaying a packet produces the same state change
  • ibc_packet_timeout rolls back any state change made in anticipation of the packet being acknowledged
  • Sequence numbers are tracked and replay is explicitly rejected

Admin and Ownership

  • Admin address is a multisig or governance contract, not a bare key
  • UpdateAdmin message is guarded — only the current admin can propose a new admin
  • Contracts that should be immutable use ClearAdmin at deployment to remove the admin permanently

Economic and Arithmetic

  • All Uint128 arithmetic uses checked operations — no silent overflow
  • Division results are checked for zero denominator
  • Token amounts flowing through BankMsg and Cw20ExecuteMsg are validated to be non-zero and within expected ranges