BLOG

Write Elegant Smart Contracts with Sigil

NEW
GENERAL
DEV
Sigil proves that Bitcoin smart contracts can be both powerful and readable. By combining WebAssembly with strong type safety, built-in checks, and human-friendly interfaces, developers get clarity without compromising on security.

Clean code isn't just aesthetically important—it's critical for security, auditability, and maintainability. When smart contracts handle real value, readability becomes a security feature.

In a recent post proposing to replace the EVM (again!) with RISC-V, Vitalik Buterin compared smart contract implementations across platforms. But he would keep Solidity and Vyper, even after throwing away the EVM—he compared the readability of smart contracts written in Solana, Nervos CKB, Solidity, and Vyper to show that smart contracts written in Rust—a general-purpose language—are significantly less readable than their Solidity and Vyper counterparts.

He's right. Here are the examples he uses, which implement a basic Unisocks-style NFT bonding curve contract:

Solana

use anchor_lang::prelude::*;

declare_id!("SocksSale111111111111111111111111111111111111");

#[program]
pub mod unisocks_sale {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>, m: u64) -> Result<()> {
        let state = &mut ctx.accounts.state;
        state.sold = 0;
        state.m = m;
        Ok(())
    }

    pub fn buy_sock(ctx: Context<BuySock>) -> Result<()> {
        let state = &mut ctx.accounts.state;
        require!(state.sold < 500, SocksError::SoldOut);

        let n = state.sold;
        let m = state.m;

        let price = calculate_price(m, n)?;
        let payer = &mut ctx.accounts.buyer;
        let treasury = &mut ctx.accounts.treasury;

        // Ensure payment is correct
        require!(payer.lamports() >= price, SocksError::InsufficientPayment);

        // Transfer lamports to treasury
        **payer.try_borrow_mut_lamports()? -= price;
        **treasury.try_borrow_mut_lamports()? += price;

        // Increment sold count
        state.sold += 1;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(init, payer = payer, space = 8 + 8 + 8)]
    pub state: Account<'info, SaleState>,
    #[account(mut)]
    pub payer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct BuySock<'info> {
    #[account(mut)]
    pub state: Account<'info, SaleState>,
    #[account(mut)]
    pub buyer: Signer<'info>,
    /// CHECK: safe because this is a system account for treasury
    #[account(mut)]
    pub treasury: AccountInfo<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct SaleState {
    pub sold: u64,  // number of socks sold (n)
    pub m: u64,     // bonding curve parameter M
}

#[error_code]
pub enum SocksError {
    #[msg("All 500 socks have been sold.")]
    SoldOut,
    #[msg("Not enough payment for the current sock price.")]
    InsufficientPayment,
}

fn calculate_price(m: u64, n: u64) -> Result<u64> {
    require!(n < 500, SocksError::SoldOut);

    let a = m.checked_div(500 - n).ok_or(SocksError::SoldOut)?;
    let b = m.checked_div(501 - n).ok_or(SocksError::SoldOut)?;
    let price = a.checked_sub(b).ok_or(SocksError::SoldOut)?;
    Ok(price)
}

What's wrong here? Solana requires verbose account definitions with #[derive(Accounts)] structs, manual lamport manipulation ( try_borrow_mut_lamports()? ), and extensive boilerplate for what should be simple state management. The business logic is buried under infrastructure code.

Nervos CKB

// src/entry.rs
use ckb_std::{
    ckb_constants::Source,
    default_alloc,
    entry,
    high_level::{load_cell_capacity, load_cell_data, load_script},
    error::SysError,
};
use core::result::Result;

entry!(entry);
default_alloc!();

const MAX_SOCKS: u64 = 500;

#[derive(Debug)]
pub enum Error {
    InvalidCapacity,
    TooManySold,
    InvalidTransaction,
}

fn parse_u64(data: &[u8]) -> u64 {
    let mut arr = [0u8; 8];
    arr.copy_from_slice(&data[..8]);
    u64::from_le_bytes(arr)
}

fn calc_price(m: u64, n: u64) -> u64 {
    // price_n = M/(500-n) - M/(501-n)
    // with integer math:
    // price_n = M * (501 - n - (500 - n)) / ((500 - n)(501 - n)) = M / ((500 - n)(501 - n))
    m / ((500 - n) * (501 - n))
}

