Skip to main content

Rell best practices

This topic shares real examples and best practices for building Rell dapps on Chromia, using actual implementations from the FT4 library.

Entity modeling best practices

Composite keys for uniqueness

Use composite keys to ensure uniqueness within specific contexts, which is crucial for distributed state integrity:

// FT4 balance entity demonstrates proper composite key usage
entity balance {
key accounts.account, asset; // Composite key ensures one balance per account+asset
mutable amount: big_integer;
}

Strategic indexing

Index fields used in cross-entity queries and @* operations, but avoid over-indexing as it impacts write performance:

// FT4 account entity with strategic indexing
entity account {
key id: byte_array;
index type: text; // For querying accounts by type
}

FT4 account integration patterns

Account registration strategy

Use account registration strategies for your dapp documentation, cookbook, course

Query patterns

Queries with pagination

FT4 demonstrates proper query patterns with pagination: documentation, example

Testing patterns

Testing error cases

function test_transfer_validation_must_fail() {
val sender = rell.test.keypairs.alice;

// Test invalid amount
val failure = rell.test.tx()
.op(transfer(
rell.test.keypairs.bob.pub,
get_test_asset_id(),
-1
))
.sign(sender)
.run_must_fail("Amount must be positive");

assert_true(failure.message.contains("Amount must be positive"));

// Test insufficient balance
val failure2 = rell.test.tx()
.op(transfer(
rell.test.keypairs.bob.pub,
get_test_asset_id(),
1000000
))
.sign(sender)
.run_must_fail("Insufficient balance");

assert_true(failure2.message.contains("Insufficient balance"));
}

Security patterns

Input validation

FT4 demonstrates thorough input validation:

function validate_asset_registration(
name: text,
symbol: text,
decimals: integer
): boolean {
// Validate name
require(name.size() >= 1, "Name cannot be empty");
require(name.size() <= 1024, "Name too long");

// Validate symbol
require(symbol.matches("^[A-Z0-9_]+$"),
"Symbol must contain only uppercase letters, numbers, and underscores");
require(symbol.size() <= 10, "Symbol too long");

// Validate decimals
require(decimals >= 0, "Decimals cannot be negative");
require(decimals <= 18, "Too many decimal places");

return true;
}

Error handling

FT4's approach to error handling:

function safe_get_balance(
account_id: byte_array,
asset_id: byte_array
): big_integer {
// Handle non-existent balance
val balance_record = balance @? {
.account.id == account_id,
.asset.id == asset_id
};

return if (balance_record != null) balance_record.amount else 0;
}

Performance optimization patterns

Efficient data structures

Use appropriate data structures for your use case:

// For frequent lookups - use indexed fields
entity user_session {
key session_id: text;
index account_id: byte_array; // Fast lookup by account
index expires_at: timestamp; // Fast cleanup of expired sessions
created_at: timestamp;
}

// For hierarchical data - use composite keys
entity post_comment {
key post_id: integer, comment_id: integer;
index author_account: byte_array;
parent_comment_id: integer?;
content: text;
created_at: timestamp;
}

Batch operations for efficiency

Process multiple items in single operations:

operation batch_transfer(
transfers: list<(
recipient: byte_array,
asset_id: byte_array,
amount: big_integer
)>
) {
val account = auth.authenticate();

// Validate all transfers first
for (transfer in transfers) {
require(transfer.amount > 0, "All amounts must be positive");
require(
transfer.recipient.size() == 32,
"Invalid recipient account ID"
);
}

// Group transfers by asset for efficient balance checking
val transfers_by_asset = map<byte_array, list<(recipient: byte_array, amount: big_integer)>>();

for (transfer in transfers) {
if (transfers_by_asset[transfer.asset_id] == null) {
transfers_by_asset[transfer.asset_id] = list<(recipient: byte_array, amount: big_integer)>();
}
transfers_by_asset[transfer.asset_id].add((
recipient = transfer.recipient,
amount = transfer.amount
));
}

for ((asset_id, asset_transfers) in transfers_by_asset) {
val total_amount = asset_transfers @ { } ( @sum .amount );
val asset = asset @ { .id == asset_id };
val sender_balance = balance @ {
.account.id == account.id,
.asset.id == asset_id
};
require(
sender_balance.amount >= total_amount,
"Insufficient balance for asset"
);

// Process all transfers for this asset
for (transfer in asset_transfers) {
transfer_internal(account.id, transfer.recipient, asset_id, transfer.amount);
}
}
}

Code formatting best practices

Consistent spacing and indentation

Follow consistent spacing patterns for better readability:

// ✅ Good formatting
entity user {
key account_id: byte_array;
index display_name: text;
created_at: timestamp;
}

