🚀 Learn Rell in 15 Minutes

Build dApps
Ridiculously Fast

Rell is a relational blockchain language. Write SQL-like code, get a fully decentralized app. No complexity, just productivity.

Data Heavy dApps
$0 Gas Fees
Native Binance support
hello_world.rell
// 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

1

Prerequisites

Docker — must be installed and running

VS Code

Dev Containers Extension

2

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
3

Open in VS Code

Open my-dapp folder in VS Code

Click "Reopen in Container" when prompted

4

Run Local Node

chr node start
# DB conflicts after code changes? Wipe: chr node start --wipe

Your dApp runs on localhost:7740 🎉

5

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

Based on mainnet usage
integer 6.7k uses

64-bit signed whole numbers

val count: integer = 42;
IDscountsamounts
text 5.7k uses

UTF-8 encoded strings

val name: text = "Alice";
.size().split().upper_case()
byte_array 4.6k uses

Binary data (hashes, keys)

val hash: byte_array = x"ab12...";
.to_hex().sha256()x"..."
boolean 1.3k uses

True or false values

val active: boolean = true;
flagsconditionstoggles
big_integer 2.5k uses

Arbitrary precision integers — essential for token amounts

val amount: big_integer = 1000000L; // 1 token (6 decimals)
L suffixtoken amountsFT4 standard
decimal 1.4k uses

Fixed-point with 20 decimal places — exact math, not floating point!

val price: decimal = 123.456789; // Exact, no rounding errors
pricesratespercentages
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>
.add().get().size()
set<T>

Unordered, unique values

val unique = set([1, 2, 2]); // {1, 2}
.add().contains()no dupes
map<K, V>

Key-value pairs

val scores = ["alice": 100, "bob": 85];
.get().keys().values()
T?

Nullable types

val maybe: integer? = null;
?:?.!!

🔄 Control Flow

Ranked by mainnet usage
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
💡 11x more common than 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";
}
💡 Cleaner than long 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;
}
💡 32x less common than 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, ...);
}
💡 Almost as common as 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

🎯 When to use Dot (.) vs $ vs nothing?

The #1 source of errors for Rell beginners. Master these 4 rules:

FILTER { } Access entity attributes to filter results
// .attribute — access entity field in filter
user @ { .id == 42 }                    // ✓ .id reads the entity's id attribute
user @ { .name == "Alice" }             // ✓ .name reads the entity's name attribute

// When your variable has the SAME name as the attribute:
val account = get_account();
val asset = get_asset();
balance @? { .account == account, .asset == asset }  // ✓ Explicit (always works)
balance @? { account, asset }                        // ✓ Shorthand (same result)

Rule: Use .attr to read from entity. Shorthand { varname } only works when variable name = attribute name.

PROJECTION ( ) Select what to return from the query
// $ = the entire entity (like "this" or "self")
user @* {} ( $ )                        // Returns list of user entities
user @* {} ( $.to_struct() )            // Convert each entity to struct

// $.attribute = access attribute in projection
user @* {} ( $.name )                   // Returns list<text> — just names
user @* {} ( $.name, $.balance )        // Returns list<(text, integer)>

// .attribute also works in projection ($ is implicit)
user @* {} ( .name, .balance )          // Same as above — $ is optional here

Rule: $ = current entity. Use $.attr or .attr in projection ( ) to pick what you want returned.

CREATE Set values when creating — never use dot!
// Explicit assignment (always works)
create user(id = 1, name = "Alice");    // ✓ No dot — you're assigning values

// Shorthand when variable name matches attribute name
val name = "Alice";
val company = get_company();
create user(name, company);             // ✓ Shorthand — name→name, company→company

Rule: Never use . in create — you're writing to the entity, not reading from it.

UPDATE Filter uses dot, assignment doesn't
update user @ { .id == id } ( name = "Bob" );
//              ↑               ↑
//         Filter: READ     Assignment: WRITE
//         uses .id         no dot on name

