Build dApps
Ridiculously Fast
Rell is a relational blockchain language. Write SQL-like code, get a fully decentralized app. No complexity, just productivity.
// Your first Rell dApp - it's that simple!
entity message {
content: text;
author: text;
timestamp;
}
operation create_message(content: text, author: text) {
create message(content, author, op_context.last_block_time);
}
query get_messages() = message @* {} (
id = .rowid,
.content, .author, .timestamp
);
Quickstart
Pre-configured environment with Docker — no manual setup needed
Create Project
docker run --rm -u $(id -u):$(id -g) \
-v "$(pwd):$(pwd)" -w "$(pwd)" \
registry.gitlab.com/chromaway/core-tools/chromia-cli/chr:latest \
chr create-rell-dapp --devcontainer my-dapp
docker run --rm -v "%CD%:%CD%" -w "%CD%" ^
registry.gitlab.com/chromaway/core-tools/chromia-cli/chr:latest ^
chr create-rell-dapp --devcontainer my-dapp
Open in VS Code
Open my-dapp folder in VS Code
Click "Reopen in Container" when prompted
Run Local Node
chr node start
# DB conflicts after code changes? Wipe: chr node start --wipe
Your dApp runs on localhost:7740 🎉
Interact with Your dApp
Keep chr node start running, open a second terminal:
# Query your dApp
chr query hello_world
# → "Hello World!"
# Send a transaction
chr tx set_name Dev
# Query again to see the change
chr query hello_world
# → "Hello Dev!"
# � Want to run the same tx again? Blockchains need unique transactions.
# -nop adds a "no operation" value to make each tx unique:
chr tx set_name Dev -nop
# → Works every time! ✓
chr query = read data
chr tx = write data
-nop = make tx unique
📄 chromia.yml - Your Config File
Every Rell project needs this file in the root:
blockchains:
my_dapp:
module: main
compile:
rellVersion: 0.14.8
database:
schema: schema_my_dapp
test:
modules:
- test
Language Essentials
Everything you need to know, nothing you don't
🔢 Built-in Types
integer
6.7k uses
64-bit signed whole numbers
val count: integer = 42;
text
5.7k uses
UTF-8 encoded strings
val name: text = "Alice";
byte_array
4.6k uses
Binary data (hashes, keys)
val hash: byte_array = x"ab12...";
boolean
1.3k uses
True or false values
val active: boolean = true;
big_integer
2.5k uses
Arbitrary precision integers — essential for token amounts
val amount: big_integer = 1000000L; // 1 token (6 decimals)
decimal
1.4k uses
Fixed-point with 20 decimal places — exact math, not floating point!
val price: decimal = 123.456789; // Exact, no rounding errors
pubkey
= byte_array
Public keys (33 bytes)
val signer: pubkey = op_context.get_signers()[0];
timestamp
= integer
Unix milliseconds since epoch
val now: timestamp = op_context.last_block_time;
name
= text
Semantic naming for identifiers
val op_name: name = op_context.op_name;
rowid
auto-generated
Unique ID per entity row
val id = user.rowid; // Every entity has this
list<T>
Ordered, allows duplicates
val items = [1, 2, 3]; // list<integer>
set<T>
Unordered, unique values
val unique = set([1, 2, 2]); // {1, 2}
map<K, V>
Key-value pairs
val scores = ["alice": 100, "bob": 85];
T?
Nullable types
val maybe: integer? = null;
🔄 Control Flow
val
14.3k uses
recommended
Immutable variable — cannot be reassigned
val name = "Alice"; // type inferred
val count: integer = 42; // explicit type
val items = [1, 2, 3]; // immutable list reference
var! Rell prefers immutability.
var
1.3k uses
Mutable variable — can be reassigned
var counter = 0;
counter += 1; // OK: reassignment allowed
var total = 0;
for (x in items) { total += x; } // accumulator pattern
if / else
4.8k uses
Conditional execution — also works as expression
// Statement
if (balance >= amount) {
transfer(from, to, amount);
} else {
log("Insufficient balance");
}
// Expression (returns value)
val status = if (active) "online" else "offline";
// Chained
val tier = if (score > 90) "gold"
else if (score > 70) "silver"
else "bronze";
when
139 uses
Multi-way branching — like switch but better
// With argument (value matching)
when(status) {
"active" -> return true;
"pending", "review" -> return false;
else -> return false;
}
// Without argument (condition matching)
when {
x < 0 -> return "negative";
x == 0 -> return "zero";
else -> return "positive";
}
if/else if chains. Multiple values with comma.
for
1.3k uses
preferred
Iterate over ranges or collections
// Over range
for (i in range(10)) { print(i); } // 0..9
// Over collection
for (user in users) {
process(user);
}
// Over map with tuple unpacking
for ((key, value) in scores) {
print(key + ": " + value);
}
// With entity query
for (u in user @* { .active }) {
notify(u);
}
while
41 uses
Loop while condition is true
var x = 1;
while (x < 100) {
x *= 2;
}
// Condition-based processing
var remaining = items.size();
while (remaining > 0) {
process_next();
remaining -= 1;
}
for! Usually for is cleaner.
require()
4.5k uses
essential
Assert condition or throw error — THE validation pattern in Rell
// Simple validation
require(amount > 0, "Amount must be positive");
// Entity existence
val user = require(
user @? { .id == user_id },
"User not found: %d".format(user_id)
);
// Input validation pattern (common in operations)
operation register(name: text) {
require(name.size() >= 3, "Name too short");
require(name.size() <= 16, "Name too long");
require(name.matches("^[a-zA-Z0-9_]+$"), "Invalid characters");
require(user @? { .name == name } == null, "Name taken");
create user(name = name, ...);
}
if! Fail-fast validation is a core Rell pattern.
Core Building Blocks
The essential components of every Rell application
entity
Your data model. Like a SQL table, but smarter.
entity user {
key id: integer; // unique identifier
index username: text; // indexed for fast lookup
mutable balance: integer = 0;
created_at: timestamp; // set in operation
// ↓ automatically added by Rell:
// rowid: rowid; // auto-generated unique ID
}
key = unique, not null
index = fast queries
mutable = can change
operation
Modifies data. Requires a transaction.
operation create_user(id: integer, username: text) {
require(username.size() > 0, "Username required");
create user(
id = id,
username = username,
created_at = op_context.last_block_time
);
}
operation update_user(id: integer, new_username: text) {
require(new_username.size() > 0, "Username required");
val u = user @ { .id == id };
// Update single field
update u ( username = new_username );
// Or update multiple fields
update u ( username = new_username, balance = 10 );
}
operation delete_user(id: integer) {
val u = user @ { .id == id };
// Delete single entity
delete u;
// Or delete with condition
delete user @* { .balance == 0 };
}
require() = validate or fail
op_context = tx info
last_block_time = Unix ms
query
Read-only. Instant, no transaction needed.
// Simple query
query get_user_balance(id: integer) = user @? { .id == id } (.balance);
// Query with transformation
query get_leaderboard(max_results: integer) = user @* {} (
@sort_desc .balance,
username = .username,
balance = .balance
) limit max_results;
// Aggregation
query total_supply() = user @* {} ( @sum .balance );
@ = exactly one
@? = zero or one
@* = many
function
Reusable logic. Can be called from operations or queries.
function calculate_fee(amount: integer): integer {
val fee_percent = 2;
return amount * fee_percent / 100;
}
function get_user_or_fail(id: integer): user {
return user @ { .id == id }; // throws if not found
}
function get_user_or_null(id: integer): user? {
return user @? { .id == id }; // returns null if not found
}
struct
Named data type. Lives in memory, not in DB.
struct user_info {
id: integer;
username: text;
balance: integer;
}
query get_user_info(id: integer): user_info {
val u = user @ { .id == id };
return user_info(u.id, u.username, u.balance);
}
// Shorthand: Entity → struct
// user @ { .id == id } ($.to_struct()); // → struct
// user @ { .id == id } (.id, .username); // → tuple (integer, text)
$ = current entity
.field = $.field
enum
Named constants. Perfect for status, types, categories.
enum order_status { pending, confirmed, shipped, delivered }
entity order {
key id: integer;
status: order_status; // type-safe!
}
// Query by enum value
query get_pending() = order @* { .status == order_status.pending };
namespace
Organize your code. Avoid naming conflicts.
namespace posts {
entity post { key title: text; }
operation create_post(title: text) {
create post(title);
}
query get_all() = post @* {} ( .title );
}
// Usage: posts.create_post("Hello World")
object
Singleton. One instance, initialized at chain start.
// One config for the whole chain
object config {
mutable fee_percent: integer = 2;
mutable paused: boolean = false;
}
// Read directly
query get_fee() = config.fee_percent;
// Update via direct assignment (no create/delete!)
function set_fee(new_fee: integer) {
config.fee_percent = new_fee;
config.paused = true; // can update multiple fields
}
object = exactly 1 instance
No create or delete
@log entity
Immutable records with automatic transaction context.
@log entity transfer_log {
from_account: byte_array;
to_account: byte_array;
amount: big_integer;
transaction: transaction; // automatically added by @log
}
// Query with block info (via .transaction)
query get_transfers() = transfer_log @* {} (
.from_account, .to_account, .amount,
block = .transaction.block.block_height,
time = .transaction.block.timestamp,
tx_id = .transaction.tx_rid
);
@log = can't update or delete
.transaction = when & where created
@extend
Hook into extendable functions. THE plugin pattern in Rell.
// Library defines extension point
@extendable function on_transfer(from: user, to: user, amount: big_integer);
// Your code hooks into it
@extend(on_transfer)
function (from: user, to: user, amount: big_integer) {
create transfer_log(from.id, to.id, amount);
}
// Multiple extensions allowed — all get called!
@extend(on_transfer)
function (from: user, to: user, amount: big_integer) {
if (amount > 1000000L) notify_admin(from, amount); // 1 coin (6 decimals)
}
@extendable = define hook
@extend = plug into hook
🔗 Entity Relationships
Entities can reference other entities. key and index determine the relationship type.
1:1 — One-to-One
Each user has exactly one profile entry
entity user { key id: integer; name: text; }
entity profile {
key user; // ← Reference! Each user max 1x
bio: text;
}
// Query: Profile for a user
query get_profile(u: user) = profile @? { .user == u };
1:N — One-to-Many
A user can have many badges
entity badge {
key id: integer;
index user; // ← index = multiple per user allowed
name: text;
received_at: timestamp;
}
// Query: All badges for a user
query get_badges(u: user) = badge @* { .user == u };
N:M — Many-to-Many
Users can have multiple challenges, challenges can have multiple users
entity challenge { key id: integer; name: text; }
entity user_challenge {
key user, challenge; // ← Composite key = join table
completed: boolean = false;
}
// Query: All challenges for a user
query get_challenges(u: user) = user_challenge @* { .user == u } (.challenge);
📝 Query Syntax Variants
Rell offers flexible syntax for queries. Choose based on complexity and readability.
1️⃣ Block Syntax { }
Standard query — type is inferred automatically
query get_all_users() {
return user @* {} (.id, .username, .balance);
}
query get_user(id: integer) {
return user @ { .id == id } ($.to_struct());
}
2️⃣ Expression Syntax =
One-liner shorthand — no return needed
query get_all_users() = user @* {} (.id, .username, .balance);
query get_user(id: integer) = user @ { .id == id } ($.to_struct());
3️⃣ Explicit Return Type : Type { }
When you need explicit typing (e.g., custom struct)
query get_user(id: integer): user_info {
val u = user @ { .id == id };
return user_info(u.id, u.username, u.balance);
}
🏗️ Query vs Function
Queries are your API layer, functions are internal helpers.
query get_user(id: integer) {
return user @ { .id == id } ($.to_struct());
}
query get_all_users() = user @* {} (.username, .balance);
function add_balance(id: integer, amount: big_integer) {
val u = user @ { .id == id };
update u ( balance += amount );
}
💡 Return Types
What you get back depends on your projection
user @* { .balance > 100 }
→ list<user>
DB reference — can update/delete
user @* {} ( .username, .balance )
→ list<(text, integer)>
Copy of selected values
user @* {} ( $.to_struct() )
→ list<struct<user>>
Named type, serializable
🎯 Query Operators Cheatsheet
Click an operator to see an example
// Get exactly one user by ID (throws if not found)
val u = user @ { .id == 42 };
// With projection
val username = user @ { .id == 42 } ( .username );
⚡ Operation Context
Available inside operations. Click to see examples. Check with op_context.exists
operation create_user(username: text) {
create user(
username = username,
balance = 0,
created_at = op_context.last_block_time // ✓ Consistent across nodes
// created_at = now() // ✗ Don't use! Differs per node
);
}
// Returns e.g. 1767225600000 (Jan 1, 2026 00:00:00 UTC)
📦 Modules & Import
Split your dApp into reusable modules. Every real project needs this.
Two Ways to Create a Module
Single-File Module
One .rell file = one module
// src/main.rell
module;
entity user { key username: text; }
operation create_user(username: text) {
create user(username);
}
Directory Module
Folder with multiple .rell files
src/users/
├── module.rell ← optional! for imports/config
├── entities.rell ← entity user { ... }
├── operations.rell ← operation create_user() { ... }
└── queries.rell ← query get_users() { ... }
All files in same folder share definitions automatically! module.rell only needed for imports or custom mounts.
Import Syntax
// Basic import — access via alias
import users; // users.create_user()
import u: users; // u.create_user()
// Wildcard — add all to current namespace
import users.*; // create_user() directly
// Selective — only what you need
import users.{ user, create_user };
module;?• Single file = one module: Add
module; at top → file is its own isolated module• Multiple files = one module: Don't add
module; → all files in folder share definitions automatically
⚙️ Module Configuration
Pass configuration values to your module via chromia.yml — access them at runtime with chain_context.args
Define in Module
// Define config structure
struct module_args {
admin_pubkey: pubkey;
fee_percent: integer;
app_name: text; // string config
}
operation admin_action() {
// Read config at runtime
require(op_context.is_signer(
chain_context.args.admin_pubkey
), "Not admin");
}
query get_app_name() = chain_context.args.app_name;
Configure in YAML
# chromia.yml
blockchains:
my_dapp:
module: main
moduleArgs:
main: # must match module name
admin_pubkey: x"03a5..."
fee_percent: 2
app_name: "My Chromia App"
struct module_args to use chain_context.args — otherwise you get a compile error!
🧪 Unit Testing
Test your code before deployment. Every function starting with test_ runs automatically.
Basic Test Module
Create the file data_test.rell in the src/test directory
@test module; // marks this file as test-only, excluded from deployment
import main;
function test_create_user() {
// Before: no users exist
assert_equals((main.user @* {}).size(), 0);
// Run operation
rell.test.tx()
.op(main.create_user(1, "alice"))
.run();
// After: user exists
assert_equals((main.user @* {}).size(), 1);
assert_equals(main.user @ { .id == 1 }.username, "alice");
}
Test with Signatures
Use built-in test keypairs: alice, bob, charlie...
function test_signed_operation() {
// Sign with alice's test keypair
rell.test.tx()
.op(main.admin_action())
.sign(rell.test.keypairs.alice)
.run();
}
function test_must_fail() {
// Expect operation to fail
rell.test.tx()
.op(main.admin_action())
.sign(rell.test.keypairs.bob) // wrong signer
.run_must_fail();
}
assert_equals(a, b)
Equal
assert_true(x)
True
assert_not_null(x)
Not null
.run_must_fail()
Expect fail
chr repl -c 'print(rell.test.keypairs.alice);'
chr test
Module Arguments in Tests
If your module uses module_args, you must also pass them in the test section of chromia.yml.
blockchains:
my_dapp:
module: main
moduleArgs:
main:
admin_pubkey: x"03a5c8e4f7b2d6..."
test:
modules:
- test
moduleArgs:
main: # Args for the main module being tested
admin_pubkey: x"02466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27" # alice pubkey
test: # Args for the test module itself
test_iterations: 10
💡 Test modules can also define their own module_args struct for test-specific configuration variables.
🔐 Account Management
The foundational concepts you need to understand before building with FT4 — Chromia's standard library for accounts, authentication, and assets.
Your digital identity on Chromia. Holds assets, tracks history, interacts with dApps.
account_id: 32 bytes (unique)Can have up to 10 AuthDescriptors
Think of it like a house 🏠 with multiple keys: each key (AuthDescriptor) defines WHO can enter and WHAT rooms they can access.
Flags — which rooms the key opens:
"A" = Account mgmt (add/remove keys)"T" = Transfer assets
Created once, flags
["A","T"]
Expires after configurable time (default 24h), often only
["T"]
🧬 How Account IDs are Generated
.hash()
Result:
account_id = signer.hash() (32 bytes)
📦 GTV & GTX — Chromia's Data Protocols
GTV is like JSON, but better for blockchains. GTX uses GTV to structure transactions.
Why GTV instead of JSON?
Deterministic
Same data → same bytes (JSON order not guaranteed)
Hashable
Cryptographic hash for proofs & signatures
byte_array
Native binary support (JSON needs Base64)
Type-safe
Direct encoding to Rell types (integer, text, big_integer...)
GTX Transaction Structure
Every operation you call becomes a GTX transaction. The args are GTV-encoded.
GTX Transaction
├── body (gtx_transaction_body)
│ ├── blockchain_rid: byte_array ← which chain
│ ├── operations: list ← what to do
│ │ └── [0] operation
│ │ ├── name: "transfer" ← operation name
│ │ └── args: [0x123, 100] ← GTV-encoded!
│ └── signers: list<byte_array> ← who signs
└── signatures: list<byte_array> ← the signatures
When do you need GTV explicitly?
// 1. Deterministic hash from multiple values (e.g., for random seed)
struct seed_data { account_id: byte_array; day: integer; }
val seed = seed_data(account.id, day).to_gtv().hash();
// Same input → same hash (JSON can't guarantee this!)
// 2. Serialize/deserialize arbitrary data
val data = my_struct.to_gtv().to_bytes(); // struct → binary
val restored = struct<my_struct>.from_gtv(gtv.from_bytes(data));
// 3. Store "any type" in entity
entity config { key name: text; config_value: gtv; } // gtv can hold anything
.to_gtv().hash() for crypto operations
🔗 ICMF — Cross-Chain Messaging
Send events between blockchains in the same cluster. Uses emit_event internally.
// main.rell
module;
import lib.icmf.{ send_message };
entity user {
key id: integer;
username: text;
}
operation create_user(id: integer, username: text) {
val user = create user ( id, username );
// Notify other chains
send_message(
"L_user_created", // topic
(username = username).to_gtv() // body as gtv
);
}
// receiver.rell
module;
import lib.icmf.receiver.{ receive_icmf_message };
struct user {
username: text;
}
@extend(receive_icmf_message)
function (sender: byte_array, topic: text, body: gtv) {
if (topic == "L_user_created") {
val data = user.from_gtv(body);
log("New user: ", data.username);
}
}
# chromia.yml
blockchains:
my_dapp:
module: main
config:
gtx:
modules:
- "net.postchain.d1.icmf.IcmfSenderGTXModule"
receiver:
module: receiver
config:
gtx:
modules:
- "net.postchain.d1.icmf.IcmfReceiverGTXModule"
sync_ext:
- "net.postchain.d1.icmf.IcmfReceiverSynchronizationInfrastructureExtension"
icmf:
receiver:
local:
- topic: "L_user_created" # L_ = local (same cluster). G_ = global (cross-cluster, system chains only)
brid: null # null = accept from any chain (dev). Production: use specific bc-rid
libs:
com.chromia.icmf:
version: 1.102.2
chr install — Downloads libraries to ./lib folder
chr library list — Shows all available library versions
chr node start — Starts both chains locally
chr tx create_user 1 "Meow" — Creates user and triggers ICMF message
🔐 ICCF — Cross Chain Proofs
Verify transactions from other clusters with cryptographic proofs. Client builds proof, target chain verifies.
// main.rell
module;
entity user {
key id: integer;
username: text;
}
operation create_user(id: integer, username: text) {
create user ( id, username );
}
// ┌──────────┐
// │ Chain A │ ← You are here
// └────┬─────┘
// │ tx confirmed
// ▼
// ╔═══════════════╗
// ║ Anchoring ║ ← Cluster Anchoring Chain
// ╚═══════┬═══════╝
// │ Merkle proof
// ▼
// ┌──────────┐
// │ Chain B │ ← Proof verified!
// └──────────┘
import { createClient } from "postchain-client";
// Directory Chain BRIDs — find at explorer.chromia.com → System Cluster
const DIRECTORY_BRID_TESTNET = "6F1B061C633A992BF195850BF5AA1B6F887AEE01BB3E51251C230930FB792A92";
const DIRECTORY_BRID_MAINNET = "7E5BE539EF62E48DDA7035867E67734A70833A69D2F162C457282C319AA58AE4";
// Directory Chain client — used for node discovery to locate Cluster Anchoring Chain
const directoryClient = await createClient({
directoryNodeUrlPool: ["https://node0.testnet.chromia.com:7740"],
// Mainnet: ["https://system.chromaway.com"],
blockchainRid: DIRECTORY_BRID_TESTNET,
});
// Source chain client — where original tx happens
const sourceClient = await createClient({
directoryNodeUrlPool: ["https://node0.testnet.chromia.com:7740"],
// Mainnet: ["https://system.chromaway.com"],
blockchainRid: "YOUR_SOURCE_CHAIN_BRID",
});
// Target chain client — receives the proof
const targetClient = await createClient({
directoryNodeUrlPool: ["https://node0.testnet.chromia.com:7740"],
// Mainnet: ["https://system.chromaway.com"],
blockchainRid: "YOUR_TARGET_CHAIN_BRID",
});
import { createIccfProofTx, newSignatureProvider } from "postchain-client";
// 1. Key pair for signing transactions — generate with: chr keygen --dry
const signer = newSignatureProvider({
privKey: Buffer.from("<YOUR_PRIVATE_KEY>", "hex"),
pubKey: Buffer.from("<YOUR_PUBLIC_KEY>", "hex"),
});
// 2. Send tx to Chain A (source)
const { transactionRid } = await sourceClient.signAndSendUniqueTransaction(
{ name: "create_user", args: [1, "Meow"] }, signer
);
// 3. Get transaction info (needed for txHash)
const txInfo = await sourceClient.getTransactionInfo(transactionRid);
// 4. Build ICCF proof (waits for anchoring)
const { iccfTx, verifiedTx } = await createIccfProofTx(
directoryClient, // Client to directory chain
transactionRid, // TX to prove
txInfo.txHash, // Hash of source TX
[signer.pubKey], // Who signed source TX (for verification)
"YOUR_SOURCE_CHAIN_BRID", // Source chain RID
"YOUR_TARGET_CHAIN_BRID", // Target chain RID
);
// 5. Send proof + target op to Chain B
await targetClient.signAndSendUniqueTransaction({
operations: [
iccfTx.operations[0], // iccf_proof
{ name: "sync_user", args: [verifiedTx] } // target op
],
signers: [signer.pubKey]
}, signer);
// receiver.rell
module;
import lib.iccf;
// Define config structure
struct module_args {
trusted_source_brid: byte_array;
}
// Must match the struct on the source chain
struct user {
id: integer;
username: text;
}
operation sync_user(tx: gtx_transaction) {
// Optional: Verify proof comes from trusted source chain
val source_brid = tx.body.blockchain_rid;
require(source_brid == chain_context.args.trusted_source_brid, "Untrusted source chain");
// Verify the proof is valid
iccf.require_valid_proof(tx, verify_signers = true);
// Find the operation in the proven tx
val op = tx.body.operations @? { .name == "create_user" };
require(op, "create_user operation not found");
// Parse operation arguments into user struct
val op_args = user.from_gtv(op.args.to_gtv());
log("New user: ", op_args.username);
}
tx: gtx_transaction parameter is the exact GTX structure from above! ICCF just verifies it's authentic.
# chromia.yml — Enable your chain to receive and verify cross-chain proofs
blockchains:
my_dapp:
module: main
receiver:
module: receiver
moduleArgs:
receiver:
trusted_source_brid: x"YOUR_SOURCE_CHAIN_BRID"
config:
gtx:
modules:
- "net.postchain.d1.iccf.IccfGTXModule"
libs:
com.chromia.iccf:
version: 1.90.2
deployments:
testnet:
brid: x"6F1B061C633A992BF195850BF5AA1B6F887AEE01BB3F51251C230930FB792A92"
url: https://node0.testnet.chromia.com:7740
container: YOUR_CONTAINER_ID_HERE
chains:
my_dapp: x"YOUR_SOURCE_CHAIN_BRID"
receiver: x"YOUR_TARGET_CHAIN_BRID"
chr install — Downloads libraries to ./lib folder
chr library list — Shows all available library versions
chr keygen --dry — Generates a new key pair for signing transactions
mkdir frontend-test && cd frontend-test
npm init -y
npm pkg set type="module"
npm install postchain-client typescript ts-node @types/node
npx ts-node --esm frontend.ts
Production Patterns
Proven patterns for building robust dApps
FT4 Authentication Flow
1. Registration Strategy — Choose One
This guide covers Open and Import strategies. Paid strategies (fee, subscription) → official docs
Anyone can register accounts without fees — frontend auto-registers on first login
// main.rell
module;
import lib.ft4.accounts.strategies;
import lib.ft4.accounts.strategies.open;
# chromia.yml
blockchains:
my_dapp:
module: main
moduleArgs:
lib.ft4.core.accounts:
auth_descriptor:
# ⚠️ Default 10 fails fast! Each browser login = new Session AuthDescriptor
# 10 logins/day → limit reached. Mainnet dApps use 50-100
max_number_per_account: 50
# ...
libs:
com.chromia.ft4:
version: 1.1.1
com.chromia.iccf: # nice to have 😉
version: 1.90.2
Import existing accounts from a trusted chain — user must have an account there first
// main.rell
module;
import lib.ft4.accounts.strategies;
import lib.ft4.accounts.strategies.import_strategy;
# chromia.yml
blockchains:
my_dapp:
module: main
moduleArgs:
lib.ft4.core.accounts:
auth_descriptor:
# ⚠️ Default 10 fails fast! Each browser login = new Session AuthDescriptor
# 10 logins/day → limit reached. Mainnet dApps use 50-100
max_number_per_account: 50
lib.ft4.core.accounts.strategies.import_strategy:
trusted_chains:
- x"ECONOMY_CHAIN_BRID" # Find at: explorer.chromia.com/mainnet/blockchains
import_account_timeout: 30000
allow_any_operation: true
# ...
libs:
com.chromia.ft4:
version: 1.1.1
com.chromia.iccf: # nice to have 😉
version: 1.90.2
2. Protect Operations
import lib.ft4.auth;
import lib.ft4.accounts.{ account };
@extend(auth.auth_handler)
function () = auth.add_auth_handler(flags = []); // Enable auth
operation update_profile(new_name: text) {
val account = auth.authenticate(); // Throws if not authenticated
update profile @ { .account == account } ( name = new_name );
}
flags = ["T"] → Only "T" flag can call
flags = [] → Any authenticated account
Admin & Config Pattern
Protect sensitive operations with signature verification.
// 1. Define admin key in module_args
struct module_args {
admin_pubkey: byte_array;
}
// 2. Helper function to check admin signature
function require_admin() {
require(
op_context.is_signer(chain_context.args.admin_pubkey),
"Admin signature required"
);
}
// 3. Use in operations
operation set_config(config_key: text, value: text) {
require_admin();
// ... admin logic
}
Generate Admin Keypair
# Generate admin keypair (once per project)
chr keygen --file .chromia/admin.keypair
# → Copy the pubkey output and paste it into chromia.yml:
# admin_pubkey: x"YOUR_PUBKEY_HERE"
chromia.yml
blockchains:
my_dapp:
module: main
moduleArgs:
main:
admin_pubkey: x"YOUR_PUBKEY_HERE"
op_context.is_signer() → Check if pubkey signed the tx
chain_context.args → Access module_args values
Token Management with FT4
Create, mint, and transfer tokens using the FT4 asset standard.
val DECIMALS = 6;
val TEN_TOKENS = 10 * (10L).pow(DECIMALS); // = 10,000,000
module;
import lib.ft4.assets.{ asset, balance, Unsafe };
import lib.ft4.accounts.{ account };
import lib.ft4.auth;
// ============ ASSET ID HELPER ============
// FT4 standard: asset_id = (name, blockchain_rid).hash()
// More performant than symbol lookup (direct key vs index scan)
function get_asset_id(name) = (name, chain_context.blockchain_rid).hash();
// Register a new token (call via CLI after deployment)
operation create_token(name: text, symbol: text, decimals: integer, icon_url: text) {
require_admin(); // see Admin & Config tab
// Check if asset already exists
val existing = asset @? { .id == get_asset_id(name) };
if (existing != null) return;
Unsafe.register_asset(name, symbol, decimals, chain_context.blockchain_rid, icon_url);
}
// Mint tokens to an account
operation mint(account_id: byte_array, asset_name: text, amount: big_integer) {
require_admin();
require(amount > 0L, "Amount must be positive");
val account = account @ { .id == account_id };
val token = asset @ { .id == get_asset_id(asset_name) };
Unsafe.mint(account, token, amount);
}
// Transfer tokens
operation transfer(to_id: byte_array, asset_name: text, amount: big_integer) {
require(amount > 0L, "Amount must be positive");
val from_account = auth.authenticate(); // see Auth & Accounts tab
val to_account = account @ { .id == to_id };
val token = asset @ { .id == get_asset_id(asset_name) };
val user_balance = (balance @? { from_account, token }.amount) ?: 0L;
require(user_balance >= amount, "Insufficient balance");
Unsafe.transfer(from_account, to_account, token, amount);
}
// Check balance
query get_balance(account_id: byte_array, asset_name: text): big_integer {
val token = asset @ { .id == get_asset_id(asset_name) };
return (balance @? { .account.id == account_id, token }.amount) ?: 0L;
}
Before you can use
import lib.ft4.*, you must declare the library dependency:
libs:
com.chromia.ft4:
version: 1.1.1
com.chromia.iccf: # nice to have 😉
version: 1.90.2
Install Libraries & Register Asset
# Install dependencies defined in chromia.yml
# Downloads FT4 and other libs to ./lib folder — required before imports work!
chr install
# Register a new asset (see Admin & Config tab for keypair setup)
chr tx create_token MyToken MTK 6 https://example.com/icon.png \
--secret .chromia/admin.keypair --await
# Verify registration
chr query ft4.get_all_assets page_size=10 page_cursor=null
chr install = Download libs to ./lib
MyToken = Asset name
MTK = Symbol
6 = Decimals
Pagination Pattern
Handle large datasets efficiently. Used by all marketplace and social dApps.
struct page_result {
items: list<gtv>;
total: integer;
page: integer;
page_size: integer;
has_next: boolean;
}
query get_items_paginated(page: integer, page_size: integer): page_result {
require(page >= 0, "Page must be >= 0");
require(page_size > 0 and page_size <= 100, "Page size must be 1-100");
val total = item @* {} ( @sum 1 );
val skip = page * page_size;
val items = item @* {} (
@sort_desc .created_at,
$.to_gtv() // $ = entire entity
) limit page_size offset skip;
return page_result(
items = items,
total = total,
page = page,
page_size = page_size,
has_next = skip + items.size() < total
);
}
page = Current page number (0-indexed)
page_size = Results per page
offset = skip items
has_next = more pages available?
Complete Examples
Copy-paste ready dApps with backend, config & frontend
💡 Your Idea Here
Have an idea for a complete example? We'd love to add it!
🪙 Token System
Complete token system with minting, account handling, and transfers using FT4.
module;
import lib.ft4.auth;
import lib.ft4.accounts.strategies;
import lib.ft4.accounts.strategies.open;
import lib.ft4.accounts.{ account, create_account_with_auth, single_sig_auth_descriptor };
import lib.ft4.assets.{ asset, balance, Unsafe};
// ============ CONFIG ============
struct module_args {
admin_pubkey: byte_array;
}
// Asset config - name is fixed, asset_id derived from (name, blockchain_rid).hash()
// This is FT4 standard: Unsafe.register_asset() uses the same formula internally,
// so get_asset_id() always matches the registered asset's ID.
val ASSET_NAME = "MyToken";
val ASSET_SYMBOL = "MTK";
val ASSET_DECIMALS = 6; // 1 token = 1,000,000 smallest units
val ASSET_ICON = "https://example.com/icon.png";
function get_asset_id() = (ASSET_NAME, chain_context.blockchain_rid).hash();
// ════════════════════════════════════════════════════════════════════
// 💰 DECIMAL HANDLING - Two Scenarios
// ════════════════════════════════════════════════════════════════════
// With 6 decimals: 1 token = 1,000,000 smallest units
//
// 1️⃣ USER decides amount (transfers, trades):
// → Frontend converts BEFORE calling: 5.5 tokens = 5500000
// → Backend receives ready-to-use smallest units
//
// 2️⃣ BACKEND decides amount (rewards, bonuses, fees):
// → 10 tokens = 10 * (10L).pow(ASSET_DECIMALS) = 10,000,000
// → See claim_signup_bonus() below for example
// ════════════════════════════════════════════════════════════════════
// ============ AUTH SETUP ============
@extend(auth.auth_handler)
function () = auth.add_auth_handler(flags = ["T"]);
function require_admin() {
require(op_context.is_signer(chain_context.args.admin_pubkey), "Admin only");
}
// ============ OPERATIONS ============
// Optional: Register account via CLI (admin only) - our frontend doesn't need this
operation register_account(pubkey) {
require_admin();
val auth_desc = single_sig_auth_descriptor(pubkey, set(["A", "T"]));
create_account_with_auth(auth_desc);
}
// Initialize token (call once via CLI after deployment)
operation init_token() {
require_admin();
val existing = asset @? { .id == get_asset_id() };
if (existing != null) return; // Already exists
Unsafe.register_asset(ASSET_NAME, ASSET_SYMBOL, ASSET_DECIMALS,
chain_context.blockchain_rid, ASSET_ICON);
}
// Mint tokens (admin only) - amount in smallest units
operation mint_tokens(to_account_id: byte_array, amount: big_integer) {
require_admin();
require(amount > 0L, "Amount must be positive");
val token = asset @ { .id == get_asset_id() };
val to_account = account @ { .id == to_account_id };
Unsafe.mint(to_account, token, amount);
}
// Transfer tokens (authenticated users) - frontend only passes receiver + amount
operation transfer_tokens(to_account_id: byte_array, amount: big_integer) {
require(amount > 0L, "Amount must be positive");
val from_account = auth.authenticate();
val to_account = account @ { .id == to_account_id };
val token = asset @ { .id == get_asset_id() };
val user_balance = (balance @? { from_account, token }.amount) ?: 0L;
require(user_balance >= amount, "Insufficient balance");
Unsafe.transfer(from_account, to_account, token, amount);
}
// Signup bonus: new users get 10 tokens
operation claim_signup_bonus() {
val user = auth.authenticate();
val token = asset @ { .id == get_asset_id() };
// 10 tokens → must convert to smallest units
val BONUS_AMOUNT = 10; // human-readable
Unsafe.mint(user, token, BONUS_AMOUNT * (10L).pow(ASSET_DECIMALS));
}
// ============ QUERIES ============
// Get balance for an account (returns amount in smallest units)
// 💡 Optional for frontend! FT4 SDK has: session.account.getBalanceByAssetId(assetId)
query get_balance(account_id: byte_array): big_integer {
val token = asset @ { .id == get_asset_id() };
return (balance @? { .account.id == account_id, token }.amount) ?: 0L;
}
blockchains:
my_dapp:
module: main
moduleArgs:
main:
admin_pubkey: x"YOUR_ADMIN_PUBKEY_HERE"
lib.ft4.core.accounts:
auth_descriptor:
max_number_per_account: 50
compile:
rellVersion: 0.14.8
libs:
com.chromia.ft4:
version: 1.1.1
com.chromia.iccf:
version: 1.90.2
# ═══════════════════════════════════════════════════════════════
# 🌐 NETWORK FLAGS (add to tx/query commands as needed)
# --network testnet --blockchain <name> → for Testnet
# --network mainnet --blockchain <name> → for Mainnet
# (omit for local development)
# ═══════════════════════════════════════════════════════════════
# Install dependencies defined in chromia.yml
# Downloads FT4 and other libs to ./lib folder — required before imports work!
chr install
# Generate admin keypair (once per project)
chr keygen --file .chromia/admin.keypair
# → Copy the pubkey output and paste it into chromia.yml:
# admin_pubkey: x"YOUR_PUBKEY_HERE"
# Start local node (keep running in separate terminal)
chr node start
# Initialize the token (call once after deployment)
chr tx init_token --secret .chromia/admin.keypair --await
# ═══════════════════════════════════════════════════════════════
# 👤 ACCOUNT REGISTRATION (CLI method)
# ═══════════════════════════════════════════════════════════════
# For CLI/backend users: Generate keypair, compute account_id,
# then register via register_account operation.
# Generate user keypair
chr keygen --file .chromia/alice.keypair
# → outputs pubkey like: 02abc123def456...
# Compute account_id from pubkey using chr repl
chr repl -c 'x"02abc123def456...".hash()'
# → returns: x"7f3a8b..." (this is alice's account_id)
# Register account (admin only, for CLI/backend users)
# ⚠️ Pass the PUBKEY here, not the account_id!
# (account_id is derived internally via hash(pubkey))
chr tx register_account \
'x"02abc123def456..."' \
--secret .chromia/admin.keypair --await
# ═══════════════════════════════════════════════════════════════
# 🦊 BROWSER ALTERNATIVE (no CLI needed!)
# ═══════════════════════════════════════════════════════════════
# Frontend users connect via MetaMask using connect() from
# frontend.ts. The "open" strategy auto-registers accounts on
# first login — skip all the CLI steps above!
#
# How it works: account_id = hash(eth_address)
# The FT4 library handles this automatically
#
# Manual check (remove leading 0x from ETH address):
chr repl -c 'x"742d35Cc6634C0532925a3b844Bc454e4438f44e".hash()'
# → returns the account_id for that ETH address
# ═══════════════════════════════════════════════════════════════
# 💰 TOKEN OPERATIONS
# ═══════════════════════════════════════════════════════════════
# ⚠️ IMPORTANT: Amounts are in smallest units (like satoshis/wei)
# With 6 decimals: 1 token = 1,000,000 units
# So to mint 1 token → 1000000L, 100 tokens → 100000000L
# Mint tokens to an account
chr tx mint_tokens \
'x"ACCOUNT_ID_HERE"' \
1000000L \
--secret .chromia/admin.keypair --await
# ❌ "No records found"? → Use account_id, not pubkey! 😉
# Generate it: chr repl -c 'x"YOUR_PUBKEY".hash()'
# Check balance
chr query get_balance 'account_id=x"ACCOUNT_ID_HERE"'
// npm install postchain-client @chromia/ft4
import { createClient } from "postchain-client";
import {
Session, createKeyStoreInteractor, createWeb3ProviderEvmKeyStore,
createSingleSigAuthDescriptorRegistration, registerAccount,
registrationStrategy, createAmount
} from "@chromia/ft4";
declare global { interface Window { ethereum: any; } }
// State
let session: Session | undefined;
let client: any;
// ═══════════════════════════════════════════════════════════════════
// 🔧 CONFIG - Uncomment ONE environment at a time
// ═══════════════════════════════════════════════════════════════════
// 🏠 LOCAL DEVELOPMENT
const CONFIG = {
nodeUrlPool: ["http://localhost:7740"],
blockchainRid: "YOUR_LOCAL_BRID_HERE", // shown after `chr node start`
};
// 🧪 TESTNET
// const CONFIG = {
// nodeUrlPool: [
// "https://node0.testnet.chromia.com:7740",
// "https://node1.testnet.chromia.com:7740",
// "https://node2.testnet.chromia.com:7740",
// "https://node3.testnet.chromia.com:7740",
// ],
// blockchainRid: "YOUR_TESTNET_BRID_HERE",
// };
// 🚀 MAINNET
// const CONFIG = {
// nodeUrlPool: [
// "https://system.chromaway.com",
// "https://chromia-mainnet-systemnode-1.stakin-nodes.com",
// "https://chroma.node.monster:7741",
// "https://dapps0.chromaway.com",
// "https://chromia-mainnet.w3coins.io:7740",
// "https://mainnet-dapp1.sunube.net:7740",
// ],
// blockchainRid: "YOUR_MAINNET_BRID_HERE",
// };
// ═══════════════════════════════════════════════════════════════════
// 🔌 CONNECT - MetaMask login with auto-registration
// ═══════════════════════════════════════════════════════════════════
export async function connect(): Promise<Session> {
if (!window.ethereum) throw new Error("MetaMask not installed!");
client = await createClient(CONFIG);
const evmKeyStore = await createWeb3ProviderEvmKeyStore(window.ethereum);
const { getAccounts, getSession } = createKeyStoreInteractor(client, evmKeyStore);
const accounts = await getAccounts();
if (accounts.length > 0) {
// Existing account → get session
session = await getSession(accounts[0].id);
} else {
// New user → register account with "open" strategy
const authDesc = createSingleSigAuthDescriptorRegistration(["A", "T"], evmKeyStore.id);
session = (await registerAccount(client, evmKeyStore, registrationStrategy.open(authDesc))).session;
}
return session;
}
export const getSession = () => session;
export const isConnected = () => !!session;
// ═══════════════════════════════════════════════════════════════════
// 💰 DECIMAL HANDLING - Use createAmount()
// ═══════════════════════════════════════════════════════════════════
// Our token has 6 decimals → 1 token = 1,000,000 smallest units
// FT4 provides createAmount(value, decimals) for easy conversion!
//
// createAmount(0.5, 6) → Amount object for 0.5 tokens
// createAmount(100, 6) → Amount object for 100 tokens
//
// Pass the Amount object directly to FT4 functions.
const DECIMALS = 6;
// Example: Transfer 0.5 tokens
async function transferTokens(toAccountId: Buffer, humanAmount: number) {
if (!session) throw new Error("Not connected");
// createAmount converts human-readable to blockchain units
// .value extracts the bigint for session.call()
return session.call({
name: "transfer_tokens",
args: [toAccountId, createAmount(humanAmount, DECIMALS).value]
});
}
// Claim Signup Bonus
async function claimSignupBonus() {
if (!session) throw new Error("Not connected");
return session.call({
name: "claim_signup_bonus",
args: []
});
}
// ═══════════════════════════════════════════════════════════════════
// 📊 QUERIES - Use FT4 SDK methods
// ═══════════════════════════════════════════════════════════════════
// Get asset ID (single-token dApp: first asset is ours)
async function getAssetId(): Promise<Buffer | null> {
if (!session) return null;
const assets = await session.getAllAssets();
return assets.data[0]?.id || null;
}
// Get balance for current user (using FT4 SDK)
async function getBalance(): Promise<string> {
if (!session) return "0";
const assetId = await getAssetId();
if (!assetId) return "0";
const balance = await session.account.getBalanceByAssetId(assetId);
return balance?.amount.toString() || "0";
}
// Alternative: Call custom Rell query directly (bypasses FT4 SDK)
// Note: Queries are public, so we only need `client`, not `session`
async function getBalanceViaQuery(accountId: Buffer): Promise<bigint> {
if (!client) return 0n;
return client.query("get_balance", { account_id: accountId }).catch(() => 0n);
}
// Usage:
// const bal = await getBalance(); // → "5.5" (FT4 SDK)
// const rawBal = await getBalanceViaQuery( // → 5500000n (custom query)
// Buffer.from("account_id_hex", "hex")
// );
// await transferTokens(recipientId, 5.5); // send 5.5 tokens
💡 Another Idea Here
Got another dApp idea you'd like to see as a complete example?
Deploy to Production
From local dev to live on Chromia in 10 minutes
Generate Keys
Create a keypair for your deployment. Keep the private key safe!
# Generate a named keypair for testnet
chr keygen --key-id="mykey"
# Keys saved to:
# ~/.chromia/mykey (private - KEEP SECRET!)
# ~/.chromia/mykey.pubkey (public - share this)
Get Testnet Tokens (tCHR)
You need tCHR to lease a container. Get free tokens from the faucet.
Lease a Container
Containers are where your dApp runs. Lease one on the Chromia Vault.
- Connect your wallet (with tCHR)
- Click "Lease a new container"
- Paste your public key from
~/.chromia/mykey.pubkey - Sign the transaction
- Copy your Container ID (you'll need it!)
Configure chromia.yml
Add deployment settings to your project config.
blockchains:
my_dapp:
module: main
compile:
rellVersion: 0.14.8
# Add this for deployment:
deployments:
testnet:
brid: x"6F1B061C633A992BF195850BF5AA1B6F887AEE01BB3F51251C230930FB792A92"
url: https://node0.testnet.chromia.com:7740
container: YOUR_CONTAINER_ID_HERE # From step 3
Deploy!
Run the deployment command. Your dApp goes live!
# First deployment (use --key-id from Step 1)
chr deployment create --settings chromia.yml --network testnet --key-id mykey --blockchain my_dapp
# You'll get a Blockchain RID - save it!
# Example output:
# Deployment of blockchain my_dapp was successful
# Add the following to your project settings file:
# deployments:
# testnet:
# chains:
# my_dapp: x"ABC123..." ← This is your Blockchain RID
Update Your dApp
Made changes? Update your deployed dApp.
# First, add the blockchain RID to chromia.yml:
deployments:
testnet:
brid: x"6F1B061C633A992BF195850BF5AA1B6F887AEE01BB3F51251C230930FB792A92"
url: https://node0.testnet.chromia.com:7740
container: YOUR_CONTAINER_ID
chains:
my_dapp: x"YOUR_BLOCKCHAIN_RID" # Add this!
chr deployment update --network testnet --key-id mykey --blockchain my_dapp
Remove Chain (Schema Reset)
When entity changes are incompatible with existing data, you must remove and redeploy.
- Changing attribute types (e.g.,
integer→text) - Removing required attributes without defaults
- Adding/removing
@logannotation on existing entities - Fundamental schema restructuring
# Remove the deployed blockchain (PERMANENT - all data is lost!)
chr deployment remove --network testnet --key-id mykey --blockchain my_dapp -y
# Then redeploy with the new schema
chr deployment create --settings chromia.yml --network testnet --key-id mykey --blockchain my_dapp
- Adding attributes with default values
- Adding attributes to empty tables
- Removing attributes
- Changing mutability (non-
@logentities)
Deploy to Mainnet
Ready for production? Mainnet deployment is almost identical:
chr deployment ... --network mainnet
x"7E5BE539..."
https://system.chromaway.com
# Mainnet deployment config
deployments:
mainnet:
brid: x"7E5BE539EF62E48DDA7035867E67734A70833A69D2F162C457282C319AA58AE4"
url: https://system.chromaway.com
container: YOUR_MAINNET_CONTAINER_ID