operation create_user(account_id: byte_array, display_name: text) {
val existing_user = user @? { .account_id == account_id };
require(existing_user == null, "User already exists");

create user (
account_id = account_id,
display_name = display_name,
created_at = op_context.last_block_time
);
}

// ❌ Poor formatting
entity user{
key account_id:byte_array;
index display_name:text;
created_at:timestamp;
}

operation create_user(account_id:byte_array,display_name:text){
val existing_user=user@?{.account_id==account_id};
require(existing_user==null,"User already exists");
create user(account_id=account_id,display_name=display_name,created_at=op_context.last_block_time);
}

Function parameter formatting

Use consistent parameter formatting for better readability:

// ✅ Good - parameters on separate lines for complex functions
operation transfer_with_validation(
from_account: byte_array,
to_account: byte_array,
asset_id: byte_array,
amount: big_integer,
memo: text?
) {
// ... implementation
}

// ✅ Good - simple functions can be on one line
operation simple_update(account_id: byte_array, status: text) {
// ... implementation
}

Query formatting consistency

Structure queries for optimal readability:

// ✅ Good - clear query structure
query get_user_transactions(
account_id: byte_array,
limit: integer = 10
) = transfer_log @* {
.from_account == account_id or .to_account == account_id
} (
.tx_id,
.amount,
.created_at
) limit limit;

// ✅ Good - complex queries with proper line breaks
query get_user_activity_summary(
account_id: byte_array,
days: integer = 30
) {
val cutoff_time = op_context.last_block_time - days * 24 * 60 * 60 * 1000;

return (
sent_count = transfer_log @* {
.from_account == account_id,
.created_at >= cutoff_time
}.size(),
received_count = transfer_log @* {
.to_account == account_id,
.created_at >= cutoff_time
}.size()
);
}

Utility functions and helper patterns

Create reusable utility functions for common operations:

function validate_transfer_amount(
from_balance: big_integer,
transfer_amount: big_integer
): boolean {
require(from_balance >= 0, "Balance cannot be negative");
require(transfer_amount > 0, "Transfer amount must be positive");

return from_balance >= transfer_amount;
}

function require_account_exists(account_id: byte_array): account =
account @ { .id == account_id };

function get_account_balance(
account_id: byte_array,
asset_id: byte_array
): big_integer {
val balance_record = balance @? {
.account.id == account_id,
.asset.id == asset_id
};
return if (balance_record != null) balance_record.amount else 0;
}

function format_currency(amount: big_integer, decimals: integer = 2): text {
val divisor = 10 ** decimals;
val whole_part = amount / divisor;
val decimal_part = amount % divisor;
return "%d.%0*d".format(whole_part, decimals, decimal_part);
}

// Helper function for admin verification
function is_admin(account_id: byte_array): boolean {
// This should be implemented based on your specific admin system
// Example implementation:
val admin_account = account_metadata @? {
.account_id == account_id,
.display_name == "admin"
};
return admin_account != null;
}

Rate limiting for spam prevention

Use rate limiting.

Security best practices

Input validation patterns

Always validate inputs thoroughly:

function validate_account_id(account_id: byte_array): boolean {
require(account_id.size() == 32, "Account ID must be 32 bytes");
require(
account_id != x"0000000000000000000000000000000000000000000000000000000000000000",
"Account ID cannot be zero"
);
return true;
}

function validate_asset_id(asset_id: byte_array): boolean {
require(asset_id.size() == 32, "Asset ID must be 32 bytes");
require(
asset_id != x"0000000000000000000000000000000000000000000000000000000000000000",
"Asset ID cannot be zero"
);
return true;
}

function validate_amount_precision(
amount: big_integer,
decimals: integer
): boolean {
require(amount >= 0, "Amount cannot be negative");
require(decimals >= 0, "Decimals cannot be negative");
require(decimals <= 18, "Too many decimal places");
return true;
}

Documentation for dapp context

Document dapp-specific behaviors that aren't obvious from the code:

/**
* Creates account metadata for a user account
* IMPORTANT CONSIDERATIONS:
* - Creates immutable on-chain record
* - Gas costs scale with display name length due to indexing
* - Consider data size impact on network state
*/
operation create_account_metadata(
account_id: byte_array,
display_name: text
): text {
// Authentication should be handled through auth.authenticate() with proper auth handlers
val account = auth.authenticate();

// Verify the authenticated account matches the target account_id
require(account.id == account_id, "Can only create metadata for own account");

val existing_metadata = account_metadata @? { .account_id == account_id };
require(existing_metadata == null, "Account metadata already exists");

create account_metadata (
account_id = account_id,
display_name = display_name,
created_at = op_context.last_block_time,
last_active = op_context.last_block_time
);

return "Account metadata created successfully";
}