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:
1contract Hello {2 pub storage greeting: String34 @init5 fn deploy() {6 self.greeting = "Hello, SUM-Chain!"7 }89 @view10 pub fn get() -> String {11 self.greeting12 }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.
1contract Token {2 // pub storage = auto-generates external getter3 pub storage name: String4 pub storage total_supply: u12856 // private storage = only accessible inside contract7 storage owner: Address8 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:
| Annotation | Mutates Storage | Receives Tokens | Use For |
|---|---|---|---|
| @init | Yes | Optional | Constructor (one per contract) |
| @call | Yes | No | State-changing operations |
| @view | No | No | Read-only queries |
| @payable | Yes | Yes | Receiving native tokens |
1contract Vault {2 storage balance: Balance34 @init5 fn deploy() { }67 @payable8 pub fn deposit() {9 self.balance += msg_value()10 }1112 @call13 pub fn withdraw(amount: Balance) -> Result<(), Error> {14 require(caller() == self.owner, "Not owner")15 send(caller(), amount)?16 Ok(())17 }1819 @view20 pub fn get_balance() -> Balance {21 self.balance22 }23}4. Visibility
Every function must declare its visibility. The compiler errors if you forget.
1// Public — callable from outside2@call3pub fn transfer(to: Address, amount: u128) { ... }45// Private (explicit keyword)6priv fn _validate(amount: u128) -> bool { ... }78// Private (implicit — underscore prefix)9fn _check(addr: Address) -> bool { ... }1011// ERROR: pub + underscore prefix = compiler error12pub fn _bad() { ... } // will not compilepub — 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.
1let x = 5 // immutable (default is u64)2let y: u128 = 100 // explicit type annotation3var counter = 0 // mutable4counter += 1 // OK56x = 10 // ERROR: x is immutablePrimitive 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
Vec<Address> // dynamic arrayMap<Address, u128> // key-value mappingArray<u8, 32> // fixed-length arrayOption<Address> // Some(value) or NoneResult<u128, Error> // Ok(value) or Err(error)(Address, u128) // tuple6. Error Handling
Symvasi has two complementary error mechanisms:
require() — Guard Clauses
Reverts the entire transaction if the condition is false.
require(amount > 0, "Amount must be positive")require(caller() == self.owner, "Only owner")// The message MUST be a string literalrequire(x > 0, some_variable) // ERRORResult<T, E> — Recoverable Errors
1@call2pub fn transfer(to: Address, amount: u128) -> Result<(), Error> {3 require(amount > 0, "Zero transfer")4 require(self.balances[caller()] >= amount, "Insufficient balance")56 self.balances[caller()] -= amount7 self.balances[to] += amount89 Ok(())10}1112// The ? operator propagates errors automatically13@call14pub fn process(amount: u128) -> Result<(), Error> {15 let half = divide(amount, 2)? // Err propagates16 send(caller(), half as Balance)?17 Ok(())18}? 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.
1contract Token {2 // Declare events3 event Transfer { from: Address, to: Address, amount: u128 }4 event Approval { owner: Address, spender: Address, amount: u128 }56 @call7 pub fn transfer(to: Address, amount: u128) {8 self.balances[caller()] -= amount9 self.balances[to] += amount1011 // Emit event12 emit Transfer {13 from: caller(),14 to: to,15 amount: amount,16 }17 }18}8. Control Flow
1// Conditional2if amount > 0 {3 self.count += amount4} else {5 require(false, "Invalid amount")6}78// if as expression9let fee = if amount > 1000 { 10 } else { 1 }1011// Match (must be exhaustive)12match status {13 Status::Active => { process() },14 Status::Pending => { wait() },15 _ => { require(false, "Invalid status") },16}1718// For loop19for owner in self.owners {20 if owner == caller() { return true }21}2223// While loop24while self.count > 0 {25 self.count -= 126}No infinite loops — loop {} without bounds cannot exist on-chain
No recursion — deferred to v2 (no call stack analysis yet)
No goto — ever
9. Custom Types
1// Struct2pub struct Position {3 pub x: i64,4 pub y: i64,5}67// Newtype (distinct type via tuple struct)8pub struct UserId(u64)9pub struct ProductId(u64)10// UserId(1) == ProductId(1) -> compiler error!1112// Enum13pub enum Status {14 Pending,15 Active,16 Cancelled { reason: String },17}1819// Type alias20type TokenId = u1282122// Interface23pub interface Ownable {24 fn owner(self) -> Address25 fn transfer_ownership(mut self, new_owner: Address)26}2728contract Vault implements Ownable {29 // must implement all methods30 ...31}10. Built-in Functions
These are globally available in any contract — no imports needed.
Transaction Context
caller() -> Address // who called this functionorigin() -> Address // original tx signermsg_value() -> Balance // tokens sent with this calltx_hash() -> Hash // transaction identifierBlock Context
block_number() -> BlockNumberblock_time() -> Timestamp // WARNING: ~15s manipulationblock_hash() -> HashCrypto Primitives
hash(data: Bytes) -> Hash // Blake3keccak256(data: Bytes) -> Hash // EVM compatibleverify_sig(msg, sig, pubkey) -> bool // Ed25519/secp256k1merkle_verify(root, proof, leaf) -> boolSending Tokens
send(to: Address, amount: Balance) -> Result<(), Error>self_balance() -> Balancebalance_of(addr: Address) -> BalanceNext Steps
- → Browse example contracts to see real-world patterns
- → Install the toolchain and start writing contracts
- → Read the full symvasi-spec-v0.1.md for the complete language specification