pub fn entry() -> i8 {
    match main() {
        Ok(_) => 0,
        Err(_) => -1,
    }
}

fn main() -> Result<(), Error> {
    // Assume the first input cell is the state cell storing: [M: u64][sold: u64]
    let data = load_cell_data(0, Source::Input).map_err(|_| Error::InvalidTransaction)?;
    let m = parse_u64(&data[0..8]);
    let sold = parse_u64(&data[8..16]);

    if sold >= MAX_SOCKS {
        return Err(Error::TooManySold);
    }

    let expected_price = calc_price(m, sold);

    // Assume the new cell (output 0) will store [M][sold+1]
    let out_data = load_cell_data(0, Source::Output).map_err(|_| Error::InvalidTransaction)?;
    let new_sold = parse_u64(&out_data[8..16]);
    if new_sold != sold + 1 {
        return Err(Error::InvalidTransaction);
    }

    // Check payment (simplified: assume cell 1 is the payment cell)
    let input_capacity = load_cell_capacity(1, Source::Input).map_err(|_| Error::InvalidCapacity)?;
    let output_capacity = load_cell_capacity(1, Source::Output).map_err(|_| Error::InvalidCapacity)?;

    let paid = input_capacity - output_capacity;
    if paid != expected_price {
        return Err(Error::InvalidCapacity);
    }

    Ok(())
}

What's wrong here? Nervos CKB operates at the UTXO cell level, forcing manual byte parsing (parse_u64), low-level capacity checks, and cryptic cell manipulation. There's no clear distinction between business logic and blockchain primitives.

Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract UniSocks is ERC721Enumerable, Ownable {
    uint256 public constant MAX_SUPPLY = 500;
    uint256 public immutable M; // Pricing parameter

    address payable public treasury;

    constructor(uint256 _M, address payable _treasury) ERC721("UniSocks", "USOCK") {
        require(_M > 0, "M must be positive");
        require(_treasury != address(0), "Invalid treasury");
        M = _M;
        treasury = _treasury;
    }

    function currentPrice() public view returns (uint256) {
        uint256 n = totalSupply(); // number of socks already sold
        require(n < MAX_SUPPLY, "Sold out");
        // price = M / (500 - n) - M / (501 - n)
        // rewritten to avoid precision loss:
        uint256 denom1 = MAX_SUPPLY - n;
        uint256 denom2 = MAX_SUPPLY + 1 - n;
        return (M * (denom2 - denom1)) / (denom1 * denom2);
    }

    function buy() external payable {
        uint256 n = totalSupply();
        require(n < MAX_SUPPLY, "All socks sold");

        uint256 price = currentPrice();
        require(msg.value >= price, "Insufficient payment");

        // Mint sock
        _safeMint(msg.sender, n);

        // Forward funds to treasury
        (bool sent, ) = treasury.call{value: price}("");
        require(sent, "Transfer failed");

        // Refund any overpayment
        if (msg.value > price) {
            payable(msg.sender).transfer(msg.value - price);
        }
    }
}

Vyper

# @version ^0.3.10

from vyper.interfaces import ERC721

event SockPurchased:
    buyer: indexed(address)
    token_id: uint256
    price_paid: uint256

MAX_SUPPLY: constant(uint256) = 500

owner: public(address)
m_parameter: public(uint256)
total_sold: public(uint256)

owners: HashMap[uint256, address]

@external
def __init__(_m: uint256):
    self.owner = msg.sender
    self.m_parameter = _m
    self.total_sold = 0

@internal
@view
def _price(n: uint256) -> uint256:
    """
    Calculate price of the n-th sock:
    price = M / (500 - n) - M / (501 - n)
    """
    M: uint256 = self.m_parameter
    a: uint256 = 500 - n
    b: uint256 = 501 - n

    # Use fixed-point math with 10^18 precision
    M_fixed: uint256 = M * 10**18
    term1: uint256 = M_fixed / a
    term2: uint256 = M_fixed / b

    return term1 - term2