Rule: { } = filter (use dot to read) · ( ) = new values (no dot, you're writing)

💡 Memory trick: Dot = "read FROM entity" · No dot = "write TO entity" · $ = "the whole entity"

🔗 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
🔧 function
Called by
Clients (frontend, external)
Operations, other functions
Can modify?
❌ Read-only
✅ Update, delete
Access
Public API
Internal only
query — returns serializable data
query get_user(id: integer) {
    return user @ { .id == id } ($.to_struct());
}

query get_all_users() = user @* {} (.username, .balance);
function — internal logic
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

Entity
user @* { .balance > 100 }

list<user>

DB reference — can update/delete

Tuple
user @* {} ( .username, .balance )

list<(text, integer)>

Copy of selected values

Struct
user @* {} ( $.to_struct() )

list<struct<user>>

Named type, serializable

🎯 Query Operators Cheatsheet

Click an operator to see an example

@ Exactly one result — throws if 0 or more than 1
// 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

.last_block_time Unix timestamp (milliseconds) of the last confirmed block. Use for all timestamps!
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 };
⚠️ When to use 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"
⚠️ Important: You must define 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
▶️ Run: chr repl -c 'print(rell.test.keypairs.alice);'
▶️ Run: 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.

📦 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
Queries auto-convert results → GTV → JSON Operations auto-convert JSON → GTV → Rell types Use .to_gtv().hash() for crypto operations

🔗 ICMF — Cross-Chain Messaging

Send events between blockchains in the same cluster. Uses emit_event internally.

Chain A (Sender)
// 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
    );
}
Chain B (Receiver)
// 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);
    }
}
💡 GTV: Generic Transfer Value — Chromia's serialization format. See GTV/GTX section above.
⚠️ Cluster only: ICMF = same cluster. Cross-cluster → use ICCF below.
# 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
▶️ Run: chr install — Downloads libraries to ./lib folder
▶️ Run: chr library list — Shows all available library versions
▶️ Run: chr node start — Starts both chains locally
▶️ Run: 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.

Chain A (Source) — Normal Op
// 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!
//       └──────────┘
Client Setup — Directory, Source & Target
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",
});
Frontend — Build & Submit Proof
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);
Chain B (Target) — Verifies Proof
// 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);
}
💡 Key insight: The 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"
▶️ Run: chr install — Downloads libraries to ./lib folder
▶️ Run: chr library list — Shows all available library versions
▶️ Run: chr keygen --dry — Generates a new key pair for signing transactions
▶️ Frontend Setup: Run the ICCF proof from TypeScript
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.

⚠️ Decimals: Amounts are in smallest units. With 6 decimals: 1 token = 1,000,000 units.
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;
}
📦 Required: Add FT4 library to chromia.yml
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
See Complete Examples for full chromia.yml configuration.

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

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

1

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)
2

Get Testnet Tokens (tCHR)

You need tCHR to lease a container. Get free tokens from the faucet.

Steps: Connect MetaMask → Request tCHR from faucet → Wait for confirmation
3

Lease a Container

Containers are where your dApp runs. Lease one on the Chromia Vault.

  1. Connect your wallet (with tCHR)
  2. Click "Lease a new container"
  3. Paste your public key from ~/.chromia/mykey.pubkey
  4. Sign the transaction
  5. Copy your Container ID (you'll need it!)
4

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
5

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
6

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
7

Remove Chain (Schema Reset)

When entity changes are incompatible with existing data, you must remove and redeploy.

⚠️ When is this needed?
  • Changing attribute types (e.g., integertext)
  • Removing required attributes without defaults
  • Adding/removing @log annotation 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
💡 Compatible changes (no remove needed):
  • Adding attributes with default values
  • Adding attributes to empty tables
  • Removing attributes
  • Changing mutability (non-@log entities)

Deploy to Mainnet

Ready for production? Mainnet deployment is almost identical:

Tokens: Real CHR instead of tCHR
CLI Flag: chr deployment ... --network mainnet
Directory BRID: x"7E5BE539..."
Node URL: https://system.chromaway.com
# Mainnet deployment config
deployments:
  mainnet:
    brid: x"7E5BE539EF62E48DDA7035867E67734A70833A69D2F162C457282C319AA58AE4"
    url: https://system.chromaway.com
    container: YOUR_MAINNET_CONTAINER_ID