This skill covers building, testing, and deploying smart contracts for the Polkadot Asset Hub using PolkaVM (RISC-V based virtual machine).
- Runtime: PolkaVM (RISC-V based, not EVM)
- Network: Paseo Asset Hub TestNet
- RPC URL:
https://testnet-passet-hub-eth-rpc.polkadot.io - Language: Rust (
#![no_std]) - ABI: Ethereum ABI (ethabi crate) for compatibility
project/
├── .cargo/config.toml # Build target configuration
├── rust-toolchain.toml # Nightly toolchain specification
├── riscv64emac-unknown-none-polkavm.json # Custom target spec
├── Cargo.toml # Rust dependencies
├── build.sh # Build script
├── src/
│ └── my_contract.rs # Contract source
├── deploy_contract.ts # Deployment script
├── test_contract.ts # Test script
├── deployment_paseo.json # Deployed addresses
├── .env # Private key (PRIVATE_KEY=0x...)
└── *.polkavm # Compiled contract bytecode
[build]
target = "riscv64emac-unknown-none-polkavm.json"
[unstable]
build-std = ["core", "alloc"]
build-std-features = ["panic_immediate_abort"][toolchain]
channel = "nightly-2024-11-19"
components = ["rust-src"]{
"arch": "riscv64",
"cpu": "generic-rv64",
"crt-objects-fallback": "false",
"data-layout": "e-m:e-p:64:64-i64:64-i128:128-n32:64-S128",
"eh-frame-header": false,
"emit-debug-gdb-scripts": false,
"features": "+e,+m,+a,+c",
"linker": "rust-lld",
"linker-flavor": "ld.lld",
"llvm-target": "riscv64",
"max-atomic-width": 64,
"panic-strategy": "abort",
"relocation-model": "pie",
"target-pointer-width": "64"
}[package]
name = "my-contract"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "my_contract"
path = "src/my_contract.rs"
[profile.release]
opt-level = "s"
lto = "fat"
codegen-units = 1
[dependencies]
polkavm-derive = { version = "0.25.0" }
simplealloc = { version = "0.0.1", git = "https://github.com/paritytech/polkavm.git", rev = "ff37df6bf75201ef9e5f535762d267d7990c96d2" }
ethabi = { version = "18.0", default-features = false }
[dependencies.uapi]
package = "pallet-revive-uapi"
git = "https://github.com/paritytech/polkadot-sdk.git"
rev = "b7b7f0c50f6ce8bad7a7a3a10139e53714740b4e"
default-features = false
features = ["unstable-hostfn"]{
"dependencies": {
"dotenv": "^16.4.5",
"ethers": "^6.16.0"
},
"devDependencies": {
"@types/node": "^20.14.10",
"ts-node": "^10.9.2",
"typescript": "^5.5.3"
}
}#![no_main]
#![no_std]
extern crate alloc;
use alloc::string::String;
use ethabi::{decode, encode, ParamType, Token};
use polkavm_derive::polkavm_export;
use simplealloc::SimpleAlloc;
use uapi::{HostFn, HostFnImpl as api, ReturnFlags};
#[global_allocator]
static ALLOCATOR: SimpleAlloc<{ 256 * 1024 }> = SimpleAlloc::new(); // 256KB heap
// Function selectors (first 4 bytes of keccak256 hash of function signature)
const SELECTOR_MY_FUNC: [u8; 4] = [0x12, 0x34, 0x56, 0x78];
fn dispatch(selector: [u8; 4], data: &[u8]) {
match selector {
SELECTOR_MY_FUNC => {
// Decode input parameters
let decoded = decode(&[ParamType::Uint(256), ParamType::String], data)
.expect("Failed to decode");
let num = if let Token::Uint(v) = &decoded[0] { v.low_u64() } else { 0 };
let text = if let Token::String(s) = &decoded[1] { s.as_str() } else { "" };
// Process and return result
let result = encode(&[Token::String(String::from("Hello!"))]);
api::return_value(ReturnFlags::empty(), &result);
}
_ => api::return_value(ReturnFlags::REVERT, b"UNKNOWN_FUNCTION"),
}
}
#[no_mangle]
#[polkavm_export]
pub extern "C" fn deploy() {
// Initialization logic (runs once at deployment)
}
#[no_mangle]
#[polkavm_export]
pub extern "C" fn call() {
let length = api::call_data_size() as usize;
if length < 4 {
api::return_value(ReturnFlags::REVERT, b"Invalid input");
}
let mut selector = [0u8; 4];
api::call_data_copy(&mut selector, 0);
let data_len = length.saturating_sub(4).min(1024);
let mut data = alloc::vec![0u8; data_len];
if data_len > 0 {
api::call_data_copy(&mut data, 4);
}
dispatch(selector, &data);
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::arch::asm!("unimp"); core::hint::unreachable_unchecked(); }
}Selector = first 4 bytes of keccak256(function signature):
const { keccak256, toUtf8Bytes } = require('ethers');
const sig = 'transfer(address,uint256)';
const hash = keccak256(toUtf8Bytes(sig));
const selector = hash.slice(0, 10); // 0xa9059cbb
// For Rust constant:
console.log(`[0x${selector.slice(2,4)}, 0x${selector.slice(4,6)}, 0x${selector.slice(6,8)}, 0x${selector.slice(8,10)}]`);
// Output: [0xa9, 0x05, 0x9c, 0xbb]Important: No spaces in signature, use canonical types (uint256 not uint).
cargo build --release --bin my_contract
polkatool link --strip --output my_contract.polkavm \
target/riscv64emac-unknown-none-polkavm/release/my_contract// deploy_contract.ts
import { ethers } from 'ethers';
import * as fs from 'fs';
import * as dotenv from 'dotenv';
dotenv.config();
const RPC_URL = 'https://testnet-passet-hub-eth-rpc.polkadot.io';
const PRIVATE_KEY = process.env.PRIVATE_KEY || '';
async function main() {
if (!PRIVATE_KEY) {
console.error('PRIVATE_KEY not found in .env');
process.exit(1);
}
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
console.log('Deployer:', wallet.address);
const balance = await provider.getBalance(wallet.address);
console.log('Balance:', ethers.formatEther(balance), 'PAS\n');
// Read compiled bytecode
const bytecode = fs.readFileSync('my_contract.polkavm');
console.log(`Contract size: ${bytecode.length} bytes`);
// Deploy with explicit gas limit (estimation often too low for complex contracts)
const tx = await wallet.sendTransaction({
data: '0x' + bytecode.toString('hex'),
gasLimit: 60000000, // Explicit limit - adjust based on contract size
});
console.log(`Tx: ${tx.hash}`);
const receipt = await tx.wait();
const contractAddress = receipt!.contractAddress!;
console.log(`Contract deployed: ${contractAddress}`);
// Save deployment info
const deployment = {
address: contractAddress,
timestamp: new Date().toISOString()
};
fs.writeFileSync('deployment_paseo.json', JSON.stringify(deployment, null, 2));
}
main().catch(console.error);use uapi::{HostFn, HostFnImpl as api, ReturnFlags, StorageFlags, CallFlags};
// Return data to caller
api::return_value(ReturnFlags::empty(), &encoded_data);
api::return_value(ReturnFlags::REVERT, b"Error message");
// Storage operations
api::set_storage(StorageFlags::empty(), &key_32bytes, &value);
api::get_storage(StorageFlags::empty(), &key_32bytes, &mut buffer);
// Call data
api::call_data_size(); // Get input size
api::call_data_copy(&mut buffer, offset); // Copy input data
// Cross-contract calls (use /4 to reserve gas for multiple calls)
api::call(
CallFlags::empty(),
&target_address_20bytes,
api::ref_time_left() / 4, // Gas limit - use /4 if making multiple calls
u64::MAX, // Deposit limit
&[u8::MAX; 32], // Value (max = don't transfer)
&[0u8; 32], // Input deposit
&call_data,
Some(&mut &mut output[..])
);
// Get caller/origin
api::caller(&mut buffer_20bytes);
api::origin(&mut buffer_20bytes);-
Build fails with "can't find crate": Run
rustup component add rust-src -
Contract too large: Enable LTO and size optimization in Cargo.toml
-
REVERT on call: Check selector matches and parameters are correctly encoded
-
Storage not persisting: Ensure StorageFlags are correct
-
Cross-contract call fails: Check target address, gas allocation, and that target is configured
-
Deployment fails/times out: Use explicit
gasLimit: 60000000for large contracts -
Binary search returns wrong index: Ensure lookup array is sorted by the search key
// Heap is fixed at compile time - prefer stack allocation
let mut buffer = [0u8; 1024]; // Stack - no heap overhead
// Avoid large stack arrays (causes stack overflow)
let huge = [0u8; 1_000_000]; // BAD - stack overflow!// Reentrancy: State changes BEFORE external calls (Checks-Effects-Interactions)
set_balance(sender, balance - amount); // Effect first
api::call(...)?; // Interaction last
// Integer overflow: Use checked math
let new_balance = balance.checked_add(amount).expect("Overflow");
// Access control: Verify caller
let mut caller = [0u8; 20];
api::caller(&mut caller);
if caller != owner { api::return_value(ReturnFlags::REVERT, b"UNAUTHORIZED"); }// Check gas before expensive operations
if api::ref_time_left() < MIN_GAS_REQUIRED {
api::return_value(ReturnFlags::REVERT, b"INSUFFICIENT_GAS");
}
// In loops: check periodically (not every iteration - checks cost gas)
if i % 10 == 0 && api::ref_time_left() < estimated_remaining_cost {
api::return_value(ReturnFlags::REVERT, b"GAS_LIMIT");
}// Emit debug events to trace execution
api::deposit_event(&[[0xDE; 32]], b"CHECKPOINT_1");
// Log values: pack label + value into event data
let mut data = [0u8; 12];
data[0..4].copy_from_slice(b"BAL:");
data[4..12].copy_from_slice(&balance.to_le_bytes());
api::deposit_event(&[[0xDE; 32]], &data);// Use prefixes to namespace keys and avoid collisions
key[0] = PREFIX_BALANCE; // 1 for balances, 2 for allowances, etc.
key[1..21].copy_from_slice(user);
// Transient storage: cleared after transaction (good for reentrancy guards)
api::set_storage(StorageFlags::TRANSIENT, &key, &value);
// Gotcha: Setting 32-byte zero value DELETES the key
api::set_storage(StorageFlags::empty(), &key, &[0u8; 32]); // Deletes!The ethabi crate handles Ethereum ABI encoding/decoding automatically:
use ethabi::{decode, encode, ParamType, Token};
// Decoding input parameters
let decoded = decode(
&[ParamType::Uint(256), ParamType::Address],
data
).expect("decode failed");
let amount = if let Token::Uint(v) = &decoded[0] { v.low_u64() } else { 0 };
let recipient = if let Token::Address(a) = &decoded[1] { a.0 } else { [0u8; 20] };
// Encoding output
let result = encode(&[
Token::Uint(balance.into()),
Token::String(status),
]);
api::return_value(ReturnFlags::empty(), &result);Note: Don't manually handle endianness or padding - ethabi does it for you.
ethabi's Token::Int uses two's complement. For signed integers:
fn i64_to_token(v: i64) -> Token {
use ethabi::ethereum_types::U256;
if v >= 0 {
Token::Int(U256::from(v as u64))
} else {
let abs = (-(v as i128)) as u128;
let twos = (!U256::from(abs)) + U256::from(1u64);
Token::Int(twos)
}
}
// Decoding: int64 comes as U256, interpret as signed
let decoded = decode(&[ParamType::Int(64)], data)?;
let value = if let Token::Int(v) = &decoded[0] { v.low_u64() as i64 } else { 0 };// Return tuple(string,uint256)
api::return_value(ReturnFlags::empty(), &encode(&[Token::Tuple(vec![
Token::String(name),
Token::Uint(value.into()),
])]));Split large data into separate files:
// src/my_contract.rs
mod my_data; // Declares module
use my_data::{DATA, LOOKUP}; // Imports from module
// src/my_data.rs (same directory, no mod.rs needed)
pub static DATA: &[&str] = &["item1", "item2", ...];
pub static LOOKUP: &[(&str, u16)] = &[("item1", 0), ("item2", 1), ...];For large string tables that need both index→value and value→index lookups:
// Primary array: sorted by your preferred order (frequency, priority, etc.)
pub static DATA: &[&str] = &["apple", "banana", "cherry", ...];
// Lookup array: alphabetically sorted tuples (value, original_index)
// Enables O(log n) binary search for value→index queries
pub static DATA_LOOKUP: &[(&str, u16)] = &[
("apple", 0),
("banana", 1),
("cherry", 2),
// ... sorted alphabetically by first element
];
// Usage: O(1) index→value
let value = DATA[idx];
// Usage: O(log n) value→index via binary search
let idx = match DATA_LOOKUP.binary_search_by_key(&search_term, |(v, _)| *v) {
Ok(i) => DATA_LOOKUP[i].1 as i32,
Err(_) => -1, // Not found
};Store contract addresses with sequential keys:
// Define storage keys (simple incrementing values)
const STORAGE_ADDR0: [u8; 32] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
const STORAGE_ADDR1: [u8; 32] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
const STORAGE_ADDR2: [u8; 32] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2];
fn get_address(key: &[u8; 32]) -> [u8; 20] {
let mut buffer = [0u8; 20];
let _ = api::get_storage(StorageFlags::empty(), key, &mut &mut buffer[..]);
buffer
}
fn set_address(key: &[u8; 32], addr: &[u8; 20]) {
api::set_storage(StorageFlags::empty(), key, addr);
}
// In setAddresses handler:
if let Token::Address(addr) = &decoded[i] {
let mut addr_bytes = [0u8; 20];
addr_bytes.copy_from_slice(addr.as_bytes());
set_address(&STORAGE_ADDR0, &addr_bytes);
}// READ (free, no tx): Use provider.call()
const result = await provider.call({ to: contractAddr, data: callData });
// WRITE (costs gas): Use wallet.sendTransaction()
const tx = await wallet.sendTransaction({ to: contractAddr, data: callData });
const receipt = await tx.wait();const abiCoder = ethers.AbiCoder.defaultAbiCoder();
// Simple types
const data = selector + abiCoder.encode(['uint256', 'address'], [amount, addr]).slice(2);
// Signed integers
const data = selector + abiCoder.encode(['int64', 'int64'], [value1, value2]).slice(2);// Single return value
const decoded = abiCoder.decode(['string'], result);
const value = decoded[0];
// Tuple return: tuple(string,uint256)
const decoded = abiCoder.decode(['tuple(string,uint256)'], result);
const [name, value] = decoded[0]; // Destructure tuple
// Multiple return values
const decoded = abiCoder.decode(['bool', 'uint256'], result);
const [success, count] = decoded;Contracts often need configuration after deployment (e.g., setting dependencies):
const configSelector = '0x12345678'; // setDependencies(address,address)
const configData = configSelector + abiCoder.encode(
['address', 'address'],
[contract1, contract2]
).slice(2);
await wallet.sendTransaction({ to: mainContract, data: configData });- Don't ignore call results: Always check
api::call()return values - Don't use
unwrap()in production: Useexpect()with message or proper error handling - Don't forward all gas: Reserve some for post-call operations
- Don't trust external input sizes: Always validate before copying
- Don't assume storage exists: Handle missing keys gracefully
- Don't check gas in tight loops: The check itself costs gas
- Don't use ALLOW_REENTRY flag: Unless you specifically need reentrancy