Smart Contract Development on Ink! (Polkadot)
Substrate chains are not EVM. Developers coming to Polkadot ecosystem with Solidity experience immediately hit fundamentally different execution model: WebAssembly runtime instead of EVM, Rust instead of Solidity, cargo-contract instead of Hardhat. Ink! is embedded DSL atop Rust, compiling to Wasm and deployed on pallet-contracts. Transferring mental models from EVM directly here is dangerous.
How Ink! Fundamentally Differs from Solidity
First thing that strikes — storage model. In Solidity mapping(address => uint256) is just slot in storage with keccak256 key. In Ink! each #[ink(storage)] field translates to separate Lazy entries in Substrate's Merkle storage tree. This means:
- No concept of "slot" in EVM sense — no slot packing
- Access to
Mapping<AccountId, Balance>isgetfrom off-chain state, not arithmetic over 32-byte word -
StorageVecin Ink! 5.x lazy by default: elements loaded only on explicit read
Second fundamental difference — call model. In EVM msg.sender always immediate caller. In Ink! self.env().caller() returns previous caller in chain. Reentrancy in Ink! physically disabled by default via ReentrancyGuard at execution environment level, unless --allow-reentrant-calls flag passed explicitly. But this doesn't mean you can relax — cross-contract calls with CallBuilder still require careful state management.
Third feature — contract lifecycle. Ink! supports #[ink(message, payable)] for native token receipt, #[ink(constructor)] for initialization, and — unique to Polkadot ecosystem — set_code_hash() for updating code without address change. This is analog of UUPS proxy from EVM world, but built into protocol.
Typical Ink! Contract Mistakes
Wrong Mapping vs StorageHashMap Usage
Ink! 4.x had ink::storage::Mapping, which doesn't implement iteration over keys (done intentionally — off-chain indexing via events, not on-chain). Developers used to EnumerableMap from OpenZeppelin start storing keys in Vec<AccountId> next to Mapping, and this breaks on scaling: Vec loaded entirely on each read, making call O(n) by gas weight.
Correct solution — index via ink::env::emit_event! and build off-chain state via Subsquid or SubQuery. Don't try to recreate on-chain iterable structures.
Weight vs Gas: Fundamentally Different Cost Model
EVM counts gas operationally. Substrate counts weight — two-dimensional resource: ref_time (nanoseconds CPU) and proof_size (bytes of light client proof). Deploying via cargo-contract need explicitly specify --gas-limit in weight units, or use dry_run for estimation.
Pattern regularly causing issues: developer does cargo-contract call without prior dry_run, contract fails with OutOfGas, team guesses what's wrong — though sufficient to run:
cargo contract call --dry-run --contract <address> --message transfer --args <args>
Update via set_code_hash
Ink! allows updating contract code via self.env().set_code_hash(&new_code_hash). But new code's storage layout must be compatible with old. Changing field order in #[ink(storage)] — data in storage read incorrectly. In EVM proxy pattern this called storage collision — same problem here, but without bytecode-level verification tools.
Use ink_storage_traits::StorageLayout derive macros and manually check storage layout via cargo-contract info --output-json before each upgrade.
How We Build Ink! Projects
Stack and Tools
| Tool | Role |
|---|---|
cargo-contract 4.x |
Compilation, deployment, calls |
substrate-contracts-node |
Local dev node |
drink! |
Unit testing without node (mock runtime) |
openbrush |
Standard library (PSP22, PSP34) |
| Subsquid | Event indexing |
polkadot.js API |
Frontend integration |
Test on three levels:
-
Unit tests via
#[ink::test]— synchronous, mock environment, fast -
Integration tests via
drink!— real Substrate runtime without network, can test cross-contract calls -
E2E tests on
substrate-contracts-node— full stack with real transactions
Token Standards in Polkadot Ecosystem
PSP22 — ERC-20 analog. PSP34 — ERC-721 analog. Both implemented via openbrush, providing trait-based extension system — like Solidity mixins via diamond proxy, but without selector clashing issues because Ink! uses blake2b hashes for message dispatch.
One subtlety: PSP22::transfer takes data: Vec<u8> — hook for receiver (ERC-777-like hook). If receiving contract implements PSP22Receiver, it can react to incoming transfer. If not — call still succeeds. Different from ERC-777 where tokensReceived mandatory for contract addresses.
Process
Analysis. Study target Substrate chain: which pallet-contracts version, any custom chain extensions, native token, need XCM integration for cross-chain calls.
Design. Define storage layout (can't change after deployment without migration), events for indexing, message interface. At this stage establish upgrade capability via set_code_hash — if needed.
Development. Write contract with drink! tests. 90%+ coverage. Test cross-contract interaction separately on substrate-contracts-node.
Audit and deployment. Static analysis via cargo clippy + manual review of critical paths. Deploy on testnet (Rococo Contracts), verify via polkadot.js Apps.
Timeline Guidelines
Simple contract (PSP22 token, 1-2 custom messages): 3-5 days including tests. Medium complexity with cross-contract calls and upgrade: 1-2 weeks. Complex protocol with XCM integration and custom chain extensions: from 1 month.
Specific timeline depends on target chain — contract parachains like Astar or Shiden may have own pallet-contracts config peculiarities.







