NEAR Smart Contract Fundamentals for EVM Developers
Jerry George (Jbotrex)4 min read·Just now--
Part 1 of a three-article series on building with NEAR as an Ethereum developer. This article covers contract structure, storage, view methods, and change methods, written for someone who already thinks in Solidity.
The Mental Model Shift
The syntax is easy. The hard part is unlearning Ethereum assumptions that feel so natural you don’t notice you’re making them.
Your First NEAR Contract
import { NearBindgen, view, call, initialize, assert, near } from "near-sdk-js";
@NearBindgen({ requireInit: true })
class Contract {
// state and storage
owner_id: string = "";
greeting: string = "";
// schema (compulsory)
static schema = {
owner_id: "string",
greeting: "string",
};
@initialize({})
init({ owner_id, greeting = "Hello from NEAR!" }: { owner_id: string; greeting?: string }): void {
this.owner_id = owner_id;
this.greeting = greeting;
}
@view({})
get_greeting(): string {
return this.greeting;
}
@call({})
set_greeting({ new_greeting }: { new_greeting: string }): void {
assert(
near.predecessorAccountId() === this.owner_id,
"Only owner can set greeting"
);
near.log(`Saving greeting ${new_greeting}`);
this.greeting = new_greeting;
}
}This looks like a Solidity contract with decorators instead of modifiers. The execution model underneath is fundamentally different.
Contract Structure: One Account, One Contract
On Ethereum, contracts and wallets are two separate entity types. A contract address holds bytecode. An EOA holds a key. They never overlap.
NEAR has one account type. Every account can optionally hold a contract. alice.near can hold your token contract and still sign transactions with her key. The account is the contract.
@NearBindgen handles serializing your class fields to NEAR's key-value storage and deserializing them back on every call. Your class fields are your contract state. No mappings, no SSTORE, no storage slots.
@NearBindgen({ requireInit: true })
class Contract {
// state and storage
owner_id: string = ""; // persisted to account storage
greeting: string = ""; // persisted to account storage⚠️ Always use requireInit: true. Without it, the first person to call any function triggers the constructor. In a contract that sets owner_id from near.predecessorAccountId(), that means the first caller becomes the owner. This has been exploited.
Initialization: You Control It Explicitly
On Ethereum, constructors run exactly once at deployment. NEAR doesn’t work that way. Deployment and initialization are separate steps.
@initialize({ privateFunction: true })
init({ owner_id, greeting = "Hello from NEAR!" }: { owner_id: string; greeting?: string }): void {
this.owner_id = owner_id;
this.greeting = greeting;
}@initializeensures this method can only be called once. Call it twice and the contract panics.- Miss calling it before other methods and, with
requireInit: true, the contract panics there too.
# Deploy the Wasm
near deploy mycontract.near --wasmFile contract.wasm
# Initialize with explicit owner
near call mycontract.near init \
'{"owner_id": "alice.near"}' \
--accountId alice.nearThe tradeoff: this separation is more flexible than Solidity constructors. You can batch deploy and init, or delay initialization deliberately. But the contract is not in a safe state between deploy and init.
View Methods: Read-Only, Free to Call
@view({})
get_greeting(): string {
return this.greeting;
}Property Detail Modifies state? No Costs gas? No Requires transaction? No. Direct RPC query. Solidity equivalent view modifier
near view mycontract.near get_greeting '{}'
# "Hello from NEAR!"⚠️ Calling near.predecessorAccountId() inside a @view method will panic. There is no predecessor in a view call. Use near.currentAccountId() instead.
Change Methods: Transactions That Modify State
@call({})
set_greeting({ new_greeting }: { new_greeting: string }): void {
assert(
near.predecessorAccountId() === this.owner_id,
"Only owner can set greeting"
);
near.log(`Saving greeting ${new_greeting}`);
this.greeting = new_greeting;
}NEAR Solidity Equivalent @call non-view function near.predecessorAccountId() msg.sender assert require
assert panics and reverts state changes, but only for the current receipt. In cross-contract call chains, each call is a separate receipt. A failure in one does not automatically unwind previous receipts. For a single-contract method, it behaves exactly like require.
# Owner sets greeting
near call mycontract.near set_greeting \
'{"new_greeting": "Hello World"}' \
--accountId alice.near
# Non-owner attempt, panics: "Only owner can set greeting"
near call mycontract.near set_greeting \
'{"new_greeting": "Hacked"}' \
--accountId bob.nearA Note on near-sdk-js Versions
You will likely encounter patterns in learning environments that differ from the official documentation:
@NearBindgen({})
class Contract {
constructor({ owners } = { owners: [] }) {
this.owners = owners || [];
}
@call({})
init({ initial_owner }) { // plain @call, not @initialize
this.owners.push(initial_owner);
}
@call({})
add_owner({ account }) {
require(this.is_owner(near.predecessorAccountId()), "Only owner");
this.owners.push(account);
}
}Learning Env Production Init @call named init @initialize decorator Uninit guard None requireInit: true Access control require() assert() Collections Plain arrays/objects Vector, LookupMap Constructor Inline predecessorAccountId() Empty strings + init
Three of these differences are safety issues:
@initializeguarantees one-time-only. A regular@callnamedinitcan be called repeatedly, overwriting state each time.requireInit: trueprevents any call before initialization.requireis not exported by the SDK. It will throw aReferenceError.
The State Model: What Actually Gets Stored
In Solidity, every state variable maps to a specific storage slot. NEAR uses a key-value store. @NearBindgen serializes your entire class via Borsh and deserializes it back on every call.
⚠️ Schema changes require a migration function. If you redeploy with a different class structure (adding a field, renaming one, changing a type), the old serialized bytes won’t deserialize into your new struct. The contract panics on every call.
Storage on NEAR is also not free. Every byte requires a proportional amount of NEAR tokens locked in the account. This is called storage staking. Tokens are returned when storage is freed. It’s not a fee; it’s a deposit.
The Five Core Patterns
Every NEAR contract is built on these five patterns:
@NearBindgen: state serialization and contract lifecyclerequireInit+@initialize: safe, explicit initialization@view: read-only queries, no gas, no transaction@call+assert: state changes with access controlnear.predecessorAccountId(): the NEAR equivalent ofmsg.sender
Get these right and you have the skeleton of any NEAR contract. Everything else (collections, events, cross-contract calls, upgrades) is built on top.