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 longYou 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
endBreaking this down:
scilla_version 0declares the Scilla versionlibrarydefines reusable values and pure functionsfielddeclares contract state with an initial valueprocedureis internal logic (not callable externally)transitionis 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
endThis 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. acceptis required to receive ZIL. Without it, the contract rejects incoming funds.- Pattern matching with
match/endhandles conditional logic. Scilla does not haveif/else. - Message sending via
send msgstransfers ZIL to the beneficiary. _balanceis 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.scillaFor 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 stdlibEach 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/cryptopackage 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:
- Forgetting
acceptin payable transitions. If a transition should receive ZIL, you must callaccept. Otherwise the funds are rejected silently. - Wrong chain version. The
versionfield in transactions encodes both chain ID and message version. Double check this for testnet vs mainnet. - Not handling all match branches. The Scilla checker rejects contracts with non-exhaustive pattern matches. Always include both
TrueandFalsebranches. - 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.