Building DApps on Zilliqa with Scilla Smart Contracts

Zilliqa uses sharding to achieve high throughput. If you need a blockchain that handles thousands of transactions per second, it is worth checking out. The network splits into smaller groups (shards) that process transactions in parallel. This means Zilliqa scales linearly as more nodes join the network, unlike Ethereum where every node processes every transaction.

The platform uses Scilla for smart contracts. Scilla is designed to prevent common vulnerabilities like reentrancy attacks at the language level. If you have written Solidity before, Scilla will feel different. The trade-off is a steeper learning curve in exchange for safer contracts by default.

In this guide, we will build a working token allowance contract, deploy it to testnet, and interact with it from JavaScript. By the end you will have a practical understanding of the full Zilliqa development workflow.

What is Scilla?

Scilla stands for Smart Contract Intermediate-Level Language. It is a functional language with formal verification in mind. The key design decision is the separation between pure computation and blockchain effects. Every state change is explicit, which makes it possible to reason about what a contract does by reading it linearly.

Here are the core concepts you need to understand:

  • Fields hold the contract's persistent state on-chain. Every read and write is explicit.
  • Transitions are the public entry points that external accounts or other contracts can call. Think of them like public methods.
  • Procedures are internal functions. They can only be called from within the contract. Use them to avoid code duplication.
  • Library blocks define constants and pure helper functions that have no access to contract state.
  • Messages are how contracts send ZIL or call other contracts. You construct a message and pass it as an output of a transition.

Scilla enforces a communication model where a transition runs to completion, produces messages, and then the blockchain processes those messages separately. This is why reentrancy is impossible. A contract cannot be called back mid-execution.

Setting Up Your Environment

Start by creating a new project directory and installing the dependencies:

mkdir zilliqa-dapp && cd zilliqa-dapp
npm init -y
npm install @zilliqa-js/zilliqa @zilliqa-js/util @zilliqa-js/crypto long

You will also need:

  • ZilPay browser extension for wallet management and signing transactions
  • Zilliqa testnet faucet to get test ZIL tokens (visit the faucet at dev-wallet.zilliqa.com)
  • Scilla IDE at ide.zilliqa.com for writing and checking contracts before deployment

To verify your setup, create a quick connection test:

const { Zilliqa } = require('@zilliqa-js/zilliqa');

const zilliqa = new Zilliqa('https://dev-api.zilliqa.com');

async function checkConnection() {
  const networkId = await zilliqa.network.GetNetworkId();
  console.log('Connected to network:', networkId.result);

  const blockchainInfo = await zilliqa.blockchain.getBlockChainInfo();
  console.log('Current block:', blockchainInfo.result.NumTxBlocks);
}

checkConnection();

If you see the network ID and block number printed, your environment is ready.

Writing a Basic Smart Contract

Let us start with a counter contract to understand the syntax:

scilla_version 0

library SimpleCounterLib

let one = Uint32 1

contract SimpleCounter()

field count : Uint32 = Uint32 0

procedure IncreaseCount()
  c <- count;
  new_count = builtin add c one;
  count := new_count
end

transition Increase()
  IncreaseCount;
  c <- count;
  e = { _eventname : "CountIncreased"; current_count : c };
  event e
end

transition GetCount()
  c <- count;
  e = { _eventname : "CurrentCount"; value : c };
  event e
end

Breaking this down:

  • scilla_version 0 declares the Scilla version
  • library defines reusable values and pure functions
  • field declares contract state with an initial value
  • procedure is internal logic (not callable externally)
  • transition is the public interface

The arrow operator (<-) reads from state. The assignment operator (:=) writes to state. The = operator binds the result of a pure expression to a local variable. Events emit logs that your frontend can listen for.

A More Practical Example: Token Allowance Contract

A counter is useful for learning, but let us build something closer to a real use case. This contract lets an owner deposit ZIL and set an allowance that a beneficiary can withdraw periodically:

scilla_version 0

library AllowanceLib

let zero = Uint128 0
let one_msg =
  fun (msg : Message) =>
    let nil_msg = Nil {Message} in
    Cons {Message} msg nil_msg

contract Allowance(owner : ByStr20, beneficiary : ByStr20, allowance_amount : Uint128)

field total_withdrawn : Uint128 = Uint128 0

transition Deposit()
  accept;
  e = { _eventname : "DepositReceived"; amount : _amount; sender : _sender };
  event e
end

