synor/docs/tutorials/03-smart-contracts.md
Gulshan Yadav a6233f285d docs: add developer tutorial series
Create comprehensive step-by-step tutorials:
- Tutorial 1: Getting Started - node setup, wallet creation, transactions
- Tutorial 2: Building a Wallet - React app with state management, encryption
- Tutorial 3: Smart Contracts - Rust WASM contracts, token example, testing
- Tutorial 4: API Guide - JSON-RPC, WebSocket subscriptions, client building

Each tutorial includes working code examples and best practices.
2026-01-10 06:24:51 +05:30

11 KiB

Tutorial 3: Smart Contracts on Synor

Learn to build, deploy, and interact with smart contracts on Synor's WebAssembly-based contract system.

What You'll Learn

  • Write a contract in Rust
  • Compile to WebAssembly
  • Deploy to Synor
  • Call contract methods
  • Handle state and events

Prerequisites


Part 1: Understanding Synor Contracts

How Contracts Work

┌─────────────────────────────────────────────┐
│           Synor Smart Contract               │
├─────────────────────────────────────────────┤
│  Written in: Rust (compiled to WASM)        │
│  Execution: Deterministic sandbox           │
│  State: Key-value storage                   │
│  Gas: Metered execution                     │
│  Size limit: 256 KB compiled WASM           │
└─────────────────────────────────────────────┘

Contract Capabilities

Feature Support
State storage Key-value store
Cross-contract calls Via host functions
Events/Logs Emit events
Token transfers Native SYNOR
Cryptography Blake3, Ed25519
Time access Block timestamp
Randomness Not yet (determinism)

Part 2: Setting Up

Install the Contract SDK

# Add the WASM target
rustup target add wasm32-unknown-unknown

# Install the Synor contract CLI
cargo install synor-contract-cli

Create a New Contract Project

synor-contract new my_token
cd my_token

This creates:

my_token/
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    └── integration.rs

Part 3: Writing Your First Contract

A Simple Token Contract

Edit src/lib.rs:

//! A simple fungible token contract.

#![no_std]

use synor_contract_sdk::prelude::*;

// Contract state
#[derive(Default)]
struct Token {
    name: String,
    symbol: String,
    decimals: u8,
    total_supply: u128,
    balances: Map<Address, u128>,
    allowances: Map<(Address, Address), u128>,
}

// Contract implementation
#[synor_contract]
impl Token {
    /// Initialize the token contract
    #[init]
    pub fn new(name: String, symbol: String, decimals: u8, initial_supply: u128) -> Self {
        let caller = env::caller();

        let mut balances = Map::new();
        balances.insert(caller, initial_supply);

        emit!(TokenCreated {
            name: name.clone(),
            symbol: symbol.clone(),
            total_supply: initial_supply,
        });

        Token {
            name,
            symbol,
            decimals,
            total_supply: initial_supply,
            balances,
            allowances: Map::new(),
        }
    }

    /// Get token name
    #[view]
    pub fn name(&self) -> String {
        self.name.clone()
    }

    /// Get token symbol
    #[view]
    pub fn symbol(&self) -> String {
        self.symbol.clone()
    }

    /// Get decimals
    #[view]
    pub fn decimals(&self) -> u8 {
        self.decimals
    }

    /// Get total supply
    #[view]
    pub fn total_supply(&self) -> u128 {
        self.total_supply
    }

    /// Get balance of address
    #[view]
    pub fn balance_of(&self, owner: Address) -> u128 {
        self.balances.get(&owner).copied().unwrap_or(0)
    }

    /// Transfer tokens to another address
    #[mutate]
    pub fn transfer(&mut self, to: Address, amount: u128) -> bool {
        let from = env::caller();

        let from_balance = self.balance_of(from);
        require!(from_balance >= amount, "Insufficient balance");

        self.balances.insert(from, from_balance - amount);
        let to_balance = self.balance_of(to);
        self.balances.insert(to, to_balance + amount);

        emit!(Transfer { from, to, amount });

        true
    }

    /// Approve spender to transfer tokens
    #[mutate]
    pub fn approve(&mut self, spender: Address, amount: u128) -> bool {
        let owner = env::caller();
        self.allowances.insert((owner, spender), amount);

        emit!(Approval { owner, spender, amount });

        true
    }

    /// Get allowance
    #[view]
    pub fn allowance(&self, owner: Address, spender: Address) -> u128 {
        self.allowances.get(&(owner, spender)).copied().unwrap_or(0)
    }

    /// Transfer tokens on behalf of owner
    #[mutate]
    pub fn transfer_from(&mut self, from: Address, to: Address, amount: u128) -> bool {
        let spender = env::caller();

        let allowed = self.allowance(from, spender);
        require!(allowed >= amount, "Allowance exceeded");

        let from_balance = self.balance_of(from);
        require!(from_balance >= amount, "Insufficient balance");

        // Update balances
        self.balances.insert(from, from_balance - amount);
        let to_balance = self.balance_of(to);
        self.balances.insert(to, to_balance + amount);

        // Update allowance
        self.allowances.insert((from, spender), allowed - amount);

        emit!(Transfer { from, to, amount });

        true
    }

    /// Mint new tokens (only for demonstration)
    #[mutate]
    pub fn mint(&mut self, to: Address, amount: u128) {
        // In production, add access control!
        let balance = self.balance_of(to);
        self.balances.insert(to, balance + amount);
        self.total_supply += amount;

        emit!(Transfer {
            from: Address::zero(),
            to,
            amount
        });
    }

