This section will walk through building a simulation test of our example NCN program. The test represents a comprehensive scenario designed to mimic a complete NCN system. It involves multiple operators, vaults, and different types of tokens. The test covers the entire workflow, from the initial setup of participants and the NCN program itself, through the voting process, and finally to reaching and verifying consensus. It heavily utilizes Jito's restaking and vault infrastructure alongside the custom NCN voting logic.
The NCN program used can be found here. By creating a simulation test of this NCN, you'll be better prepared to use it as a template or base that you can adapt to create your own NCN program. Just a reminder: we do not recommend most NCN developers build their NCN from scratch. Rather, we suggest using this prebuilt program as a starting point and customizing it according to your needs.
The simulation test we'll be creating below can also be found in the example NCN repository. However, you'll understand the system better if you write the test along with us, so feel free to clone the repository, delete the test file ./integration_tests/test/ncn_program/simulation_test.rs
, and follow along. This will give you hands-on experience with the entire NCN lifecycle: initializing vaults and operators using Jito's restaking and vault programs, configuring the NCN program, and executing the full voting process.
Before running the simulation test, ensure you have completed the following setup steps:
cargo build-sbf --manifest-path program/Cargo.toml --sbf-out-dir integration_tests/tests/fixtures
Let's build the simulation test step by step.
You can start with a blank file. Create a new file named simulation_test.rs
inside the integration_tests/tests
folder. Copy and paste the following boilerplate code at the bottom of your test function:
#[cfg(test)]
mod tests {
use crate::fixtures::{test_builder::TestBuilder, TestResult};
use jito_restaking_core::{config::Config, ncn_vault_ticket::NcnVaultTicket};
use ncn_program_core::{ballot_box::WeatherStatus, constants::WEIGHT};
use solana_sdk::{msg, signature::Keypair, signer::Signer};
#[tokio::test]
async fn simulation_test() -> TestResult<()> {
// YOUR TEST CODE WILL GO HERE
// 2. ENVIRONMENT SETUP
// 3. NCN SETUP
// 4. OPERATORS AND VAULTS SETUP
// 5. NCN PROGRAM CONFIGURATION
// 6. Epoch Snapshot and Voting Preparation
// 7. VOTING
// 8. REWARDS DISTRIBUTION
// 9. VERIFICATION
// 10. CLEANUP
Ok(())
}
}
Unless otherwise specified, all of the code snippets provided in this guide represent code that should go inside the simulation_test
test function, in the order provided.
Next, you need to make this new test discoverable. Copy and paste the following line into the integration_tests/tests/mod.rs
file to declare the new module:
// integration_tests/tests/mod.rs
mod simulation_test;
Now, you can run this specific test using the following command:
SBF_OUT_DIR=integration_tests/tests/fixtures cargo test -p ncn-program-integration-tests --test tests simulation_test
This command targets the ncn-program-integration-tests
package and runs only the simulation_test
test function. If you want to run all tests in the suite, simply remove the test name filter (-p ncn-program-integration-tests --test tests simulation_test
) from the command.
Currently, the test will pass because it doesn't contain any logic yet. You should see output similar to this:
running 1 test
test ncn_program::simulation_test::tests::simulation_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 54 filtered out; finished in 0.00s
The first step within our test function is to set up the testing environment using the TestBuilder
. Copy and paste the following code at the bottom of your test function:
let mut fixture = TestBuilder::new().await;
The TestBuilder
is a test utility that encapsulates and simplifies the setup process for NCN program testing. It provides:
This and other utility functions (like add_operators_to_test_ncn
, add_vaults_to_test_ncn
) abstract away much of the complex, repetitive setup code, allowing tests to focus on the specific behaviors being verified rather than boilerplate infrastructure.
Since we are running this test locally against a test ledger, we need to initialize the Jito Restaking and Vault programs on the ledger. In a real network environment (devnet, mainnet), these programs would already be deployed and configured.
Copy and paste the following code at the bottom of your test function:
fixture.initialize_restaking_and_vault_programs().await?;
Finally, let's prepare some client objects and configuration variables we'll use throughout the test.
Copy and paste the following code at the bottom of your test function:
let ncn_program_client = fixture.ncn_program_client();
let vault_program_client = fixture.vault_client();
let restaking_client = fixture.restaking_program_client();
// Define test parameters
const OPERATOR_COUNT: usize = 13; // Number of operators to simulate
let mints = vec![
(Keypair::new(), WEIGHT), // Alice: Base weight
(Keypair::new(), WEIGHT * 2), // Bob: Double weight
(Keypair::new(), WEIGHT * 3), // Charlie: Triple weight
(Keypair::new(), WEIGHT * 4), // Dave: Quadruple weight
];
let delegations = [
1, // Minimum delegation amount (e.g., 1 lamport)
10_000_000_000, // 10 tokens (assuming 9 decimals)
100_000_000_000, // 100 tokens
1_000_000_000_000, // 1,000 tokens
10_000_000_000_000, // 10,000 tokens
];
This code does the following:
OPERATOR_COUNT
to specify how many operators we'll create.mints
: a list of keypairs representing different SPL token mints and their corresponding voting weights. We use different weights to test the stake-weighting mechanism. WEIGHT
is likely a constant representing the base unit of weight.delegations
: an array of different token amounts (in lamports, assuming 9 decimals for typical SPL tokens) that vaults will delegate to operators.Now, let's create the NCN account using the Jito Restaking program. The create_test_ncn
helper function handles the necessary instruction calls.
Copy and paste the following code at the bottom of your test function:
let mut test_ncn = fixture.create_test_ncn().await?;
let ncn_pubkey = test_ncn.ncn_root.ncn_pubkey;
This step:
ncn_pubkey
) of the newly created NCN, which we'll need to interact with it later.If you run the test at this point (cargo test ... simulation_test
), you should see transaction logs in the output, indicating that the NCN creation instructions were executed successfully.
This phase is crucial for simulating a realistic network. We will create the operators who vote and the vaults that provide the stake (voting power).
We'll add the specified number of operators (OPERATOR_COUNT
) to our NCN using another helper function.
Copy and paste the following code at the bottom of your test function:
fixture
.add_operators_to_test_ncn(&mut test_ncn, OPERATOR_COUNT, Some(100))
.await?;
This add_operators_to_test_ncn
function performs several actions by calling instructions in the Jito Restaking program:
OPERATOR_COUNT
(13 in our case) separate operator accounts.The handshake process involves multiple steps:
do_initialize_ncn_operator_state
).do_ncn_warmup_operator
).do_operator_warmup_ncn
).For more information on this, please read the guide here
This handshake is essential for security. It ensures that operators must explicitly connect to the NCN (and vice-versa) and potentially wait through an activation period before they can participate in voting.
Next, we create vaults to hold the different types of tokens we defined earlier. We'll distribute them across the token types. Note that you can have more than one vault with the same ST Mint (Support Token Mint).
Copy and paste the following code at the bottom of your test function:
// Create vaults associated with different token mints
{
// Create 3 vaults for Alice (base weight)
fixture
.add_vaults_to_test_ncn(&mut test_ncn, 3, Some(mints[0].0.insecure_clone()))
.await?;
// Create 2 vaults for Bob (double weight)
fixture
.add_vaults_to_test_ncn(&mut test_ncn, 2, Some(mints[1].0.insecure_clone()))
.await?;
// Create 1 vault for Charlie (triple weight)
fixture
.add_vaults_to_test_ncn(&mut test_ncn, 1, Some(mints[2].0.insecure_clone()))
.await?;
// Create 1 vault for Dave (quadruple weight)
fixture
.add_vaults_to_test_ncn(&mut test_ncn, 1, Some(mints[3].0.insecure_clone()))
.await?;
}
The add_vaults_to_test_ncn
helper function orchestrates calls to both the Jito Vault and Jito Restaking programs to:
mints[0]
, mints[1]
, etc.).do_initialize_ncn_vault_ticket
, do_warmup_ncn_vault_ticket
).do_initialize_vault_ncn_ticket
, do_warmup_vault_ncn_ticket
).do_initialize_operator_vault_ticket
, do_warmup_operator_vault_ticket
) and Jito Vault (do_initialize_vault_operator_delegation
) instructions. Note that do_initialize_vault_operator_delegation
only sets up the potential for delegation; no actual tokens are delegated yet.fixture.advance_slots
) after handshakes "Tickets" to ensure the relationships become active, simulating the necessary waiting period.Creating vaults with different token types allows us to test how the NCN handles varying voting power based on token weights.
This is where vaults actually delegate their tokens (stake) to operators, granting them voting power. We'll iterate through operators and vaults to create delegations.
Copy and paste the following code at the bottom of your test function:
// Vaults delegate stake to operators
{
// Iterate through all operators except the last one
for (index, operator_root) in test_ncn
.operators
.iter()
.take(OPERATOR_COUNT - 1)
.enumerate()
{
// Each vault delegates to this operator
for vault_root in test_ncn.vaults.iter() {
// Cycle through the predefined delegation amounts
let delegation_amount = delegations[index % delegations.len()];
if delegation_amount > 0 {
// Call the Vault program to add the delegation
vault_program_client
.do_add_delegation(
vault_root, // The vault delegating
&operator_root.operator_pubkey, // The operator receiving
delegation_amount, // The amount to delegate
)
.await
.unwrap();
}
}
}
}
The delegation process is where voting power is established. Each vault delegates tokens to operators, which determines:
Key aspects of the delegation setup:
Every vault delegates to every operator (except the last one for this example)
Note that vaults can choose whom to delegate to, they don't have to delegate to all operators
Delegation amounts cycle through the delegations
array to test different scenarios
The last operator intentionally receives zero delegation to test the system's handling of operators without stake
The delegation is performed directly through the vault program using do_add_delegation
which will call a specific instruction in the vault program to do that
Each operator accumulates voting power from all the different delegations they receive. The total voting power for an operator is the sum of the weighted values of each delegation.
Example:
This distributed delegation model enables testing complex scenarios where:
The deliberate omission of delegation to the last operator creates a control case to verify that operators with zero stake cannot influence the voting process, which is a critical security feature.
You can run the test now and see the output.
The delegation architecture follows a multiplication relationship:
Each operator accumulates voting power from all the different delegations they receive. The total voting power for an operator is the sum of the weighted values of each delegation.
Example:
This distributed delegation model enables testing complex scenarios where:
The deliberate omission of delegation to the last operator creates a control case to verify that operators with zero stake cannot influence the voting process, which is a critical security feature.
You can run the test now and see the output.
Until now, all the code we've written uses the Jito restaking program and Jito vault program. Now we will start using the example NCN program that you will have to deploy.
The NCN Program Configuration phase establishes the on-chain infrastructure necessary for the voting and consensus mechanisms. This includes setting up configuration parameters, creating data structures, and registering the token types and vaults that will participate in the system.
First, we initialize the main configuration account for our NCN instance.
Copy and paste the following code at the bottom of your test function:
// Initialize the main Config account for the NCN program
ncn_program_client
.do_initialize_config(test_ncn.ncn_root.ncn_pubkey, &test_ncn.ncn_root.ncn_admin)
.await?;
This step initializes the core configuration for the NCN program with critical parameters:
Under the hood, this creates an NcnConfig
account that stores these parameters and serves as the authoritative configuration for this NCN instance.
The vault registry account is a large one, so it is not possible to initialize it in one call due to Solana network limitations. We will have to call the NCN program multiple times to get to the full size. The first call will be an init call to the instruction admin_initialize_vault_registry
. After that, we will call a realloc instruction admin_realloc_vault_registry
to increase the size of the account. This will be done in a loop until the account is the correct size.
The realloc will take care of assigning the default values to the vault registry account once the desirable size is reached. In our example, we will do that by calling one function do_full_initialize_vault_registry
. If you want to learn more about this, you can check the source code.
Copy and paste the following code at the bottom of your test function:
// Initialize the VaultRegistry account (handles potential reallocations)
ncn_program_client
.do_full_initialize_vault_registry(test_ncn.ncn_root.ncn_pubkey)
.await?;
The vault registry is a critical data structure that:
Note that this is only initializing the vault registry. The vaults and the supported tokens will be registered in the next steps.
Check out the vault registry struct here
Next, we advance the simulation clock to ensure that all previously established handshake relationships (NCN-Operator, NCN-Vault, Operator-Vault) become active, as Jito's restaking infrastructure often includes activation periods.
Copy and paste the following code at the bottom of your test function:
// Fast-forward time to simulate a full epoch passing
// This is needed for all the relationships to get activated
let restaking_config_address =
Config::find_program_address(&jito_restaking_program::id()).0;
let restaking_config = restaking_client
.get_config(&restaking_config_address)
.await?;
let epoch_length = restaking_config.epoch_length();
fixture
.warp_slot_incremental(epoch_length * 2)
.await
.unwrap();
This section:
The time advancement is necessary because Jito's restaking infrastructure uses an activation period for security. This prevents malicious actors from quickly creating and voting with fake operators or vaults by enforcing a waiting period before they can participate.
Now it is time to register the supported tokens with the NCN program and assign weights to each mint for voting power calculations.
Copy and paste the following code at the bottom of your test function:
// Register each Supported Token (ST) mint and its weight in the NCN's VaultRegistry
for (mint, weight) in mints.iter() {
ncn_program_client
.do_admin_register_st_mint(ncn_pubkey, mint.pubkey(), *weight)
.await?;
}
This step registers each Supported Token (ST) mint with the NCN program and assigns the appropriate weight:
The weight assignment is fundamental to the design, allowing different tokens to have varying influence on the voting process based on their economic significance or other criteria determined by the NCN administrators.
It's good to know that in real-life examples, NCNs will probably want to set the token weights based on the token's price or market cap. To do so, you will have to use an oracle to get the price of the token and then set the weight based on that. In this case, you will have to store the feed of the price in this step instead of the weight.
Registering a vault is a permissionless operation. The reason is the admin has already given permission to the vault to be part of the NCN in the vault registration step earlier, so this step is just to register the vault in the NCN program.
Copy and paste the following code at the bottom of your test function:
// Register all the vaults in the ncn program
for vault in test_ncn.vaults.iter() {
let vault = vault.vault_pubkey;
let (ncn_vault_ticket, _, _) = NcnVaultTicket::find_program_address(
&jito_restaking_program::id(),
&ncn_pubkey,
&vault,
);
ncn_program_client
.do_register_vault(ncn_pubkey, vault, ncn_vault_ticket)
.await?;
}
The final configuration step registers each vault with the NCN program:
This registration process establishes the complete set of vaults that can contribute to the voting system, creating a closed ecosystem of verified participants.
The NCN program configuration establishes a multi-layered security model:
This layered approach ensures the integrity of the voting system by validating the identity and relationships of all participants before they can influence the consensus process.
The configuration phase completes the preparation of the system's infrastructure, setting the stage for the actual voting mechanics to begin in subsequent phases.
The Epoch Snapshot and Voting Preparation phase is where the system captures the current state of all participants and prepares the infrastructure for voting. This is an essential component of the architecture as it ensures voting is based on a consistent, verifiable snapshot of the network state at a specific moment in time.
The upcoming section is a keeper task (with the exception of the voting). This means that it is permissionless and can be done by anyone.
To begin a new consensus cycle (epoch), we first initialize an EpochState
account for our NCN, which will track the progress of this epoch.
Copy and paste the following code at the bottom of your test function:
// Initialize the epoch state for the current epoch
fixture.add_epoch_state_for_test_ncn(&test_ncn).await?;
This step initializes the Epoch State for the current consensus cycle:
EpochState
account tied to the specific NCN and epoch.Once initialized, the EpochState
account becomes the authoritative record of where the system is in the voting process, preventing operations from happening out of order or in duplicate.
You can take a look at the epoch state struct here.
For the current epoch, we initialize a WeightTable
and populate it by copying the token weights from the VaultRegistry
, effectively freezing these weights for the duration of this consensus cycle.
Copy and paste the following code at the bottom of your test function:
// Initialize the weight table to track voting weights
let clock = fixture.clock().await;
let epoch = clock.epoch;
ncn_program_client
.do_full_initialize_weight_table(test_ncn.ncn_root.ncn_pubkey, epoch)
.await?;
// Take a snapshot of weights for each token mint
ncn_program_client
.do_set_epoch_weights(test_ncn.ncn_root.ncn_pubkey, epoch)
.await?;
The weight table mechanism handles the token weights for the current epoch in two stages:
Weight Table Initialization:
WeightTable
account for the specific epoch using do_full_initialize_weight_table
. This may involve multiple calls internally to allocate sufficient space.VaultRegistry
.Weight Setting:
WeightTable
by calling do_set_epoch_weights
VaultRegistry
to the epoch-specific WeightTable
.EpochState
to mark weight setting as complete.This two-step process is critical for the integrity of the system as it:
We then create an EpochSnapshot
account to record the overall state for this epoch, such as total operator and vault counts, and to accumulate total stake weight.
Copy and paste the following code at the bottom of your test function:
// Take the epoch snapshot
fixture.add_epoch_snapshot_to_test_ncn(&test_ncn).await?;
The epoch snapshot captures the aggregate state of the entire system:
EpochSnapshot
account for the NCN and epoch.Next, individual OperatorSnapshot
accounts are created for each participating operator, capturing their state and expected delegations for the epoch.
Copy and paste the following code at the bottom of your test function:
// 2.b. Initialize the operators using the Jito Restaking program, and initiate the
// handshake relationship between the NCN <> operators
{
for _ in 0..OPERATOR_COUNT {
// Set operator fee to 100 basis points (1%)
let operator_fees_bps: Option<u16> = Some(100);
// Initialize a new operator account with the specified fee
let operator_root = restaking_client
.do_initialize_operator(operator_fees_bps)
.await?;
// Establish bidirectional handshake between NCN and operator:
// 1. Initialize the NCN's state tracking (the NCN operator ticket) for this operator
restaking_client
.do_initialize_ncn_operator_state(
&test_ncn.ncn_root,
&operator_root.operator_pubkey,
)
.await?;
// 2. Advance slot to satisfy timing requirements
fixture.warp_slot_incremental(1).await.unwrap();
// 3. NCN warms up to operator - creates NCN's half of the handshake
restaking_client
.do_ncn_warmup_operator(&test_ncn.ncn_root, &operator_root.operator_pubkey)
.await?;
// 4. Operator warms up to NCN - completes operator's half of the handshake
restaking_client
.do_operator_warmup_ncn(&operator_root, &test_ncn.ncn_root.ncn_pubkey)
.await?;
// Add the initialized operator to our test NCN's operator list
test_ncn.operators.push(operator_root);
}
}
This step creates an individual snapshot for each operator in the system:
OperatorSnapshot
account linked to the operator, NCN, and epoch.These snapshots establish each operator's baseline for the current epoch. The actual voting power will be populated in the next step based on individual delegations. This ensures that later delegation changes cannot alter voting weight once the snapshot phase is complete.
With operator snapshots ready, we now record the weighted stake from each specific vault-to-operator delegation into the relevant OperatorSnapshot
and update the total stake in the EpochSnapshot
.
Copy and paste the following code at the bottom of your test function:
// Record all vault-to-operator delegations
fixture
.add_vault_operator_delegation_snapshots_to_test_ncn(&test_ncn)
.await?;
This crucial step iterates through each active vault-to-operator delegation and records its contribution to the operator's voting power:
WeightTable
.OperatorSnapshot
by adding the calculated stake weight.OperatorSnapshot
's vault_operator_stake_weight
array.EpochSnapshot
.VaultOperatorDelegationSnapshot
account for detailed auditing.These granular snapshots serve multiple purposes:
OperatorSnapshot
accounts with the actual stake weights used for voting.EpochSnapshot
with the total voting power present in the system for this epoch.To prepare for voting, we initialize a BallotBox
account for the current epoch, which will collect and tally all operator votes.
Copy and paste the following code at the bottom of your test function:
// Initialize the ballot box for collecting votes
fixture.add_ballot_box_to_test_ncn(&test_ncn).await?;
The final preparation step creates the ballot box:
BallotBox
account linked to the NCN and epoch using do_full_initialize_ballot_box
. Similar to the weight table, this may require multiple allocation calls internally.OperatorVote
) and ballot tallies (BallotTally
).EpochState
for progress tracking.The BallotBox
becomes the central repository where all votes are recorded and tallied during the voting process. It is designed to efficiently track:
The snapshot system implements several key architectural principles:
EpochSnapshot
)OperatorSnapshot
)OperatorSnapshot
, optionally VaultOperatorDelegationSnapshot
)The comprehensive snapshot approach ensures that voting occurs on a well-defined, verifiable view of the network's state, establishing a solid foundation for the actual voting process to follow.
The Voting Process is the core functionality of the NCN system, where operators express their preferences on the network state (represented by the "weather status" in this simulation). This process leverages the infrastructure and snapshots created in previous steps to ensure secure, verifiable, and stake-weighted consensus.
In our simulation, we'll predefine an expected winning outcome for verification purposes.
Copy and paste the following code at the bottom of your test function:
// Define the expected winning weather status
let winning_weather_status = WeatherStatus::Sunny as u8;
For testing purposes, the system defines an expected outcome (WeatherStatus::Sunny
). In a production environment, the winning outcome would be determined organically through actual operator votes based on real-world data or criteria. The weather status enum (Sunny
, Cloudy
, Rainy
) serves as a simplified proxy for any on-chain decision that requires consensus.
Operators now cast their votes. We'll simulate a few operators voting, some for the expected outcome and some against, to test the tallying logic.
Copy and paste the following code at the bottom of your test function:
// Cast votes from operators
{
let epoch = fixture.clock().await.epoch;
let first_operator = &test_ncn.operators[0];
let second_operator = &test_ncn.operators[1];
let third_operator = &test_ncn.operators[2];
// First operator votes for Cloudy
ncn_program_client
.do_cast_vote(
ncn_pubkey,
first_operator.operator_pubkey,
&first_operator.operator_admin,
WeatherStatus::Cloudy as u8,
epoch,
)
.await?;
// Second and third operators vote for Sunny (expected winner)
ncn_program_client
.do_cast_vote(
ncn_pubkey,
second_operator.operator_pubkey,
&second_operator.operator_admin,
winning_weather_status,
epoch,
)
.await?;
ncn_program_client
.do_cast_vote(
ncn_pubkey,
third_operator.operator_pubkey,
&third_operator.operator_admin,
winning_weather_status,
epoch,
)
.await?;
}
This section demonstrates the system's ability to handle diverse voting preferences using the do_cast_vote
helper, which calls the cast_vote
instruction:
do_cast_vote
call invokes the NCN program with the operator's choice and admin signature.Under the hood, each vote triggers several key operations within the cast_vote
instruction:
BallotBox
.OperatorSnapshot
to confirm eligibility and get its total stake weight.EpochState
indicates voting is currently allowed.operator_votes
array within the BallotBox
.BallotTally
for the chosen weather status in the ballot_tallies
array.EpochSnapshot
.BallotBox
and records the current slot.To ensure consensus is reached for our test, the remaining eligible operators will now vote for the predefined winning weather status.
Copy and paste the following code at the bottom of your test function:
// All remaining operators vote for Sunny to form a majority
for operator_root in test_ncn.operators.iter().take(OPERATOR_COUNT).skip(3) {
ncn_program_client
.do_cast_vote(
ncn_pubkey,
operator_root.operator_pubkey,
&operator_root.operator_admin,
winning_weather_status,
epoch,
)
.await?;
}
The consensus mechanism works as follows:
BallotTally
for each unique option voted on.EpochSnapshot
.Ballot
as the winning_ballot
in the BallotBox
.slot
in slot_consensus_reached
.EpochState
.ConsensusResult
account (discussed in Verification).When an operator casts a vote via the cast_vote
instruction, the system performs several critical operations:
operator_admin
keypair associated with the operator
account.OperatorSnapshot
for the current epoch
.BallotBox
).OperatorSnapshot
.EpochState
confirms that the snapshotting phase is complete and voting is open.operator_votes
array within the BallotBox
.operator
pubkey, current slot
, the operator's total stake_weights
(from OperatorSnapshot
), and the index corresponding to the chosen ballot within the ballot_tallies
array.operators_voted
counter in the BallotBox
.ballot_tallies
array for an existing entry matching the weather_status
.stake_weights
to the stake_weights
field of the existing BallotTally
and increments the raw tally
counter.BallotTally
entry with the weather_status
, the operator's stake_weights
, and a tally
of 1. Increments unique_ballots
.stake_weights
from the EpochSnapshot
.stake_weights
against the total.winning_ballot
field in the BallotBox
.slot
in slot_consensus_reached
.EpochState
.ConsensusResult
account, storing the winning status, epoch, weights, and slot.ncn
and epoch
through the PDAs used for the involved accounts (BallotBox
, OperatorSnapshot
, EpochState
).This multi-layered architecture ensures votes are processed securely, tallied correctly using the snapshotted weights, and that consensus is determined accurately based on stake-weighted participation.
The voting process incorporates several key security features:
BallotBox
tracks which operators have voted (operator_votes
array).EpochState
indicates the voting phase is active for the specified epoch
.valid_slots_after_consensus
), they won't change the already determined outcome.operator_admin
signature.EpochSnapshot
, providing a fixed target for the epoch.These security measures ensure the voting process remains resilient against various attack vectors and manipulation attempts, maintaining the integrity of the consensus mechanism.
After consensus is reached, the NCN system can distribute rewards to participants based on their contributions to the consensus process. The rewards system operates through a multi-layered distribution mechanism that allocates rewards to different stakeholders: the Protocol, the NCN itself, operators, and vaults.
The reward distribution process consists of three main phases:
Before rewards can be distributed, the system must initialize reward routers that manage the flow of rewards to different participants.
Copy and paste the following code at the bottom of your test function:
// Setup reward routers for NCN and operators
{
let ncn = test_ncn.ncn_root.ncn_pubkey;
let clock = fixture.clock().await;
let epoch = clock.epoch;
ncn_program_client
.do_full_initialize_ncn_reward_router(ncn, epoch)
.await?;
for operator_root in test_ncn.operators.iter() {
let operator = operator_root.operator_pubkey;
ncn_program_client
.do_initialize_operator_vault_reward_router(ncn, operator, epoch)
.await?;
}
}
This step creates the infrastructure for reward distribution:
The reward routers implement a hierarchical distribution system:
The first phase of reward distribution involves routing rewards into the NCN system and distributing them according to the configured fee structure.
Copy and paste the following code at the bottom of your test function:
// Route rewards into the NCN reward system
{
let ncn = test_ncn.ncn_root.ncn_pubkey;
let epoch = fixture.clock().await.epoch;
const REWARD_AMOUNT: u64 = 1_000_000;
// Advance the clock to ensure we are in a valid time window for reward distribution.
let valid_slots_after_consensus = {
let config = ncn_program_client.get_ncn_config(ncn).await?;
config.valid_slots_after_consensus()
};
fixture
.warp_slot_incremental(valid_slots_after_consensus + 1)
.await?;
// Send rewards to the NCN reward receiver
let ncn_reward_receiver =
NCNRewardReceiver::find_program_address(&ncn_program::id(), &ncn, epoch).0;
fn lamports_to_sol(lamports: u64) -> f64 {
lamports as f64 / 1_000_000_000.0
}
let sol_rewards = lamports_to_sol(REWARD_AMOUNT);
ncn_program_client
.airdrop(&ncn_reward_receiver, sol_rewards)
.await?;
// Route rewards through the NCN reward system
ncn_program_client.do_route_ncn_rewards(ncn, epoch).await?;
// Should be able to route twice (idempotent operation)
ncn_program_client.do_route_ncn_rewards(ncn, epoch).await?;
let ncn_reward_router = ncn_program_client.get_ncn_reward_router(ncn, epoch).await?;
// Distribute Protocol Rewards (4% of total)
{
let rewards = ncn_reward_router.protocol_rewards();
if rewards > 0 {
let config = ncn_program_client.get_ncn_config(ncn).await?;
let protocol_fee_wallet = config.fee_config.protocol_fee_wallet();
let balance_before = {
let account = fixture.get_account(protocol_fee_wallet).await?;
account.unwrap().lamports
};
println!("Distributing {} of Protocol Rewards", rewards);
ncn_program_client
.do_distribute_protocol_rewards(ncn, epoch)
.await?;
let balance_after = {
let account = fixture.get_account(protocol_fee_wallet).await?;
account.unwrap().lamports
};
assert_eq!(
balance_after,
balance_before + rewards,
"Protocol fee wallet balance should increase by the rewards amount"
);
}
}
// Distribute NCN Rewards (4% of total)
{
let rewards = ncn_reward_router.ncn_rewards();
if rewards > 0 {
let config = ncn_program_client.get_ncn_config(ncn).await?;
let ncn_fee_wallet = config.fee_config.ncn_fee_wallet();
let balance_before = {
let account = fixture.get_account(ncn_fee_wallet).await?;
account.unwrap().lamports
};
println!("Distributing {} of NCN Rewards", rewards);
ncn_program_client
.do_distribute_ncn_rewards(ncn, epoch)
.await?;
let balance_after = {
let account = fixture.get_account(ncn_fee_wallet).await?;
account.unwrap().lamports
};
assert_eq!(
balance_after,
balance_before + rewards,
"NCN fee wallet balance should increase by the rewards amount"
);
}
}
// Distribute Operator Vault Rewards (92% of total)
{
for operator_root in test_ncn.operators.iter() {
let operator = operator_root.operator_pubkey;
let operator_route = ncn_reward_router.operator_vault_reward_route(&operator);
let rewards = operator_route.rewards().unwrap_or(0);
if rewards == 0 {
continue;
}
println!("Distribute NCN Reward {}", rewards);
ncn_program_client
.do_distribute_operator_vault_reward_route(operator, ncn, epoch)
.await?;
}
}
}
The NCN reward routing process follows these steps:
Timing Validation: The system waits for the configured valid_slots_after_consensus
period to ensure proper timing for reward distribution.
Reward Reception: Rewards are deposited into the NCN Reward Receiver account, which serves as the entry point for all rewards.
Fee Calculation: The system automatically calculates different fee categories based on the NCN configuration:
Distribution Execution: Each category of rewards is distributed to its respective recipients:
The distribution is weighted based on the operators' voting participation and stake weights from the consensus process, ensuring that rewards flow proportionally to participants who contributed to achieving consensus.
The second phase distributes rewards that were allocated to operators and vaults, managing the final distribution to individual participants.
Copy and paste the following code at the bottom of your test function:
// Route rewards to operators and their delegated vaults
{
let ncn = test_ncn.ncn_root.ncn_pubkey;
let epoch = fixture.clock().await.epoch;
for operator_root in test_ncn.operators.iter() {
let operator = operator_root.operator_pubkey;
// Route rewards to operator and vaults
ncn_program_client
.do_route_operator_vault_rewards(ncn, operator, epoch)
.await?;
// Should be able to route twice (idempotent operation)
ncn_program_client
.do_route_operator_vault_rewards(ncn, operator, epoch)
.await?;
let operator_vault_reward_router = ncn_program_client
.get_operator_vault_reward_router(operator, ncn, epoch)
.await?;
// Distribute operator's fee portion
let operator_rewards = operator_vault_reward_router.operator_rewards();
if operator_rewards > 0 {
ncn_program_client
.do_distribute_operator_rewards(operator, ncn, epoch)
.await?;
}
// Distribute rewards to vaults that delegated to this operator
for vault_root in test_ncn.vaults.iter() {
let vault = vault_root.vault_pubkey;
let vault_reward_route = operator_vault_reward_router.vault_reward_route(&vault);
if let Ok(vault_reward_route) = vault_reward_route {
let vault_rewards = vault_reward_route.rewards();
if vault_rewards > 0 {
ncn_program_client
.do_distribute_vault_rewards(vault, operator, ncn, epoch)
.await?;
}
}
}
}
}
The operator vault reward routing process manages distribution at the most granular level:
This ensures that the economic incentives align with the security and participation goals of the NCN system.
The rewards system implements several key architectural principles:
This comprehensive reward system ensures that all participants in the NCN ecosystem are appropriately compensated for their contributions while maintaining the security and integrity of the consensus mechanism.
The Verification phase validates that the voting process completed successfully and that the expected consensus was achieved.
This critical step confirms the integrity of the entire system by examining the on-chain data structures (BallotBox
and ConsensusResult
) and verifying they contain the expected results.
After voting concludes, we first verify the BallotBox
to ensure it correctly reflects that consensus was reached and identifies the expected winning ballot.
Copy and paste the following code at the bottom of your test function:
// Verify the results recorded in the BallotBox
{
let epoch = fixture.clock().await.epoch;
let ballot_box = ncn_program_client.get_ballot_box(ncn_pubkey, epoch).await?;
assert!(ballot_box.has_winning_ballot());
assert!(ballot_box.is_consensus_reached());
assert_eq!(ballot_box.get_winning_ballot().unwrap().weather_status(), winning_weather_status);
}
The first verification step examines the BallotBox
account for the completed epoch:
Winning Ballot Check:
has_winning_ballot()
confirms that the winning_ballot
field within the BallotBox
structure is marked as valid.Consensus Status Check:
Winning Ballot Check:
has_winning_ballot()
confirms that the winning_ballot
field within the BallotBox
structure is marked as valid.is_consensus_reached()
checks if the slot_consensus_reached
field is greater than zero, indicating the consensus condition was met during the voting process.winning_ballot
struct and asserts that its weather_status
field matches the winning_weather_status
defined earlier (WeatherStatus::Sunny
). This confirms the correct outcome was identified based on the stake-weighted tally.Verifying the BallotBox
ensures the core voting and tallying mechanism functioned correctly during the active epoch.
Next, we verify the permanently stored ConsensusResult
account to confirm it accurately records the winning outcome, epoch details, and vote weights, consistent with the BallotBox
.
Copy and paste the following code at the bottom of your test function:
// Fetch and verify the consensus_result account
{
let epoch = fixture.clock().await.epoch;
let consensus_result = ncn_program_client
.get_consensus_result(ncn_pubkey, epoch)
.await?;
assert!(consensus_result.is_consensus_reached());
assert_eq!(consensus_result.epoch(), epoch);
assert_eq!(consensus_result.weather_status(), winning_weather_status);
let ballot_box = ncn_program_client.get_ballot_box(ncn_pubkey, epoch).await?;
let winning_ballot_tally = ballot_box.get_winning_ballot_tally().unwrap();
assert_eq!(consensus_result.vote_weight(), winning_ballot_tally.stake_weights().stake_weight() as u64);
println!(
"✅ Consensus Result Verified - Weather Status: {}, Vote Weight: {}, Total Weight: {}, Recorder: {}",
consensus_result.weather_status(),
consensus_result.vote_weight(),
consensus_result.total_vote_weight(),
consensus_result.consensus_recorder()
);
}
The second verification step examines the ConsensusResult
account, which serves as the permanent, immutable record of the voting outcome:
Consensus Result Existence & Fetching:
ConsensusResult
account using its PDA derived from the NCN pubkey and epoch. Its existence implies consensus was reached and the account was created.Consensus Status Validation:
Consensus Result Existence & Fetching:
ConsensusResult
account using its PDA derived from the NCN pubkey and epoch. Its existence implies consensus was reached and the account was created.is_consensus_reached()
checks an internal flag derived from stored values (like consensus_slot
> 0), confirming the outcome is officially recognized.epoch
field matches the current epoch.weather_status
matches the expected winning_weather_status
.BallotBox
again.BallotTally
corresponding to the winning ballot from the BallotBox
.vote_weight
stored in the ConsensusResult
exactly matches the stake_weight
recorded in the winning BallotTally
within the BallotBox
. This ensures consistency between the temporary voting record and the permanent result.ConsensusResult
account for confirmation.Verifying the ConsensusResult
confirms that the outcome was durably stored with the correct details and consistent with the voting process itself.
The verification phase highlights several important architectural features:
BallotBox
during the epoch for active voting and tallying.ConsensusResult
account.BallotBox
(process) can eventually be closed to reclaim rent.ConsensusResult
(outcome) persists indefinitely as the historical record.ConsensusResult
account is typically created automatically within the cast_vote
instruction when the consensus threshold is first met. This ensures timely recording without requiring a separate administrative action.ConsensusResult
account, once created, is designed to be immutable. It stores the outcome based on the state when consensus was reached.BallotBox
and ConsensusResult
store key timing information (slot_consensus_reached
, epoch
). This metadata is crucial for auditing and understanding the system's behavior over time.The verification approach demonstrates several best practices:
BallotBox
) and the persistent outcome account (ConsensusResult
) provides comprehensive validation.has_winning_ballot()
, is_consensus_reached()
) makes tests more readable and robust against internal representation changes.assert_eq!
) for key outcome data (winning status, epoch, weights) ensures exactness.vote_weight
) between the BallotBox
and ConsensusResult
confirms data consistency across different parts of the system.println!
) provides immediate feedback during test runs.This rigorous verification ensures the NCN system reliably achieves and records stake-weighted consensus according to its design.
After the core functionality has been tested and verified for a given epoch, the temporary accounts associated with that epoch can be closed to reclaim the SOL locked for rent. The persistent ConsensusResult
account remains.
Copy and paste the following code at the bottom of your test function:
// Close epoch accounts but keep consensus result
let epoch_before_closing_account = fixture.clock().await.epoch;
fixture.close_epoch_accounts_for_test_ncn(&test_ncn).await?;
// Verify that consensus_result account is not closed
{
let consensus_result = ncn_program_client
.get_consensus_result(ncn_pubkey, epoch_before_closing_account)
.await?;
assert!(consensus_result.is_consensus_reached());
assert_eq!(consensus_result.epoch(), epoch_before_closing_account);
}
This cleanup process involves:
epoch_before_closing_account
) just before initiating closure.fixture.close_epoch_accounts_for_test_ncn
, which likely iterates through epoch-specific accounts and invokes a close_epoch_account
instruction for each.ConsensusResult
account for the same epoch_before_closing_account
.ConsensusResult
still exists and retains its key data (is_consensus_reached
, epoch
), confirming it was not closed during the cleanup process.This demonstrates a crucial design feature:
ConsensusResult
) is preserved as a permanent on-chain record, suitable for historical lookups or use by other programs.This efficient cleanup mechanism allows the NCN system to operate continuously over many epochs without unbounded growth in account storage requirements.
Now you can save the file and run the test to see the result.