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";
}