@external
@payable
def buy():
    n: uint256 = self.total_sold
    assert n < MAX_SUPPLY, "All socks sold"

    price: uint256 = self._price(n)

    assert msg.value >= price, "Insufficient payment"

    token_id: uint256 = n
    self.owners[token_id] = msg.sender
    self.total_sold += 1

    log SockPurchased(msg.sender, token_id, price)

    # Refund excess
    excess: uint256 = msg.value - price
    if excess > 0:
        send(msg.sender, excess)

@external
@view
def ownerOf(token_id: uint256) -> address:
    assert token_id < self.total_sold, "Token does not exist"
    return self.owners[token_id]

@external
@view
def currentPrice() -> uint256:
    return self._price(self.total_sold)

Kontor Smart Contracts

What are Kontor and Sigil?

Kontor is a metaprotocol that enables rich smart contract applications on Bitcoin. Sigil is Kontor's WebAssembly-based smart contract framework. Sigil is designed to support any language that compiles to WebAssembly, making it a natively polyglot platform for writing smart contracts in a way that makes it feel like writing regular software. Sigil initially supports the Rust programming language, but other languages that work with WebAssembly will be added over time.

One of the biggest differences between Sigil and other WASM-based smart contract frameworks (such as Alkanes, CosmWasm, Polygon, etc.) is that Sigil leverages the new WebAssembly Component Model. This allows contracts to link to the runtime with full type-safety, and it provides deep integration with conventional Rust tooling. It shifts errors to build-time, keeps determinism and gas behavior predictable, and reduces the trusted computing base. Sigil thus dramatically improves developer ergonomics while fully supporting a flexible and extensible ecosystem built on open standards. With Kontor, contracts aren't opaque blobs—they're typed, composable components with explicit interfaces.

Sigil Contract

use stdlib::*;

contract!(name = "unisocks");

const MAX_SUPPLY: u64 = 500;

#[derive(Clone, Default, StorageRoot)]
struct SocksStorage {
    pub m: Integer,
    pub sold: Integer,
    pub owners: Map<Integer, Address>,
}

impl Guest for Unisocks {
    fn init(ctx: &ProcContext, m: Integer) {
        SocksStorage {
            m,
            ..Default::default()
        }
        .init(ctx);
    }

    fn buy(ctx: &ProcContext) -> Result<(), Error> {
        let model = ctx.model();
        let sold = model.sold();
        
        ensure_available(sold)?;
        
        model.owners().set(sold, ctx.signer());
        model.set_sold(sold.add(1.into())?);
        
        Ok(())
    }

    fn current_price(ctx: &ViewContext) -> Result<Integer, Error> {
        calculate_price(ctx.model().m(), ctx.model().sold())
    }

    fn owner_of(ctx: &ViewContext, token_id: Integer) -> Option<Address> {
        ctx.model().owners().get(token_id)
    }

    fn total_sold(ctx: &ViewContext) -> Integer {
        ctx.model().sold()
    }
}

fn ensure_available(sold: Integer) -> Result<(), Error> {
    if sold >= MAX_SUPPLY.into() {
        Err(Error::Message("sold out".to_string()))
    } else {
        Ok(())
    }
}

fn calculate_price(m: Integer, n: Integer) -> Result<Integer, Error> {
    ensure_available(n)?;
    
    let remaining = Integer::from(MAX_SUPPLY).sub(n)?;
    let next_remaining = remaining.sub(1.into())?;
    
    m.div(remaining)?.sub(m.div(next_remaining)?)
}

Sigil WIT

package root:component;

world root {
  include kontor:built-in/built-in;
  use kontor:built-in/context.{view-context, proc-context};
  use kontor:built-in/error.{error};
  use kontor:built-in/numbers.{integer};
  use kontor:built-in/types.{address};

  export init: func(ctx: borrow<proc-context>, m: integer);
  export buy: func(ctx: borrow<proc-context>) -> result<_, error>;
  export current-price: func(ctx: borrow<view-context>) -> result<integer, error>;
  export owner-of: func(ctx: borrow<view-context>, token-id: integer) -> option<address>;
  export total-sold: func(ctx: borrow<view-context>) -> integer;
}

