Operation
Operations are declared using the operation keyword, followed by a unique operation name and a list of parameters
enclosed in parentheses. The parameters specify the data that the operation expects as input.
Operations have the following characteristics:
- Can modify the data in the database
- Doesn't return a value
- Parameter types must be GTV-compatible
Example
operation create_user(name: text) {
create user(name = name);
}
Default parameter values
Operations can have parameters with default values, making them optional when called from the blockchain (external clients). This allows you to add new parameters without breaking existing client code.
Important: For operations, new parameters with default values must be added at the end of the parameter list.
operation create_user(name: text, age: integer = 0) {
create user(name = name, age = age);
}
In this example, age has a default value of 0, so it's optional when calling the operation from the blockchain.
Clients can call create_user with just the name parameter, and age will default to 0.
Illustrative example: registering a channel
Make an operation that allows a user to create a new channel:
operation register_channel (user_pubkey: pubkey, channel_name: name) {
require( op_context.is_signer(user_pubkey) );
create channel (
owner = user @ {.pubkey == user_pubkey},
name = channel_name
);
}
You can go through this line by line. First we declare the operation name and a list of parameters:
operation register_channel (user_pubkey: pubkey, channel_name: name) {
It is very similar to function definitions in other languages. An operation is a function of a special kind: you can
invoke it by using a blockchain transaction by its name. When invoking register_channel, the caller must provide two
arguments of specified types. Otherwise, it fails.
require( op_context.is_signer(user_pubkey) );
We don't want Alice to be able to pull a prank on Bob by registering a channel with a silly name on his behalf. Thus we
need to ensure that the transaction gets signed with a key corresponding to the public key specified in the first
parameter. (In other words, if Bob's public key is user_pubkey, the transaction must also get signed by Bob. That's,
Bob is a signer of this transaction.) it's a typical pattern in Rell -- typically, you specify an actor in an operation
parameter. In the body of the operation, you verify that the actor was the signer. require fails the operation if the
specified condition isn't met.
create channel creates a persistent object channel. You don't need to explicitly store it, as all created objects
persist if the operation succeeds.
user @ {.pubkey=user_pubkey} -- now we retrieve a user object by its pubkey, which should be unique. If no such user
exists, the operation fails. We don't need to test for that explicitly, as @ operator does this job.
Rell can automatically find attribute names corresponding to arguments using types. As user and name are different
types, you can write create channel as follows:
create channel (user @ {.pubkey == user_pubkey}, channel_name);
Guard blocks
Guard blocks are used in operations to specify read-only argument verification code. They allow you to separate parameter verification from the rest of the operation's code, making the verification logic clearer and enabling it to be executed independently during transaction validation.
Syntax
A guard block is declared using the guard keyword followed by a block of code:
operation foo(name: text) {
guard {
val u = user @ { name };
require(is_admin(u));
}
// ... rest of operation code
}
The guard block contains verification logic that checks whether the operation's arguments are valid before the operation executes. Note that variables can be declared and assigned within the guard block itself, as shown above.
Validation and execution
When a Postchain node receives a transaction, it validates the transaction before executing it. Guard blocks are executed during both the validation step and the execution step:
- Validation step (
checkCorrectness()): Nodes may not write to the database during validation, but they may read from it. Guard blocks run during this step to verify arguments. - Execution step (
apply()): The full operation, including the guard block, runs again during execution.
This dual execution means that any log() or print() calls (or similar) that occur in the guard block will execute
twice, resulting in duplicate logged or printed lines.
Restrictions
Guard blocks have several important restrictions:
Variable declarations before guard
Only variable declarations are allowed to occur in an operation before a guard block. Variable assignment is not allowed before the guard.
Legal example:
operation foo(y: integer) {
val x: integer; // ✅ Declaration is allowed
guard {
require(y > 0);
}
x = y + 1; // Assignment happens after guard
// ... rest of operation
}
Illegal example:
operation foo(y: integer) {
val x = y + 1; // ❌ Assignment before guard is not allowed
guard {
require(y > 0);
}
// ... rest of operation
}
Database modifications
Guard blocks are forbidden from making database modifications, since they need to run during validation when database writes are not permitted. However, guard blocks may query the database.
Event emission
The op_context struct is accessible within the guard block during validation, but the op_context.emit_event() method
is not allowed and will result in a runtime error during validation.
Example: Using guard blocks
Here's a practical example that demonstrates how guard blocks separate verification logic from operation implementation:
operation transfer_tokens(from_pubkey: pubkey, to_pubkey: pubkey, amount: integer) {
guard {
val sender = user @ { .pubkey == from_pubkey };
val recipient = user @ { .pubkey == to_pubkey };
require(op_context.is_signer(from_pubkey));
require(sender.balance >= amount);
require(amount > 0);
}
// Verification passed, now perform the transfer
val sender = user @ { .pubkey == from_pubkey };
val recipient = user @ { .pubkey == to_pubkey };
update sender ( balance = sender.balance - amount );
update recipient ( balance = recipient.balance + amount );
}
In this example, the guard block:
- Queries the database to retrieve the sender and recipient users
- Verifies that the transaction is signed by the sender
- Checks that the sender has sufficient balance
- Ensures the transfer amount is positive
All verification logic is clearly separated in the guard block, making the operation's intent and requirements explicit. Note that variables needed for verification are declared and assigned within the guard block, and then queried again after the guard for use in the operation body.