Learn Symvasi

A step-by-step guide to writing your first smart contract. No prior blockchain experience required.

1. Your First Contract

Every Symvasi program is a contract— a piece of code that lives on the blockchain and manages its own state. Here is the simplest possible contract:

hello.sym
1contract Hello {
2 pub storage greeting: String
3
4 @init
5 fn deploy() {
6 self.greeting = "Hello, SUM-Chain!"
7 }
8
9 @view
10 pub fn get() -> String {
11 self.greeting
12 }
13}

contract Hello — declares a contract named Hello.

pub storage greeting: String — persistent on-chain state. pub auto-generates a read-only getter.

@init fn deploy() — the constructor. Runs once when the contract is deployed.

@view pub fn get() — a read-only function. Cannot modify storage.

Try it yourself:

$ sym check hello.sym    # type-check

$ sym build hello.sym    # compile to IR

2. Storage & State

Storage fields are your contract's persistent data. They live on-chain and survive between function calls.

storage.sym
1contract Token {
2 // pub storage = auto-generates external getter
3 pub storage name: String
4 pub storage total_supply: u128
5
6 // private storage = only accessible inside contract
7 storage owner: Address
8 storage balances: Map<Address, u128>
9}

pub storage creates a public read-only getter. External callers can read, but only the contract can write.

storage(without pub) is private — no external access at all.

Access storage via self.field_name.

Key rule: Storage is mutable in @call and @payable functions, but read-only in @view. The compiler enforces this.

3. Functions & Annotations

Every contract function must have exactly one annotation that tells the compiler what the function does:

AnnotationMutates StorageReceives TokensUse For
@initYesOptionalConstructor (one per contract)
@callYesNoState-changing operations
@viewNoNoRead-only queries
@payableYesYesReceiving native tokens
annotations.sym
1contract Vault {
2 storage balance: Balance
3
4 @init
5 fn deploy() { }
6
7 @payable
8 pub fn deposit() {
9 self.balance += msg_value()
10 }
11
12 @call
13 pub fn withdraw(amount: Balance) -> Result<(), Error> {
14 require(caller() == self.owner, "Not owner")
15 send(caller(), amount)?
16 Ok(())
17 }
18
19 @view
20 pub fn get_balance() -> Balance {
21 self.balance
22 }
23}

4. Visibility

Every function must declare its visibility. The compiler errors if you forget.

visibility.sym
1// Public — callable from outside
2@call
3pub fn transfer(to: Address, amount: u128) { ... }
4
5// Private (explicit keyword)
6priv fn _validate(amount: u128) -> bool { ... }
7
8// Private (implicit — underscore prefix)
9fn _check(addr: Address) -> bool { ... }
10
11// ERROR: pub + underscore prefix = compiler error
12pub fn _bad() { ... } // will not compile

pub — accessible from outside the contract

priv — internal only

_ prefix — implicitly private (no keyword needed)

@init — always public (keyword optional)

5. Variables & Types

Variables are immutable by default. Use var for mutable bindings.

variables.sym
1let x = 5 // immutable (default is u64)
2let y: u128 = 100 // explicit type annotation
3var counter = 0 // mutable
4counter += 1 // OK
5
6x = 10 // ERROR: x is immutable

Primitive Types

u8 u16 u32 u64 u128

Unsigned integers

i8 i16 i32 i64 i128

Signed integers

bool

true or false (no int casting)

String

Immutable UTF-8 text

Chain-Native Types

Address

32-byte wallet/contract address

Balance

Native token amount (u128)

Hash

32-byte hash value

Timestamp

Unix time in seconds (u64)

Collections

collections.sym
Vec<Address> // dynamic array
Map<Address, u128> // key-value mapping
Array<u8, 32> // fixed-length array
Option<Address> // Some(value) or None
Result<u128, Error> // Ok(value) or Err(error)
(Address, u128) // tuple

6. Error Handling

