Skip to content

Instantly share code, notes, and snippets.

@lovelaced
Last active January 18, 2026 16:57
Show Gist options
  • Select an option

  • Save lovelaced/7ef2cc5d0ee50547eec06222fbf3d4d6 to your computer and use it in GitHub Desktop.

Select an option

Save lovelaced/7ef2cc5d0ee50547eec06222fbf3d4d6 to your computer and use it in GitHub Desktop.
.claude/skills for polkavm rust contracts

PolkaVM Smart Contract Development

This skill covers building, testing, and deploying smart contracts for the Polkadot Asset Hub using PolkaVM (RISC-V based virtual machine).

Environment Overview

  • 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 Structure

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

Required Configuration Files

.cargo/config.toml

[build]
target = "riscv64emac-unknown-none-polkavm.json"

[unstable]
build-std = ["core", "alloc"]
build-std-features = ["panic_immediate_abort"]

rust-toolchain.toml

[toolchain]
channel = "nightly-2024-11-19"
components = ["rust-src"]

riscv64emac-unknown-none-polkavm.json

{
  "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"
}

Cargo.toml Dependencies

[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"]

package.json for TypeScript scripts

{
  "dependencies": {
    "dotenv": "^16.4.5",
    "ethers": "^6.16.0"
  },
  "devDependencies": {
    "@types/node": "^20.14.10",
    "ts-node": "^10.9.2",
    "typescript": "^5.5.3"
  }
}

Contract Template

#![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(); }
}

Computing Function Selectors

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).

Build Commands

cargo build --release --bin my_contract
polkatool link --strip --output my_contract.polkavm \
    target/riscv64emac-unknown-none-polkavm/release/my_contract

Deployment Script Template

// 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);

Common Host Functions (uapi)

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);

Common Issues

  1. Build fails with "can't find crate": Run rustup component add rust-src

  2. Contract too large: Enable LTO and size optimization in Cargo.toml

  3. REVERT on call: Check selector matches and parameters are correctly encoded

  4. Storage not persisting: Ensure StorageFlags are correct

  5. Cross-contract call fails: Check target address, gas allocation, and that target is configured

  6. Deployment fails/times out: Use explicit gasLimit: 60000000 for large contracts

  7. Binary search returns wrong index: Ensure lookup array is sorted by the search key


Memory Management

// 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!

Security Considerations

// 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"); }

Gas Management

// 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");
}

Debugging with Events

// 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);

Storage Best Practices

// 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!

ABI Encoding with ethabi

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.

Signed Integers (int64, int256)

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 };

Returning Tuples

// Return tuple(string,uint256)
api::return_value(ReturnFlags::empty(), &encode(&[Token::Tuple(vec![
    Token::String(name),
    Token::Uint(value.into()),
])]));

Module Organization

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), ...];

Dual-Index Pattern for Lookup Tables

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
};

Address Storage Pattern

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);
}

TypeScript: Read vs Write

// 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();

Encoding Call Data

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);

Decoding Results

// 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;

Post-Deployment Configuration

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 });

Pitfalls to Avoid

  1. Don't ignore call results: Always check api::call() return values
  2. Don't use unwrap() in production: Use expect() with message or proper error handling
  3. Don't forward all gas: Reserve some for post-call operations
  4. Don't trust external input sizes: Always validate before copying
  5. Don't assume storage exists: Handle missing keys gracefully
  6. Don't check gas in tight loops: The check itself costs gas
  7. Don't use ALLOW_REENTRY flag: Unless you specifically need reentrancy
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment