Today, we’ll start with a small fix to the event
entity and expand it a bit. Let's add the mutable
annotation to the is_closed
and result
fields so that we can update them later. Also we’re introducing a new field called status
with three possible values: PENDING
, APPROVED
, and REJECTED
. This is our way of adding moderation, so we don’t let unwanted content slip into the system. And finally, we’re adding the ability to configure the bet amount.
Here’s what the updated event looks like in oddly/entities.rell
:
entity event {
creator: ft4_account;
question: text;
created_at: timestamp = utils.last_known_time();
mutable is_closed: boolean = false;
expires_at: timestamp;
mutable result: result_values = result_values.UNRESOLVED;
mutable status: event_status = event_status.PENDING;
bet_amount: integer;
}
Let’s update the create_event
operation. But first, let’s clean things up a bit. Right now all the required checks are sitting directly inside the operation, and it looks a little overloaded. So we’re going to move those validations into a separate file to keep things neat and readable. Create a new file at oddly/requirements.rell
and drop in the following functions:
function validate_question_length(question: text) {
require(
question.size() in range(utils.MIN_QUESTION_LENGTH, utils.MAX_QUESTION_LENGTH),
"Question must be between 10 and 280 characters."
);
}
function validate_expiration_period(expires_in_days: integer) {
require(
expires_in_days in range(utils.MIN_EXPIRATION_DAYS, utils.MAX_EXPIRATION_DAYS),
"Expiration period must be between 1 and 365 days."
);
}
function validate_bet_amount(bet_amount: integer) {
require(
bet_amount in range(utils.MIN_BET_AMOUNT, utils.MAX_BET_AMOUNT),
"Bet amount must be between 1 and 1000."
);
}
We simplified them a bit by using range()
instead of plain comparison operators. And don’t forget to update utils/constants.rell
:
val MIN_EXPIRATION_DAYS = 1;
val MAX_EXPIRATION_DAYS = 366;
val MIN_QUESTION_LENGTH = 10;
val MAX_QUESTION_LENGTH = 281;
val MIN_BET_AMOUNT = 1;
val MAX_BET_AMOUNT = 1001;
Now each validation lives in its own neat little place. Next up, let’s update the operation itself:
operation create_event(question: text, expires_in_days: integer, bet_amount: integer) {
val account = auth.authenticate();
validate_question_length(question);
validate_expiration_period(expires_in_days);
validate_bet_amount(bet_amount);
val expires_at = utils.days_from_now(expires_in_days);
create event ( creator = account, question, expires_at, bet_amount );
}
I also want to add a new value to result_values
— VOID
. This will come in handy when an event can’t be resolved for some reason. Maybe the event never happened, the question turned out to be invalid, or there’s simply not enough information to decide the outcome. So let’s update our result_values
enum in oddly/enums
like this:
enum result_values {
YES,
NO,
UNRESOLVED,
VOID
}
Now we just need to tweak the tests. We’ll update the create_event
call to include the bet amount, and in test_create_event
, we’ll throw in two extra checks, one for the event status and one for the bet amount. It should look like this:
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, 10))
.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);
assert_equals(events[0].status, event_status.PENDING);
assert_equals(events[0].bet_amount, 10);
}
Let’s pause here for now with adding new features to the events, so we don’t overload the MVP. Honestly, I’ve got tons of ideas and features I want to bring to life in this dapp. But the main goal right now is to ship a minimal but functional version. We’ll keep building and leveling it up from there until we shoot it into orbit 🚀
So for now:
- the bet amount will be static
- each account can vote only once per event
- the result will be resolved manually by an admin
Alright, time to add an admin-only operation that lets moderators review and approve (or reject) events. In general, any admin operation brings a bit of the centralized world into our beautifully decentralized one, so in the future, we’ll definitely need to fix that. In oddly/module.rell
we import the admin module like this:
import lib.ft4.core.admin;
Next, we create a new operation inside oddly/operations.rell
. First things first, let’s make sure only an admin can call it:
operation moderate_event(event, event_status) {
admin.require_admin();
}
And really, that’s all we need this operation to do, just update the event’s status. Nice and simple:
operation moderate_event(event, event_status) {
admin.require_admin();
event.status = event_status;
}
Maybe it would make sense to add extra checks here, like blocking re-moderation or preventing status changes from REJECTED
to APPROVED
. But for now, let’s keep things simple and just trust the admins 🙂
Time to test. First, we add the admin public key
we generated earlier to the blockchain config. And for testing, we’ll generate a separate key just for that. Run the following command:
chr keygen --file .chromia/admin_test_keypair
Make sure the project structure looks exactly like this:
Then update chromia.yml
so the system knows who the admin is in production and who gets admin powers during tests:
blockchains:
oddly_development:
module: development
moduleArgs:
lib.ft4.core.admin:
admin_pubkey: x"030E7FE665AA55349A9503DD0DB8FA6CB2C1267A50A9F50A60A1EA34D4571FCDE3"
compile:
rellVersion: 0.14.5
database:
schema: schema_oddly
test:
modules:
- tests
moduleArgs:
lib.ft4.core.admin:
admin_pubkey: x"035A93A7DDF932BB4742CE678D330BC1A586C442A1B6FE3244AA5209F311AF8C16"
libs:
ft4:
registry: https://gitlab.com/chromaway/ft4-lib.git
path: rell/src/lib/ft4
tagOrBranch: v1.1.0r
rid: x"FEEB0633698E7650D29DCCFE2996AD57CDC70AA3BDF770365C3D442D9DFC2A5E"
insecure: false
iccf:
registry: https://gitlab.com/chromaway/core/directory-chain
path: src/lib/iccf
tagOrBranch: 1.87.0
rid: x"9C359787B75927733034EA1CEE74EEC8829D2907E4FC94790B5E9ABE4396575D"
insecure: false
Next, in tests/development_tests
, create a file called helpers.rell
and add our test admin key there:
function admin_keypair() = rell.test.keypair(
priv = x"820D5301627C33AF91C3BCBCE8BC32D36DF7BBE232FC76AF309AE2A2A36F97FA",
pub = x"035A93A7DDF932BB4742CE678D330BC1A586C442A1B6FE3244AA5209F311AF8C16"
);
Great. The admin is in place. Now we can move on to testing event moderation. We’ve already duplicated quite a bit of code in our tests, so it’s time to clean things up. Let’s start with a quick refactor. First, in development_tests/module.rell
add the following import:
import core_accounts: lib.ft4.core.accounts;
Now in development_tests/helpers.rell
let’s extract all the repetitive pieces that keep popping up in every test. We’ll start with account creation:
function register_account(user_kp: rell.test.keypair, auth_descriptor: core_accounts.auth_descriptor) {
rell.test.tx()
.op(open_strategy.ras_open(auth_descriptor))
.op(strategies.register_account())
.sign(user_kp)
.run();
}
Next, let’s prepare some structs to help us pass parameters around more easily. In some of our helper functions, the number of parameters is getting out of hand, and passing them one by one would be painful. Structs to the rescue:
struct create_event_struct {
user_kp: rell.test.keypair;
question: text;
expires_in_days: integer;
bet_amount: integer;
}
struct create_event_must_fail_struct {
user_kp: rell.test.keypair;
question: text;
expires_in_days: integer;
bet_amount: integer;
error_message: text;
}
And now, the functions themselves:
function create_test_event(params: create_event_struct) {
rell.test.tx()
.op(ft_auth_operation_for(params.user_kp.pub))
.op(create_event(params.question, params.expires_in_days, params.bet_amount))
.sign(params.user_kp)
.run();
return get_events()[0];
}
function create_test_event_must_fail(params: create_event_must_fail_struct) {
rell.test.tx()
.op(ft_auth_operation_for(params.user_kp.pub))
.op(create_event(params.question, params.expires_in_days, params.bet_amount))
.sign(params.user_kp)
.run_must_fail(params.error_message);
}
function create_test_event_and_moderate(event: event, status: event_status) {
rell.test.tx()
.op(moderate_event(event, status))
.sign(admin_keypair())
.run();
}
Take a look at the create_test_event
and create_test_event_must_fail
functions. Since both take more than three parameters, we’re using a struct
to pass them around. It’s way more convenient than stuffing a bunch of arguments into every call.
Now in the main test file, we replace all the direct calls with these helper functions and throw in one more test for event moderation. Let’s go.
function test_create_event() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
register_account(alice, auth_descriptor);
val event = create_test_event(
create_event_struct(
user_kp = alice,
question = "Will CHR be $10 in a week?",
expires_in_days = 7,
bet_amount = 10
)
);
assert_equals(event.creator.id, alice.pub.hash());
assert_equals(event.question, "Will CHR be $10 in a week?");
assert_true(event.created_at > 0);
assert_equals(event.is_closed, false);
assert_true(event.expires_at > event.created_at);
assert_equals(event.result, result_values.UNRESOLVED);
assert_equals(event.status, event_status.PENDING);
}
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());
register_account(alice, auth_descriptor);
create_test_event_must_fail(
create_event_must_fail_struct(
user_kp = alice,
question = "Too short",
expires_in_days = 7,
bet_amount = 10,
error_message = "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());
register_account(alice, auth_descriptor);
val long_question = "A".repeat(300);
create_test_event_must_fail(
create_event_must_fail_struct(
user_kp = alice,
question = long_question,
expires_in_days = 7,
bet_amount = 10,
error_message = "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());
register_account(alice, auth_descriptor);
create_test_event_must_fail(
create_event_must_fail_struct(
user_kp = alice,
question = "Will CHR be $100?",
expires_in_days = 400,
bet_amount = 10,
error_message = "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());
register_account(alice, auth_descriptor);
create_test_event_must_fail(
create_event_must_fail_struct(
user_kp = alice,
question = "Will CHR reach $1 today?",
expires_in_days = 0,
bet_amount = 10,
error_message = "Expiration period must be between 1 and 365 days."
)
);
}
function test_create_event_and_moderate() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
register_account(alice, auth_descriptor);
var event = create_test_event(
create_event_struct(
user_kp = alice,
question = "Will CHR be $10 in a week?",
expires_in_days = 7,
bet_amount = 10
)
);
create_test_event_and_moderate(event, event_status.APPROVED);
event = get_events()[0];
assert_equals(event.status, event_status.APPROVED);
}
Let’s run the tests and boom — everything works:
TEST RESULTS:
OK tests.development_tests:test_account_creation (0.673s)
OK tests.development_tests:test_create_event (0.179s)
OK tests.development_tests:test_create_event_too_short_question_must_fail (0.126s)
OK tests.development_tests:test_create_event_too_long_question_must_fail (0.113s)
OK tests.development_tests:test_create_event_exceeds_max_expiration_must_fail (0.117s)
OK tests.development_tests:test_create_event_zero_days_must_fail (0.111s)
OK tests.development_tests:test_create_event_and_moderate (0.120s)
SUMMARY: 0 FAILED / 7 PASSED / 7 TOTAL (1.442s)
Now we’re moving on to betting functionality. As always, let’s start with the entity. We’re adding a new one called bet:
entity bet {
key event, better: ft4_account;
choice: choice_values;
created_at: timestamp = utils.last_known_time();
}
We’ve created a composite key
, which is an index that enforces uniqueness across two fields at once: event
and better
. This setup ensures that a single account can only place one bet per event. If they try to place another one, the system will throw an error. Simple, reliable, and exactly what we need.
One more thing worth highlighting is the event field. Here we’ve directly linked bet
to event
, creating a one-to-many relationship. In plain terms, a single event can have many bets attached to it.
Next, let’s add a validation rule to make sure users aren’t trying to bet on expired events. It’s a basic check, but absolutely essential. We’ll drop this into requirements.rell
:
function validate_event_expiration(event: event) {
require(
event.expires_at > utils.last_known_time(),
"Event has expired."
);
}
This function simply checks that the event hasn’t expired yet. If the expiration time has already passed, we throw an error and prevent the user from placing a bet.
Alongside this, we’re also adding a very straightforward operation to place a bet. Nothing fancy, just straight to the point:
operation place_bet(event, choice: choice_values) {
val account = auth.authenticate();
validate_event_expiration(event);
create bet ( event, account, choice );
}
We authenticate the account, make sure the event is still active, and then create the bet. Simple as that.
And just like we did with events, we add a simple query that returns all bets:
query get_bets(): list<bet> {
return bet @* { };
}
For now, there’s no filtering or sorting, just a full list. Later on, we’ll definitely level this up.
We’ll be placing and testing bets more than once, so it’s a good idea to pull the main logic into helpers.rell
to avoid repeating ourselves:
function create_test_bet(user_kp: rell.test.keypair, event: event, choice: choice_values) {
rell.test.tx()
.op(ft_auth_operation_for(user_kp.pub))
.op(place_bet(event, choice))
.sign(user_kp)
.run();
return get_bets()[0];
}
We’ll move the main test cases into a separate file
development_tests/bets_test.rell
:
function test_place_bet() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
register_account(alice, auth_descriptor);
val event = create_test_event(
create_event_struct(
user_kp = alice,
question = "Will CHR be $10 in a week?",
expires_in_days = 7,
bet_amount = 10
)
);
val bet = create_test_bet(alice, event, choice_values.YES);
assert_equals(bet.event, event);
assert_equals(bet.better.id, alice.pub.hash());
assert_equals(bet.choice, choice_values.YES);
assert_true(event.created_at > 0);
}
function test_place_bet_twice_must_fail() {
val alice = rell.test.keypairs.alice;
val auth_descriptor = create_auth_descriptor(alice.pub, ["A", "T"], null.to_gtv());
register_account(alice, auth_descriptor);
val event = create_test_event(
create_event_struct(
user_kp = alice,
question = "Will CHR hit $5 next month?",
expires_in_days = 30,
bet_amount = 10
)
);
create_test_bet(alice, event, choice_values.YES);
rell.test.tx()
.op(ft_auth_operation_for(alice.pub))
.op(place_bet(event, choice_values.NO))
.sign(alice)
.run_must_fail();
}
In the last test, we deliberately place a different choice (NO), but it still has to fail—because a single account shouldn’t be able to place two bets on the same event. That’s exactly what our composite key is here to prevent.
That’s a wrap for today. Time to exhale. I’m not yet sure what we’ll build next, but we’re definitely going to keep pushing forward. From now on, I’ll start skipping detailed test descriptions so we can focus more on the core logic. But don’t worry, you can always find all the tests in the repo if you need them. Let’s keep moving.
👈 Previous step | 💻 GitHub repo
Top comments (0)