transition Withdraw()
  is_beneficiary = builtin eq _sender beneficiary;
  match is_beneficiary with
  | True =>
    bal <- _balance;
    can_withdraw = builtin lt allowance_amount bal;
    match can_withdraw with
    | True =>
      tw <- total_withdrawn;
      new_tw = builtin add tw allowance_amount;
      total_withdrawn := new_tw;
      msg = { _tag : ""; _recipient : beneficiary; _amount : allowance_amount };
      msgs = one_msg msg;
      send msgs;
      e = { _eventname : "WithdrawalMade"; amount : allowance_amount; to : beneficiary };
      event e
    | False =>
      e = { _eventname : "InsufficientBalance"; balance : bal };
      event e
    end
  | False =>
    e = { _eventname : "Unauthorized"; caller : _sender };
    event e
  end
end

This contract demonstrates several important Scilla patterns, and the architecture mirrors common Ruby design patterns like the template method — separating public interfaces from internal logic:

  • Constructor parameters (owner, beneficiary, allowance_amount) are set at deployment and cannot change.
  • accept is required to receive ZIL. Without it, the contract rejects incoming funds.
  • Pattern matching with match/end handles conditional logic. Scilla does not have if/else.
  • Message sending via send msgs transfers ZIL to the beneficiary.
  • _balance is a built-in implicit field that tracks the contract's ZIL balance.

Testing Your Contract

Use the Scilla IDE at ide.zilliqa.com. Paste your contract and run the checker. It catches type errors and potential issues before deployment. The checker is strict. Every variable must be used, every type must match exactly, and unreachable code is flagged.

You can also use the Scilla interpreter locally:

scilla-checker -libdir stdlib -gaslimit 10000 contract.scilla

For more thorough local testing, install the Scilla binaries and use scilla-runner to simulate transitions:

scilla-runner -i contract.scilla -o output.json -init init.json \
  -istate state.json -iblockchain blockchain.json \
  -imessage message.json -gaslimit 10000 -libdir stdlib

Each JSON file represents part of the execution context. init.json contains the constructor parameters. state.json holds the current contract state. message.json defines which transition to call and with what arguments. This lets you test contracts without deploying to any network.

Deploying to Testnet

Use Zilliqa-JS to deploy. Here is a complete deployment script:

const { Zilliqa } = require('@zilliqa-js/zilliqa');
const { units, BN, Long } = require('@zilliqa-js/util');
const { getAddressFromPrivateKey } = require('@zilliqa-js/crypto');
const fs = require('fs');

const zilliqa = new Zilliqa('https://dev-api.zilliqa.com');

const privateKey = process.env.ZIL_PRIVATE_KEY;
zilliqa.wallet.addByPrivateKey(privateKey);
const deployerAddress = getAddressFromPrivateKey(privateKey);

async function deploy() {
  const code = fs.readFileSync('./contract.scilla', 'utf8');

  const init = [
    { vname: '_scilla_version', type: 'Uint32', value: '0' },
    { vname: 'owner', type: 'ByStr20', value: deployerAddress },
    { vname: 'beneficiary', type: 'ByStr20', value: '0x1234567890abcdef1234567890abcdef12345678' },
    { vname: 'allowance_amount', type: 'Uint128', value: '1000000000000' },
  ];

  const contract = zilliqa.contracts.new(code, init);

  const [deployTx, deployedContract] = await contract.deploy(
    {
      version: 21823489, // chainId 333 for testnet
      gasPrice: units.toQa('2000', units.Units.Li),
      gasLimit: Long.fromNumber(25000),
    },
    33, // max attempts to confirm
    1000, // interval between attempts in ms
  );

  if (deployTx.isConfirmed()) {
    console.log('Contract deployed at:', deployedContract.address);
    console.log('Transaction ID:', deployTx.id);
  } else {
    console.log('Deployment failed:', deployTx.receipt);
  }
}

deploy();

Note the version parameter. It encodes both the chain ID and the message version. For testnet (chain ID 333), the version is 21823489. For mainnet (chain ID 1), use 65537. Getting this wrong is a common source of failed transactions.

Calling Contract Transitions

After deployment, call transitions like this:

async function depositFunds(contractAddress) {
  const contract = zilliqa.contracts.at(contractAddress);

  const callTx = await contract.call(
    'Deposit',
    [],
    {
      version: 21823489,
      amount: new BN(units.toQa('10', units.Units.Zil)),
      gasPrice: units.toQa('2000', units.Units.Li),
      gasLimit: Long.fromNumber(10000),
    },
  );

  console.log('Deposit TX:', callTx.id);
  console.log('Receipt:', JSON.stringify(callTx.receipt, null, 2));
}

