Near Learn

NEAR contract security checklist

A practical NEAR smart contract security review checklist: callbacks, access, deposits, storage, gas and upgrades — plus how to test failure paths in sandbox.

Intermediate5 min read3-question check

Use this page as a review pass before every deploy. Each row is a bug that has cost real projects money; each links to the lesson with the vulnerable and fixed code. A checklist does not replace threat modeling, tests or an audit — it catches the well-known mistakes so reviewers can spend their time on the novel ones.

Review checklist#

AreaCheckLesson
AsyncEvery value-moving cross-contract call has a callback that handles the Err branch and undoes optimistic stateCallbacks & rollbacks
AsyncBalances are deducted before the call and restored on failure; deposits are credited only after successReentrancy across receipts
AsyncState is consistent at the end of every receipt; locks or pending flags are released on both callback branchesReentrancy across receipts
AsyncEvery callback is #[private], has no unwrap in the undo path, and has enough static gas for itPrivate callbacks
AccessAuthorization uses env::predecessor_account_id(), never signer_account_id()Predecessor vs signer
AccessContract account keys reviewed; no stray full-access keys; no unguarded add-key or deploy methodsAccess keys
AccessAsset-moving and permission-changing methods are #[payable] and call assert_one_yocto()One yocto
Money#[payable] only where needed; exact price checks; excess deposits refunded; outgoing transfers that matter have a callbackDeposits & refunds
MoneyCallers pay for the storage they add (per write or NEP-145); user-provided strings and vectors are cappedStorage attacks
Stateoverflow-checks = true in [profile.release]; balances use checked_* math with clear require! messagesOverflow & panics
StateNo change method iterates a collection that users can grow; list views are paginated with a hard capUnbounded iteration
UpgradesState layout changes ship with a #[private] #[init(ignore_state)] migrate; upgrade authority is a DAO/multisig or the contract is lockedUpgrades & migrations

Test the failure paths#

Unit tests rarely catch these bugs, because they do not run real receipts. Integration tests with near-workspaces deploy your Wasm to a local sandbox node, so callbacks, deposits, gas limits and access checks behave as on mainnet. Write a test for every failure you care about, not just the happy path.

A sandbox test that a stranger cannot call the callback directly
Rust
use serde_json::json;

#[tokio::test]
async fn callback_rejects_strangers() -> anyhow::Result<()> {
    let wasm = near_workspaces::compile_project("./").await?;
    let sandbox = near_workspaces::sandbox().await?;
    let contract = sandbox.dev_deploy(&wasm).await?;
    let attacker = sandbox.dev_create_account().await?;

    contract
        .call("new")
        .args_json(json!({ "token": "token.test.near" }))
        .transact()
        .await?
        .into_result()?;

    // #[private] must make this fail
    let res = attacker
        .call(contract.id(), "on_withdraw")
        .args_json(json!({ "user": attacker.id(), "amount": "1000000" }))
        .transact()
        .await?;
    assert!(res.is_failure());
    Ok(())
}

Before mainnet#

  • Read the official NEAR security docs — including front-running, sybil resistance and duplicate inputs, which this module does not cover.
  • Get an independent audit from a firm with NEAR and Rust experience before holding meaningful user funds, and re-audit after significant changes.
  • Publish verifiable source (NEP-330 metadata + reproducible builds) so users can check what is deployed.
  • Decide upgrade authority and key ownership up front, document it, and review the contract account’s keys after every deploy.
  • Keep a NEAR buffer on the contract account for storage, and monitor it.

Check yourself

3 questions · progress saved in this browser

  1. 1.Why are near-workspaces sandbox tests better than unit tests for catching callback bugs?
  2. 2.During review you find require!(env::signer_account_id() == self.owner) on an admin method. What should it be?
  3. 3.Which of these is NOT a way for a contract, or one of its features, to become unusable?