The above example shows a key innovation: Sigil uses WebAssembly Interface Types (WIT) to define explicit contract interfaces in a language-agnostic type system. Every Sigil contract includes a WIT file that, along with the Component Model, enables:

  • IDE Integration: Your editor understands the contract interface without runtime execution
  • Cross-Language Calls: Contracts written in different languages can call each other with full type safety
  • Tooling Support: Build tools, indexers, and frontends can parse contract capabilities directly
  • Runtime Validation: The Kontor runtime validates that calls match declared signatures

Kontor's Design

1. Clean Storage Abstraction

The Problem: Solana requires manual account validation and lamport manipulation. Nervos CKB forces byte-level cell management.

Kontor's Solution: Storage is just a Rust struct with a #[derive(StorageRoot)] macro:

#[derive(Clone, Default, StorageRoot)]
struct SocksStorage {
    pub m: Integer,
    pub sold: Integer,
    pub owners: Map<Integer, Address>,
}

Behind the scenes, the macro generates:

  • Type-safe getters and setters for each field
  • Nested field access without deserializing entire structures
  • Isolated storage per contract (no delegatecall footguns)

You write simple Rust. The framework handles the blockchain complexity.

2. Type-Safe Data Access

Instead of raw storage operations, Kontor provides a structured interface:

let model = ctx.model();
let balance = model.ledger().get(&address).unwrap_or_default();
model.ledger().set(address, new_balance);

This approach keeps storage operations simple and predictable:

  • Simple getter and setter storage methods: ctx.model().foo().set_baz(new_baz)
  • Predictable gas: No hidden complexity, just simple get/set operations
  • Type preservation: Your Integer type stays an Integer, not raw bytes

3. Userspace Abstraction over Runtime Primitives

Kontor's runtime only provides a minimal set of primitives:

  • Storage get/set operations
  • Signer access (in proc contexts)
  • Cross-contract calls
  • Path/key existence checks

Everything else—the Map type, checked arithmetic, the storage macros—is built in userspace libraries, not the core runtime. While our first-class support is for Rust, these could be written in any supported language. This means:

  • Standard testing: Write unit tests without the runtime using trait mocks
  • Composable libraries: Use standard package ecosystems (like Rust's crates.io) for dependencies.
  • No runtime bloat: The VM stays simple and auditable

4. Safety by Default

Kontor eliminates entire classes of vulnerabilities:

No storage collisions: Each contract's storage is isolated. There's no equivalent to Solidity's delegatecall that can corrupt storage layouts.

Overflow protection: Built-in checked_add, checked_div, etc. make safe math the default, not an afterthought.

Explicit visibility: Unlike Ethereum, storage isn't automatically readable. You must write view functions to expose data, enabling controlled access and future-proof upgrades.

5. Error Handling That Makes Sense

Kontor uses Rust's Result type:

#[proc]
fn buy() -> Result<(), Error> {
    let price = calculate_price(m, sold)?;
    model.sold().set(sold.checked_add(1)?);
    Ok(())
}

When a function returns Err:

  • That contract's storage changes roll back
  • The calling contract can handle the error any of Rust’s suite tools for handling Result
  • Only the failed contract's state reverts (fine-grained control)

When a function throws a panic, there’s a full transaction rollback across all contracts.

This is dramatically clearer than Solidity's require/revert/assert confusion.

The Bottom Line

Sigil demonstrates that smart contracts written for WebAssembly don't have to sacrifice readability for safety.

  • Clean storage abstractions — no manual byte parsing or account validation boilerplate
  • Type-safe state accessctx.model() provides fully typed storage with automatic serialization
  • Built-in safety — checked arithmetic and isolated storage eliminate entire vulnerability classes
  • Clear separation — business logic stays separate from blockchain primitives
  • Explicit interfaces — WIT definitions create human-readable, machine-verifiable contracts

The examples from other platforms aren't indictments of Rust itself, but of frameworks that expose too much low-level complexity. By embracing better abstractions, we can build systems that are not just safer, but also simpler to write, read, and audit. The result is code that is clear, concise, and correct by construction, proving that the language of choice need not be a compromise between security and developer experience.

Continue Reading
Text Link
GENERAL

Announcing Kontor