async function withdraw(contractAddress) {
  const contract = zilliqa.contracts.at(contractAddress);

  const callTx = await contract.call(
    'Withdraw',
    [],
    {
      version: 21823489,
      amount: new BN(0),
      gasPrice: units.toQa('2000', units.Units.Li),
      gasLimit: Long.fromNumber(10000),
    },
  );

  console.log('Withdraw TX:', callTx.id);
  console.log('Events:', callTx.receipt.event_logs);
}

The amount field determines how much ZIL to send along with the transaction. For Deposit, we send 10 ZIL. For Withdraw, we send zero because the contract sends ZIL back to us.

Reading Contract State

You can query contract state without making a transaction:

async function readState(contractAddress) {
  const contract = zilliqa.contracts.at(contractAddress);

  const state = await contract.getState();
  console.log('Full state:', state);

  const subState = await contract.getSubState('total_withdrawn');
  console.log('Total withdrawn:', subState.total_withdrawn);
}

State reads are free. They do not cost gas and do not require a wallet. Use them to display current values in your frontend. If you're building a backend API to serve this data, our Ruby API frameworks comparison covers the best options for building lightweight endpoints.

Building the Frontend

Connect your frontend using ZilPay. It injects a wallet provider similar to MetaMask:

async function connectAndCall(contractAddress) {
  if (!window.zilPay) {
    alert('Please install ZilPay wallet');
    return;
  }

  const zilPay = window.zilPay;
  const connected = await zilPay.wallet.connect();

  if (!connected) {
    console.log('User rejected connection');
    return;
  }

  console.log('Connected account:', zilPay.wallet.defaultAccount.base16);

  const contract = zilPay.contracts.at(contractAddress);

  const tx = await contract.call(
    'Withdraw',
    [],
    { amount: '0', gasPrice: '2000000000', gasLimit: '10000' }
  );

  console.log('Transaction submitted:', tx);
}

To listen for contract events and update the UI in real time, subscribe to the WebSocket API:

const wsUrl = 'wss://dev-ws.zilliqa.com';
const ws = new WebSocket(wsUrl);

ws.onopen = () => {
  const subscribeMsg = {
    query: 'EventLog',
    addresses: [contractAddress.replace('0x', '').toLowerCase()],
  };
  ws.send(JSON.stringify(subscribeMsg));
};

ws.onmessage = (msg) => {
  const data = JSON.parse(msg.data);
  if (data.type === 'EventLog') {
    console.log('Event received:', data.value);
  }
};

Key Differences from Ethereum

Understanding these differences will save you debugging time:

  • No reentrancy by design. Scilla's communication model processes messages after a transition completes. This eliminates an entire class of vulnerabilities.
  • Explicit state reads and writes. You always know when contract state is being accessed. There is no hidden storage lookup.
  • Pattern matching instead of if/else. Every branch must be handled. The compiler enforces exhaustive matching.
  • Gas model. Zilliqa gas costs are generally lower. The sharding architecture distributes the computational load.
  • Address format. Zilliqa uses bech32 addresses (zil1...) for display, but ByStr20 hex internally. Use the @zilliqa-js/crypto package to convert between formats.
  • No inheritance. Scilla contracts do not support inheritance. You compose behavior through libraries and inter-contract messaging instead.

Common Pitfalls

A few things that trip up developers new to Zilliqa:

  1. Forgetting accept in payable transitions. If a transition should receive ZIL, you must call accept. Otherwise the funds are rejected silently.
  2. Wrong chain version. The version field in transactions encodes both chain ID and message version. Double check this for testnet vs mainnet.
  3. Not handling all match branches. The Scilla checker rejects contracts with non-exhaustive pattern matches. Always include both True and False branches.
  4. Using the wrong address format. Init parameters expect ByStr20 hex format, not bech32. Convert addresses before passing them.

Next Steps

Start with the testnet. Get test ZIL from the faucet at dev-wallet.zilliqa.com. Deploy the counter contract first, then move to the allowance contract. If you want to package your Zilliqa tooling into a reusable library, our guide on building your first Ruby gem walks through the full process. Once you are comfortable with Scilla's syntax, explore these advanced topics:

  • Algebraic Data Types (ADTs) for modeling complex state like linked lists or option types
  • Inter-contract messaging where one contract calls transitions on another
  • Map data structures for building token registries or voting systems
  • The fungible token standard (ZRC-2) if you want to create your own token

The Zilliqa developer documentation covers each of these patterns with examples. The Scilla standard library source code is also worth reading since it shows idiomatic patterns for common operations.