Symvasi has two complementary error mechanisms:

require() — Guard Clauses

Reverts the entire transaction if the condition is false.

require.sym
require(amount > 0, "Amount must be positive")
require(caller() == self.owner, "Only owner")
// The message MUST be a string literal
require(x > 0, some_variable) // ERROR

Result<T, E> — Recoverable Errors

result.sym
1@call
2pub fn transfer(to: Address, amount: u128) -> Result<(), Error> {
3 require(amount > 0, "Zero transfer")
4 require(self.balances[caller()] >= amount, "Insufficient balance")
5
6 self.balances[caller()] -= amount
7 self.balances[to] += amount
8
9 Ok(())
10}
11
12// The ? operator propagates errors automatically
13@call
14pub fn process(amount: u128) -> Result<(), Error> {
15 let half = divide(amount, 2)? // Err propagates
16 send(caller(), half as Balance)?
17 Ok(())
18}
Tip: The ? operator can only be used inside functions that return Result<T, E>. Using it elsewhere is a compiler error.

7. Events

Events are logs emitted during execution. Off-chain applications can subscribe to them.

events.sym
1contract Token {
2 // Declare events
3 event Transfer { from: Address, to: Address, amount: u128 }
4 event Approval { owner: Address, spender: Address, amount: u128 }
5
6 @call
7 pub fn transfer(to: Address, amount: u128) {
8 self.balances[caller()] -= amount
9 self.balances[to] += amount
10
11 // Emit event
12 emit Transfer {
13 from: caller(),
14 to: to,
15 amount: amount,
16 }
17 }
18}

8. Control Flow

control_flow.sym
1// Conditional
2if amount > 0 {
3 self.count += amount
4} else {
5 require(false, "Invalid amount")
6}
7
8// if as expression
9let fee = if amount > 1000 { 10 } else { 1 }
10
11// Match (must be exhaustive)
12match status {
13 Status::Active => { process() },
14 Status::Pending => { wait() },
15 _ => { require(false, "Invalid status") },
16}
17
18// For loop
19for owner in self.owners {
20 if owner == caller() { return true }
21}
22
23// While loop
24while self.count > 0 {
25 self.count -= 1
26}

No infinite loopsloop {} without bounds cannot exist on-chain

No recursion — deferred to v2 (no call stack analysis yet)

No goto — ever

9. Custom Types

custom_types.sym
1// Struct
2pub struct Position {
3 pub x: i64,
4 pub y: i64,
5}
6
7// Newtype (distinct type via tuple struct)
8pub struct UserId(u64)
9pub struct ProductId(u64)
10// UserId(1) == ProductId(1) -> compiler error!
11
12// Enum
13pub enum Status {
14 Pending,
15 Active,
16 Cancelled { reason: String },
17}
18
19// Type alias
20type TokenId = u128
21
22// Interface
23pub interface Ownable {
24 fn owner(self) -> Address
25 fn transfer_ownership(mut self, new_owner: Address)
26}
27
28contract Vault implements Ownable {
29 // must implement all methods
30 ...
31}

10. Built-in Functions

These are globally available in any contract — no imports needed.

Transaction Context

caller() -> Address // who called this function
origin() -> Address // original tx signer
msg_value() -> Balance // tokens sent with this call
tx_hash() -> Hash // transaction identifier

Block Context

block_number() -> BlockNumber
block_time() -> Timestamp // WARNING: ~15s manipulation
block_hash() -> Hash

Crypto Primitives

hash(data: Bytes) -> Hash // Blake3
keccak256(data: Bytes) -> Hash // EVM compatible
verify_sig(msg, sig, pubkey) -> bool // Ed25519/secp256k1
merkle_verify(root, proof, leaf) -> bool

Sending Tokens

send(to: Address, amount: Balance) -> Result<(), Error>
self_balance() -> Balance
balance_of(addr: Address) -> Balance

Next Steps