Smart contracts have long been the primary method for developing applications in Web3.
However, the underlying technology hasn't changed much over the years. The EVM (Ethereum Virtual Machine) has long been the default option for smart contract development. It served its purpose well, bootstrapping an entire industry of decentralized applications.
Today, it is a massive ecosystem that has become the de facto standard for interacting with Web3 infrastructure.
Here’s the catch, though:
The EVM is an emulation layer, not an actual machine.
Compared to the rest of the software world, it is a virtual machine designed for a specific network, which comes with limitations. Although it is general-purpose in a sense, it does not give developers the full capabilities of a typical tech stack or the standard, high-performance tooling that powers the rest of the internet. If we want to create a new standard for Web3 software, it needs to be on par with (or ideally, better than) the status quo.
This isolation is what holds back the next generation of complex, high-compute applications. We should be able to create and deploy applications the same way we do in the ‘conventional‘ sense on Web3.
The Current State of Smart Contracts
Smart contracts today are rather simplistic in terms of developer experience and execution. The developer writes high-level code, compiles it to bytecode, and the EVM executes that bytecode instruction-by-instruction. While effective, it is slow compared to native execution.
By relying on a custom virtual machine, we lose access to the decades of optimization that have gone into standard hardware architectures like RISC-V.
This is where PolkaVM comes in.
PolkaVM is a RISC-V-based virtual machine that allows developers to write smart contracts in Rust, Go, and other languages that can be compiled by LLVM, just like traditional software. It represents a shift to "bare metal" execution.
It still executes deterministic logic and maintains persistent state, but it grants developers flexibility beyond the typical bounds of a custom, virtual machine sandbox.
The Bigger Picture: Polkadot & Revive
Taking a step back, it is important to note that Polkadot supports Solidity through Revive (pallet-revive). Revive is a compiler pipeline that targets PolkaVM (RISC-V) while keeping Ethereum tooling compatibility as a first-class goal.
Think of it as a translation layer. On the node side, you can run an Ethereum JSON-RPC adapter (often referred to as an eth-rpc adapter) so tools like MetaMask, Hardhat, or Foundry can speak "normal Ethereum JSON-RPC" while the node translates those calls into the underlying Polkadot runtime and contract environment.
When you send a transaction, the adapter and runtime plumbing route it into the PolkaVM execution engine, taking advantage of the performance and security properties of the underlying RISC-V VM.
If you want a reference point on Ethereum RPC support on Polkadot Hub, see:
- Polkadot Hub RPC node guide (Ethereum RPC adapter)
- Smart contract functionality / Ethereum tooling compatibility
You can also see how smart contracts on Polkadot Hub generally work:
The "Bare Metal" Environment
Solidity is not our only option. As mentioned previously, we can write contracts in any language that compiles to RISC-V, such as Rust.
Writing for PolkaVM is similar to writing for an embedded environment. You are operating on "bare metal," without an operating system (OS).
This is a no_std environment. The Rust standard library (std) assumes OS services for things like threads, files, and some allocation plumbing. Since we don't have an OS, we cannot use std in the usual way.
However, you can still use core (always available) and alloc if you provide a global allocator.
Memory: Stack vs. Heap
To understand why no_std is challenging, we need to look at how computers manage memory:
The Stack
- The Mechanism: Linear memory, Last-In/First-Out (LIFO). It’s fast because “allocating” is often just moving a pointer.
- The Limitation: The compiler needs to know sizes up-front. This makes truly dynamic structures harder.
The Heap
-
The Mechanism: Dynamic memory for data structures that can grow/shrink at runtime (like
VecandString). - The Cost: It needs an allocator to manage free space and fragmentation.
In a standard environment, the allocator is provided for you. In a no_std environment, you start without one.
This doesn’t mean "no heap is possible" - it means you don’t get heap allocation for free. If you want dynamic data, you provide an allocator.
Entering the Allocator
What if we could have an allocator for the heap, even in a no_std environment?
We can. By adding a memory allocator like picoalloc, we can manage a fixed chunk of memory (typically a statically reserved region) as a heap.
// We designate a fixed region of memory for the allocator to manage.
#[global_allocator]
static mut ALLOC: picoalloc::Mutex<...> = ...;
This unlocks the standard alloc crate, allowing you to use useful types like Vec<T> even in a no_std environment.
Host Functions & ABI
If the contract runs in an isolated environment, how does it interact with the blockchain?
It uses a host interface (in Revive’s stack this is commonly accessed via the pallet-revive-uapi crate). When the contract needs to store data, transfer tokens, emit events, or inspect chain context, it calls host functions.
Here are representative host functions you’ll see in pallet-revive-uapi (names match the HostFn surface):
| Host Function | Purpose |
|---|---|
set_storage / get_storage
|
Write/read contract storage (key/value). |
call / instantiate
|
Call another contract / deploy a new contract instance. |
deposit_event |
Emit an event (topics + data). |
caller / origin
|
Get the caller and origin addresses. |
call_data_size / call_data_copy
|
Read transaction input (calldata). |
return_value |
Return data (and optionally revert) and stop execution. |
balance / balance_of
|
Query balances. |
Reference:
Data Persistence (Storage)
If you’re writing your PVM contract in Rust, storage is fairly conventional. It is a raw key-value store:
- Keys: Fixed-size byte arrays (commonly 32 bytes).
- Values: Variable-length byte arrays.
For example:
const VALUE_KEY: [u8; 32] = [0u8; 32];
This would then be stored using a host function like set_storage:
api::set_storage(
StorageFlags::empty(),
&VALUE_KEY,
&value_bytes
);
The ABI (Application Binary Interface)
The ABI defines how functions are identified within the contract. In Ethereum-style ABIs, a 4-byte selector targets specific functions.
We identify functions using selectors:
- Take the text signature:
"flip()" - Hash it (Keccak-256)
- Take the first 4 bytes
This 4-byte ID acts as the “key” to select a function entry point.
Our Rust contracts can use a Solidity interface as their ABI. This ensures Ethereum tooling compatibility: tools like Foundry’s cast can talk to your contract through an Ethereum JSON-RPC endpoint.
This also means other contracts (Rust or Solidity) can call into your contract on-chain using the same ABI expectations.
Creating an Ethereum ABI for a PVM Contract
To define an Ethereum-compatible ABI, you have two options:
- Create an external Solidity file that defines your contract’s ABI.
- Use the
sol!macro in alloy.
Let’s look at how this works using sol! with an external file. Suppose our Solidity interface contains a public function called flip(). We import the contract as follows:
sol!("Flipper.sol");
The macro expands this at compile-time into Rust types with selectors:
// Generated code (simplified)
struct flipCall {}
impl SolCall for flipCall {
// 4-byte selector for flip()
const SELECTOR: [u8; 4] = keccak256("flip()")[0..4];
}
This gives us:
- Type safety: Decode into Rust types rather than hand-parsing bytes.
- Standard selectors: Same selector scheme as the EVM ABI.
We can then reference the selector like so: Flipper::flipCall::SELECTOR in our dispatch loop. Once we determine which function the user is trying to call, we invoke the corresponding Rust function to do the work.
The Build Pipeline
The PVM build process consists of two stages:
-
Rust -> ELF: The compiler generates a standard ELF binary (
riscv64emac-unknown-none-polkavm). -
Refinement (Polkatool): A specialized linker performs "tree shaking" (removing dead code) and optimization to produce the final
.polkavmfile.
This .polkavm file is the artifact deployed on-chain.
Building and Deploying a Flipper Contract in Rust
Enough talk: let's build a flipper contract and deploy it on the Polkadot Testnet. This contract will store a single boolean value and allow anyone to flip it between true and false.
Prerequisites
Before jumping into the code, we need to ensure your environment is set up with the necessary tools, a wallet, and testnet funds.
1) Install Developer Tools
You will need Git, Rust, and Foundry installed on your machine.
Rust:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Foundry:
curl -L https://foundry.paradigm.xyz | bash
foundryup
Git: If you don't have Git installed, download it from:
2) Generate a Wallet
We need a private key to sign transactions. We can use Foundry's cast tool to generate one:
cast wallet new
Next, save the wallet so we can use it for signing later:
cast wallet import pvm-account --private-key <your-private-key-from-cast-wallet>
3) Get Testnet Funds
We are deploying to the Polkadot testnet (Paseo / Asset Hub), so we’ll use a faucet:
- Go to the Polkadot Faucet.
- Select "Polkadot testnet (Paseo)" from the network list.
- Ensure the chain is on "Assethub".
- Enter the address you generated above.
- Click "Get Funds" (this may take a few minutes).
Scaffold
To get started, clone a prepared template:
git clone -b template https://github.com/CrackTheCode016/flipper-pvm flipper
cd flipper
Note: There is a CLI tool under development that can automate project creation and scaffolding (see: cargo-pvm-contract). For this tutorial, we work manually from the template above.
The template is already configured with the files we need:
-
src/flipper.rsfor logic -
Flipper.solfor the ABI interface
Implement Flipper (src/flipper.rs)
Open src/flipper.rs. The template provides scaffolding (allocator via picoalloc, panic handler, and the Solidity interface definition), but the core logic is missing.
We need to fill in the todo!() blocks for storage, event emission, and the dispatch loop.
The Logic & Storage
We’ll store our flipper state in VALUE_KEY. Locate get_value and set_value.
Inside fn get_value(), replace todo!() with:
let mut value_bytes = vec![0u8; 32];
let mut output = value_bytes.as_mut_slice();
match api::get_storage(StorageFlags::empty(), &VALUE_KEY, &mut output) {
Ok(_) => {
// Check if the last byte is non-zero (Solidity stores bool as uint8)
output[31] != 0
}
Err(_) => false, // Default to false if not set
}
Inside fn set_value(value: bool), replace todo!() with:
let mut value_bytes = [0u8; 32];
value_bytes[31] = if value { 1 } else { 0 };
api::set_storage(StorageFlags::empty(), &VALUE_KEY, &value_bytes);
The Event
Find emit_flipped. Replace todo!() with:
let _event = Flipper::Flipped { new_value };
// The signature hash is the first topic (the event ID)
let topics = [Flipper::Flipped::SIGNATURE_HASH.0];
// We manually encode the data (bool as u256/32 bytes)
let mut data = [0u8; 32];
data[31] = if new_value { 1 } else { 0 };
api::deposit_event(&topics, &data);
The Entry Point
Implement the call() function (the main entry point). Replace todo!() with:
// 1. INPUT HANDLING
let call_data_len = api::call_data_size();
let mut call_data = vec![0u8; call_data_len as usize];
api::call_data_copy(&mut call_data, 0);
// 2. SELECTOR EXTRACTION
// We expect at least 4 bytes for the function selector
if call_data.len() < 4 {
api::return_value(ReturnFlags::REVERT, b"Input too short");
}
let selector: [u8; 4] = call_data[0..4].try_into().unwrap();
// 3. DISPATCHING
match selector {
// Handle flip() function call
Flipper::flipCall::SELECTOR => {
let current = get_value();
let new_value = !current;
set_value(new_value);
emit_flipped(new_value);
}
// Handle get() function call
Flipper::getCall::SELECTOR => {
let current = get_value();
// 4. RETURN ENCODING
let mut return_data = [0u8; 32];
return_data[31] = if current { 1 } else { 0 };
api::return_value(ReturnFlags::empty(), &return_data);
}
_ => {
// Unknown function selector - revert
api::return_value(ReturnFlags::REVERT, b"Unknown function");
}
}
Build
Generate the artifacts for on-chain upload:
cargo build
If successful, you should see flipper.debug.polkavm within the target directory.
Deploy & Interact
Set your RPC endpoint:
export ETH_RPC_URL="https://services.polkadothub-rpc.com/testnet"
Deploy:
cast send --account pvm-account --create "$(xxd -p -c 99999 target/flipper.debug.polkavm)"
If successful, you should see output similar to:
blockHash 0xf215391078dd412eb90095e5c5ad4454a761299ef51ce9ad44e0dc5178f957ee
blockNumber 2
contractAddress <YOUR_CONTRACT_ADDRESS>
cumulativeGasUsed 0
effectiveGasPrice 50000000000
from 0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac
gasUsed 295239...
status 1 (success)...
to 0xc01Ee7f10EA4aF4673cFff62710E1D7792aBa8f3
Copy the contractAddress and set:
export RUST_ADDRESS=<YOUR_CONTRACT_ADDRESS>
Interaction
# Check current value (should be false)
cast call $RUST_ADDRESS "get() returns (bool)"
# Flip it!
cast send --account pvm-account $RUST_ADDRESS "flip()"
# Check again (should be true)
cast call $RUST_ADDRESS "get() returns (bool)"
Explorer example:
https://polkadot.testnet.routescan.io/address/0x2044DB81C13954e157Cb9F1E006006a70b7CEA89
Conclusion
PolkaVM demonstrates a future where web3 applications are not mini-apps, but full-scale programs running on real technology stacks. Building on Web3 should not be having to compromise on design due to technological limitations; rather, it should expand and broaden our horizons while providing a more resilient and robust future for computing.
Working at this low level of smart contract development opens new avenues for computationally expensive applications, where every byte counts. Other contracts on-chain, whether written in Rust or Solidity, can call these optimized contracts, leveraging their lower costs and more practical execution models.
Top comments (0)