    /// Burn tokens
    #[mutate]
    pub fn burn(&mut self, amount: u128) {
        let caller = env::caller();
        let balance = self.balance_of(caller);
        require!(balance >= amount, "Insufficient balance");

        self.balances.insert(caller, balance - amount);
        self.total_supply -= amount;

        emit!(Transfer {
            from: caller,
            to: Address::zero(),
            amount
        });
    }
}

// Events
#[event]
struct TokenCreated {
    name: String,
    symbol: String,
    total_supply: u128,
}

#[event]
struct Transfer {
    from: Address,
    to: Address,
    amount: u128,
}

#[event]
struct Approval {
    owner: Address,
    spender: Address,
    amount: u128,
}

Part 4: Building the Contract

Compile to WASM

# Build optimized WASM
synor-contract build --release

# Output:
# Compiled: target/wasm32-unknown-unknown/release/my_token.wasm
# Size: 45.2 KB
# Hash: 0x1234...

Verify the Build

# Check contract info
synor-contract info target/wasm32-unknown-unknown/release/my_token.wasm

# Output:
# Contract: my_token
# Methods: new, name, symbol, decimals, total_supply, balance_of, transfer, approve, allowance, transfer_from, mint, burn
# View methods: name, symbol, decimals, total_supply, balance_of, allowance
# Mutate methods: transfer, approve, transfer_from, mint, burn

Part 5: Deploying the Contract

Using the CLI

# Deploy to testnet
synor-contract deploy \
  --wasm target/wasm32-unknown-unknown/release/my_token.wasm \
  --init "new" \
  --args '"MyToken","MTK",18,1000000000000000000000' \
  --network testnet

# Output:
# Contract deployed!
# Address: tsynor1contract_address...
# Transaction: 0xabc123...
# Gas used: 150000

Using JavaScript

import { deployContract } from '@synor/sdk';
import fs from 'fs';

async function deploy(wallet) {
  const wasm = fs.readFileSync('./my_token.wasm');

  const result = await deployContract(wallet, {
    wasm,
    method: 'new',
    args: ['MyToken', 'MTK', 18, '1000000000000000000000'],
  });

  console.log('Contract address:', result.contractAddress);
  return result.contractAddress;
}

Part 6: Interacting with the Contract

Read Contract State (View Methods)

import { callContractView } from '@synor/sdk';

// Get token info
const name = await callContractView(contractAddress, 'name', []);
console.log('Token name:', name); // "MyToken"

// Get balance
const balance = await callContractView(
  contractAddress,
  'balance_of',
  [myAddress]
);
console.log('Balance:', balance);

Write to Contract (Mutate Methods)

import { callContractMutate } from '@synor/sdk';

// Transfer tokens
const tx = await callContractMutate(wallet, contractAddress, 'transfer', [
  recipientAddress,
  '100000000000000000000', // 100 tokens with 18 decimals
]);

console.log('Transfer TX:', tx.id);

Listen to Events

const ws = new WebSocket('ws://localhost:17111');

ws.onopen = () => {
  ws.send(JSON.stringify({
    jsonrpc: '2.0',
    method: 'synor_subscribeContractEvents',
    params: [contractAddress],
    id: 1
  }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.method === 'synor_contractEvent') {
    const { eventName, data } = msg.params;
    if (eventName === 'Transfer') {
      console.log(`Transfer: ${data.from} -> ${data.to}: ${data.amount}`);
    }
  }
};

Part 7: Testing Your Contract

Write Integration Tests

// tests/integration.rs

#[test]
fn test_token_transfer() {
    let mut contract = Token::new(
        "TestToken".to_string(),
        "TT".to_string(),
        18,
        1_000_000 * 10u128.pow(18),
    );

    let alice = Address::from_hex("0x1234...").unwrap();
    let bob = Address::from_hex("0x5678...").unwrap();

    // Alice transfers to Bob
    env::set_caller(alice);
    assert!(contract.transfer(bob, 100 * 10u128.pow(18)));

    // Check balances
    assert_eq!(contract.balance_of(bob), 100 * 10u128.pow(18));
}

#[test]
fn test_insufficient_balance() {
    let contract = Token::new(...);

    env::set_caller(bob); // Bob has no tokens
    assert!(!contract.transfer(alice, 100));
}

Run Tests

cargo test

Part 8: Best Practices

Security Checklist

  • Add access control (owner, admin roles)
  • Validate all inputs
  • Check for overflow (use checked_* operations)
  • Test edge cases
  • Audit before mainnet

Gas Optimization

// Prefer: Direct storage access
let balance = self.balances.get(&addr);

// Avoid: Multiple reads
let b1 = self.balance_of(addr);
let b2 = self.balance_of(addr); // Wasteful!

// Prefer: Batch updates
self.balances.insert(from, from_balance - amount);
self.balances.insert(to, to_balance + amount);

Error Handling

// Use require! for validation
require!(amount > 0, "Amount must be positive");
require!(balance >= amount, "Insufficient balance");

// Return Result for recoverable errors
pub fn safe_transfer(&mut self, to: Address, amount: u128) -> Result<(), &'static str> {
    if amount == 0 {
        return Err("Amount must be positive");
    }
    // ...
    Ok(())
}

Example Contracts

For more examples, see:

  • NFT Contract: contracts/examples/nft/
  • DEX Contract: contracts/examples/dex/
  • Multisig: contracts/examples/multisig/
  • DAO Governance: contracts/examples/dao/

What's Next


Next: Working with the API