Building a Prediction Market on Chromia | Step 3 — Creating Events and Writing Your First Operation
Today we’ll create our first entity, learn how to create events, write a simple query to fetch all events, and of course, test everything to make sure it actually works. Let’s start with the basics. We’ll define our first entity, which is essentially just a SQL table. In it, we need to specify the field names and their types. The first entity will be called event. It will store all the core info about each event: the account of the user who created it, the question text, creation time, expiration time, a flag that shows whether the event is closed, and the result. I want to directly link each event to its creator’s account. For that, we’ll use the type ft4 account, which represents a user in the FT4 system. We import it from the FT4 library in oddly/module.rell like this: module; import lib.ft4.core.accounts.{ ft4_account: account }; The created_at field represents the timestamp of when the event was created. In Rell, the recommended type for this is timestamp. It stores time in milliseconds since the UNIX epoch, which is the standard way to handle time in blockchain environments. This makes it easy to sort, filter, and compare events by time. We don’t want to pass this value manually every time, so we’ll use a function that automatically sets the current time during creation. I’ll show you how that function looks a bit later. The is_closed field is a boolean that tells us whether the event has already ended. By default, we’ll set it to false. The expires_at field is another timestamp, which defines the expiration time for the event. The user will provide the number of days until expiration, and we’ll calculate the exact timestamp from that. As for the result field, we’ll need something more flexible. We’ll define an enum to represent the possible outcomes of the event: yes, no, or unresolved. To do that, we create a file called oddly/enums.rell and drop in the following code: enum result_values { YES, NO, UNRESOLVED } UNRESOLVED is the default value. It means the event is still open and the result hasn’t been decided yet. Now let’s create the entities.rell file where we’ll define our first entity. It’s going to look something like this: entity event { creator: ft4_account; question: text; created_at: timestamp = utils.last_known_time(); is_closed: boolean = false; expires_at: timestamp; result: result_values = result_values.UNRESOLVED; } Boom. The entity is ready. Now let’s move on to operations. Inside the oddly directory, we create a new file called operations.rell. And to start things off, we’ll write a basic stub for our operation. It will take two parameters: the question text (question) and the number of days until the event expires (expires_in_days): operation create_event(question: text, expires_in_days: integer) { } Before we can actually create the event, we need to know who is creating it. That’s where the authenticate function from the FT4 library comes in. But for everything to work properly, we need to do three things: Import auth from FT4. Add an auth_handler that defines what permissions a user needs to perform certain actions (in our case, creating an event). Call auth.authenticate() inside the operation to extract the account of the user who triggered it. In module.rell we add: module; import lib.ft4.core.accounts.{ ft4_account: account }; import lib.ft4.core.auth; @extend(auth.auth_handler) function () = auth.add_auth_handler( flags = ["T"] ); And in operations.rell: operation create_event(question: text, expires_in_days: integer) { val account = auth.authenticate(); } Now we know exactly who’s calling the operation, and we can safely link the event to its creator. Next up is validation. First, we want to make sure the question isn’t too short or too long. Second, we’ll check the expiration period — it needs to be a positive number and within a sensible range. A year sounds like a decent cap for now, but we can adjust it later. So let’s set up the basics. Inside the oddly directory, create a subfolder called utils. In there, as usual, start with module.rell and keep it simple: module; Now add a new file called constants.rell, where we’ll define all the validation limits. This way, if you ever need to tweak the rules, you won’t be hunting through scattered code — just update them here: val MIN_EXPIRATION_DAYS = 1; val MAX_EXPIRATION_DAYS = 365; val MIN_QUESTION_LENGTH = 10; val MAX_QUESTION_LENGTH = 280; We’ll also need two helper functions for handling time: one to determine when the event was created, and one to calculate when it should expire. Sure, we could use something like System.currentTimeMillis() — but that’s not how things work on a blockchain. Nodes don’t share a synchronized clock, and the only timestamp you can truly rely on is the one written into the block itself. So we create a new file called time.rell inside utils, and define two functions

Today we’ll create our first entity, learn how to create events, write a simple query to fetch all events, and of course, test everything to make sure it actually works.
Let’s start with the basics. We’ll define our first entity
, which is essentially just a SQL table. In it, we need to specify the field names and their types. The first entity
will be called event. It will store all the core info about each event: the account of the user who created it, the question text, creation time, expiration time, a flag that shows whether the event is closed, and the result. I want to directly link each event to its creator’s account. For that, we’ll use the type ft4 account
, which represents a user in the FT4 system. We import it from the FT4 library in oddly/module.rell
like this:
module;
import lib.ft4.core.accounts.{ ft4_account: account };
The created_at
field represents the timestamp
of when the event was created. In Rell, the recommended type for this is timestamp
. It stores time in milliseconds since the UNIX epoch, which is the standard way to handle time in blockchain environments. This makes it easy to sort, filter, and compare events by time. We don’t want to pass this value manually every time, so we’ll use a function that automatically sets the current time during creation. I’ll show you how that function looks a bit later.
The is_closed
field is a boolean that tells us whether the event has already ended. By default, we’ll set it to false
.
The expires_at
field is another timestamp
, which defines the expiration time for the event. The user will provide the number of days until expiration, and we’ll calculate the exact timestamp
from that.
As for the result
field, we’ll need something more flexible. We’ll define an enum
to represent the possible outcomes of the event: yes, no, or unresolved. To do that, we create a file called oddly/enums.rell
and drop in the following code:
enum result_values {
YES,
NO,
UNRESOLVED
}
UNRESOLVED
is the default value. It means the event is still open and the result hasn’t been decided yet.
Now let’s create the entities.rell
file where we’ll define our first entity. It’s going to look something like this:
entity event {
creator: ft4_account;
question: text;
created_at: timestamp = utils.last_known_time();
is_closed: boolean = false;
expires_at: timestamp;
result: result_values = result_values.UNRESOLVED;
}
Boom. The entity is ready. Now let’s move on to operations. Inside the oddly
directory, we create a new file called operations.rell
. And to start things off, we’ll write a basic stub for our operation. It will take two parameters: the question text (question
) and the number of days until the event expires (expires_in_days
):
operation create_event(question: text, expires_in_days: integer) {
}
Before we can actually create the event, we need to know who is creating it. That’s where the authenticate function from the FT4 library comes in. But for everything to work properly, we need to do three things:
- Import
auth
from FT4. - Add an
auth_handler
that defines what permissions a user needs to perform certain actions (in our case, creating an event). - Call
auth.authenticate()
inside the operation to extract the account of the user who triggered it.
In module.rell
we add:
module;
import lib.ft4.core.accounts.{ ft4_account: account };
import lib.ft4.core.auth;
@extend(auth.auth_handler)
function () = auth.add_auth_handler(
flags = ["T"]
);
And in operations.rell
:
operation create_event(question: text, expires_in_days: integer) {
val account = auth.authenticate();
}
Now we know exactly who’s calling the operation, and we can safely link the event to its creator.
Next up is validation. First, we want to make sure the question isn’t too short or too long. Second, we’ll check the expiration period — it needs to be a positive number and within a sensible range. A year sounds like a decent cap for now, but we can adjust it later.
So let’s set up the basics. Inside the oddly
directory, create a subfolder called utils
. In there, as usual, start with module.rell
and keep it simple:
module;
Now add a new file called constants.rell
, where we’ll define all the validation limits. This way, if you ever need to tweak the rules, you won’t be hunting through scattered code — just update them here:
val MIN_EXPIRATION_DAYS = 1;
val MAX_EXPIRATION_DAYS = 365;
val MIN_QUESTION_LENGTH = 10;
val MAX_QUESTION_LENGTH = 280;
We’ll also need two helper functions for handling time: one to determine when the event was created, and one to calculate when it should expire. Sure, we could use something like System.currentTimeMillis()
— but that’s not how things work on a blockchain. Nodes don’t share a synchronized clock, and the only timestamp
you can truly rely on is the one written into the block itself.
So we create a new file called time.rell
inside utils, and define two functions there:
function last_known_time() = if (op_context.exists) op_context.last_block_time else block @ {} (@max .timestamp) ?: 0;
function days_from_now(days: integer) {
return last_known_time() + (days * 24 * 60 * 60 * 1000);
}
-
last_known_time()
is a solid all-purpose helper. It returns the most recent knowntimestamp
in the blockchain. During an operation, it usesop_context.last_block_time;
otherwise, it fetches the maximumtimestamp
from existing blocks. If no blocks exist yet (e.g., during initialization), it returns 0. This provides a safe and consistent way to get a reliabletimestamp
in any context. -
days_from_now(days)
takes the current time and addsn
days to it, returning the result in milliseconds. Perfect for setting expiration dates.
Now don’t forget to import utils in oddly/module.rell
:
import .utils;
And now let’s get back to our operation and drop in those require checks to make sure the input values stay within bounds.
operation create_event(question: text, expires_in_days: integer) {
val account = auth.authenticate();
require(
question.size() >= utils.MIN_QUESTION_LENGTH and question.size() <= utils.MAX_QUESTION_LENGTH,
"Question must be between 10 and 280 characters."
);
require(
expires_in_days > utils.MIN_EXPIRATION_DAYS and expires_in_days < utils.MAX_EXPIRATION_DAYS,
"Expiration period must be between 1 and 365 days."
);
}
Next comes the time logic. We need to figure out exactly when the event is supposed to expire. Thanks to the days_from_now(expires_in_days)
function we just built in utils, we can easily calculate a future timestamp
by adding the number of days to the current block time. The result is a millisecond-based timestamp
, which we’ll store in the expires_at
field.
On the frontend, we’ll later convert it into a nice human-readable format like “May 20, 2025 at 1:00 PM”.
Now we pass everything to the function and here’s the final version of our operation:
operation create_event(question: text, expires_in_days: integer) {
val account = auth.authenticate();
require(
question.size() >= utils.MIN_QUESTION_LENGTH and question.size() <= utils.MAX_QUESTION_LENGTH,
"Question must be between 10 and 280 characters."
);
require(
expires_in_days > utils.MIN_EXPIRATION_DAYS and expires_in_days < utils.MAX_EXPIRATION_DAYS,
"Expiration period must be between 1 and 365 days."
);
val expires_at = utils.days_from_now(expires_in_days);
create event ( creator = account, question, expires_at );
}
The final touch for today is to add a simple query that returns all events as they are. Later on, we’ll definitely enhance it with pagination, sorting, filtering by status (active, closed), and maybe even keyword search. But for now, the goal is just to fetch a full list of events for debugging.
So in oddly/queries.rell
, let’s add:
query get_events(): list {
return event @* { };
}
Alright, let’s jump into testing — because without tests, we can’t really be sure that anything works the way it’s supposed to. First, in development.rell
, fix the existing import and, for now, bring in everything we’ve created:
import oddly.*;
Then in tests/module.rell
, add the ft_auth_operation_for
import from FT4 to handle authentication inside the tests. The whole file should look like this:
@test module;
import development.*;
import lib.ft4.test.core.{ create_auth_descriptor, ft_auth_operation_for };
import lib.ft4.external.accounts.{ get_account_by_id };
Awesome. Below is a test suite that clearly checks whether everything works the way we intended. One positive case and four negative ones. The main point here is that we don’t just throw some data into create_event
— we walk through the full flow: register an account, authenticate it, run the operation, and then make sure the result matches what we expect. One thing to highlight — notice how we always call ft_auth_operation_for
before invoking create_event
. Without that, the auth.authenticate()
call in your operation just won’t work.
function test_create_event() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
rell.test.tx()
.op(open_strategy.ras_open(auth_descriptor))
.op(strategies.register_account())
.sign(alice)
.run();
rell.test.tx()
.op(ft_auth_operation_for(alice.pub))
.op(create_event("Will CHR be $10 in a week?", 7))
.sign(alice)
.run();
val events = get_events();
assert_equals(events[0].creator.id, alice.pub.hash());
assert_equals(events[0].question, "Will CHR be $10 in a week?");
assert_true(events[0].created_at > 0);
assert_equals(events[0].is_closed, false);
assert_true(events[0].expires_at > events[0].created_at);
assert_equals(events[0].result, result_values.UNRESOLVED);
}
function test_create_event_too_short_question_must_fail() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
rell.test.tx()
.op(open_strategy.ras_open(auth_descriptor))
.op(strategies.register_account())
.sign(alice)
.run();
rell.test.tx()
.op(ft_auth_operation_for(alice.pub))
.op(create_event("Too short", 7))
.sign(alice)
.run_must_fail("Question must be between 10 and 280 characters.");
}
function test_create_event_too_long_question_must_fail() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
rell.test.tx()
.op(open_strategy.ras_open(auth_descriptor))
.op(strategies.register_account())
.sign(alice)
.run();
val long_question = "A".repeat(300);
rell.test.tx()
.op(ft_auth_operation_for(alice.pub))
.op(create_event(long_question, 7))
.sign(alice)
.run_must_fail("Question must be between 10 and 280 characters.");
}
function test_create_event_exceeds_max_expiration_must_fail() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
rell.test.tx()
.op(open_strategy.ras_open(auth_descriptor))
.op(strategies.register_account())
.sign(alice)
.run();
rell.test.tx()
.op(ft_auth_operation_for(alice.pub))
.op(create_event("Will CHR be $100?", 400))
.sign(alice)
.run_must_fail("Expiration period must be between 1 and 365 days.");
}
function test_create_event_zero_days_must_fail() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
rell.test.tx()
.op(open_strategy.ras_open(auth_descriptor))
.op(strategies.register_account())
.sign(alice)
.run();
rell.test.tx()
.op(ft_auth_operation_for(alice.pub))
.op(create_event("Will CHR reach $1 today?", 0))
.sign(alice)
.run_must_fail("Expiration period must be between 1 and 365 days.");
}
Nice, that’s a wrap — our basic Prediction Market just took its first breath. Here’s what we accomplished today:
✅ created the first entity
✅ implemented the event creation logic
✅ added validation for user input
✅ wrote a basic query to fetch all events
✅ covered it all with proper unit tests
Next time we’ll:
- refactor the tests and clean up duplicate logic
- add a new entity called bet where users can place predictions
- expand
get_events
to include filtering
Let’s keep building.