Skip to main content
The NullGraph program exposes 6 public instructions for protocol initialization, NKA submission, bounty lifecycle management, and payouts.

initialize_protocol

One-time setup creating the ProtocolState singleton with initial configuration. Location: lib.rs:18-35

Parameters

fee_basis_points
u16
required
Protocol fee rate in basis points (1/100th of 1%). Must be between 0-10000.Common values: 250 (2.5%), 100 (1%), 500 (5%)

Accounts

authority
Signer (mut)
required
Protocol admin wallet. Becomes the authority stored in ProtocolState. Pays for account creation.
protocol_state
Account<ProtocolState> (init)
required
The ProtocolState singleton PDA being initialized. Seeds: ["protocol_state"]
treasury
UncheckedAccount
required
Treasury wallet address for fee collection. Not validated on-chain; admin must ensure it has a BIO token account.
system_program
Program<System>
required
Solana System Program for account creation.

Behavior

  1. Creates the ProtocolState PDA with space 8 + ProtocolState::INIT_SPACE
  2. Sets authority to the signer’s pubkey
  3. Initializes both counters (nka_counter, bounty_counter) to 0
  4. Stores the fee_basis_points parameter
  5. Stores the treasury pubkey
  6. Saves the PDA bump seed
  7. Emits ProtocolInitialized event

Code Reference

pub fn initialize_protocol(
    ctx: Context<InitializeProtocol>,
    fee_basis_points: u16,
) -> Result<()> {
    let state = &mut ctx.accounts.protocol_state;
    state.authority = ctx.accounts.authority.key();
    state.nka_counter = 0;
    state.bounty_counter = 0;
    state.fee_basis_points = fee_basis_points;
    state.treasury = ctx.accounts.treasury.key();
    state.bump = ctx.bumps.protocol_state;

    emit!(ProtocolInitialized {
        authority: state.authority,
        fee_basis_points,
    });
    Ok(())
}
This instruction can only be called once per program deployment. Attempting to call it again will fail with an “Account already exists” error from Anchor.

submit_null_result

Submits a new Null Knowledge Asset (NKA) to the protocol. Any wallet can call this. Location: lib.rs:37-70

Parameters

hypothesis
[u8; 128]
required
UTF-8 encoded hypothesis tested. Maximum 128 bytes. Pad with zeros if shorter.
methodology
[u8; 128]
required
UTF-8 encoded methodology summary. Maximum 128 bytes. Pad with zeros if shorter.
expected_outcome
[u8; 128]
required
UTF-8 encoded expected outcome description. Maximum 128 bytes. Pad with zeros if shorter.
actual_outcome
[u8; 128]
required
UTF-8 encoded actual outcome description. Maximum 128 bytes. Pad with zeros if shorter.
p_value
u32
required
Statistical p-value as fixed-point integer (multiply by 10000). Example: 4200 = 0.42
sample_size
u32
required
Number of subjects or observations in the experiment.
data_hash
[u8; 32]
required
SHA-256 hash of the underlying dataset. Can be all zeros if no file attached.

Accounts

researcher
Signer (mut)
required
The researcher submitting the NKA. Pays for NullResult PDA creation.
protocol_state
Account<ProtocolState> (mut)
required
The protocol state singleton. nka_counter will be incremented.
null_result
Account<NullResult> (init)
required
The NullResult PDA being created. Seeds: ["null_result", researcher.key(), &(nka_counter + 1).to_le_bytes()]
system_program
Program<System>
required
Solana System Program for account creation.

Behavior

  1. Increments protocol_state.nka_counter by 1
  2. Assigns the new counter value as specimen_number
  3. Creates the NullResult PDA with all provided fields
  4. Sets status to 0 (Pending)
  5. Records the current timestamp via Clock::get()?.unix_timestamp
  6. Saves the PDA bump seed
  7. Emits NullResultSubmitted event with specimen_number and researcher

Code Reference

pub fn submit_null_result(
    ctx: Context<SubmitNullResult>,
    hypothesis: [u8; 128],
    methodology: [u8; 128],
    expected_outcome: [u8; 128],
    actual_outcome: [u8; 128],
    p_value: u32,
    sample_size: u32,
    data_hash: [u8; 32],
) -> Result<()> {
    let state = &mut ctx.accounts.protocol_state;
    state.nka_counter += 1;
    let specimen_number = state.nka_counter;

    let nr = &mut ctx.accounts.null_result;
    nr.researcher = ctx.accounts.researcher.key();
    nr.specimen_number = specimen_number;
    nr.hypothesis = hypothesis;
    nr.methodology = methodology;
    nr.expected_outcome = expected_outcome;
    nr.actual_outcome = actual_outcome;
    nr.p_value = p_value;
    nr.sample_size = sample_size;
    nr.data_hash = data_hash;
    nr.status = 0; // Pending
    nr.created_at = Clock::get()?.unix_timestamp;
    nr.bump = ctx.bumps.null_result;

    emit!(NullResultSubmitted {
        specimen_number,
        researcher: nr.researcher,
    });
    Ok(())
}

create_bounty

Creates a new bounty and escrows BIO tokens into a PDA-controlled vault. Location: lib.rs:72-121

Parameters

description
[u8; 256]
required
UTF-8 encoded description of the desired null result. Maximum 256 bytes. Pad with zeros if shorter.
reward_amount
u64
required
BIO token reward in base units (6 decimals). Must be > 0 or instruction fails with InvalidRewardAmount.Example: 1000000 = 1 BIO
deadline
i64
required
Unix timestamp deadline for submissions. Currently not enforced by program logic.

Accounts

creator
Signer (mut)
required
Bounty creator wallet. Pays for PDA creation and must approve BIO token transfer.
protocol_state
Account<ProtocolState> (mut)
required
Protocol state singleton. bounty_counter will be incremented.
bounty
Account<NullBounty> (init)
required
The NullBounty PDA being created. Seeds: ["null_bounty", creator.key(), &(bounty_counter + 1).to_le_bytes()]
vault
InterfaceAccount<TokenAccount> (init)
required
The vault token account PDA for escrowed BIO tokens. Seeds: ["bounty_vault", bounty.key()]. Authority is the vault itself.
creator_usdc_ata
InterfaceAccount<TokenAccount> (mut)
required
Creator’s BIO token associated token account. Must have sufficient balance for reward_amount. Field name is creator_usdc_ata in code but holds BIO tokens.
usdc_mint
InterfaceAccount<Mint>
required
BIO token mint address. Field name is usdc_mint in code but refers to the BIO mint. Used to validate the transfer and derive decimals (6).
token_program
Interface<TokenInterface>
required
SPL Token program for CPI transfer.
associated_token_program
Program<AssociatedToken>
required
Associated Token program for vault creation.
system_program
Program<System>
required
System program for account creation.

Validation

  • reward_amount > 0 (line 78) → else returns NullGraphError::InvalidRewardAmount

Behavior

  1. Validates reward_amount > 0
  2. Increments protocol_state.bounty_counter by 1
  3. Assigns the new counter value as bounty_number
  4. Creates the NullBounty PDA with all provided fields
  5. Initializes the vault token account with authority set to the vault itself
  6. Executes transfer_checked CPI transferring reward_amount BIO tokens from creator’s ATA to vault
  7. Sets bounty status to 0 (Open) and matched_submission to Pubkey::default()
  8. Records timestamp and saves bump seeds
  9. Emits BountyCreated event

Code Reference

pub fn create_bounty(
    ctx: Context<CreateBounty>,
    description: [u8; 256],
    reward_amount: u64,
    deadline: i64,
) -> Result<()> {
    require!(reward_amount > 0, NullGraphError::InvalidRewardAmount);

    let state = &mut ctx.accounts.protocol_state;
    state.bounty_counter += 1;
    let bounty_number = state.bounty_counter;

    let bounty = &mut ctx.accounts.bounty;
    bounty.creator = ctx.accounts.creator.key();
    bounty.bounty_number = bounty_number;
    bounty.description = description;
    bounty.reward_amount = reward_amount;
    bounty.usdc_mint = ctx.accounts.usdc_mint.key();
    bounty.vault = ctx.accounts.vault.key();
    bounty.deadline = deadline;
    bounty.status = 0; // Open
    bounty.matched_submission = Pubkey::default();
    bounty.created_at = Clock::get()?.unix_timestamp;
    bounty.vault_bump = ctx.bumps.vault;
    bounty.bump = ctx.bumps.bounty;

    // Transfer USDC from creator to vault
    let decimals = ctx.accounts.usdc_mint.decimals;
    transfer_checked(
        CpiContext::new(
            ctx.accounts.token_program.to_account_info(),
            TransferChecked {
                from: ctx.accounts.creator_usdc_ata.to_account_info(),
                mint: ctx.accounts.usdc_mint.to_account_info(),
                to: ctx.accounts.vault.to_account_info(),
                authority: ctx.accounts.creator.to_account_info(),
            },
        ),
        reward_amount,
        decimals,
    )?;

    emit!(BountyCreated {
        bounty_number,
        creator: bounty.creator,
        reward_amount,
        deadline,
    });
    Ok(())
}

submit_to_bounty

Links a researcher’s existing NKA to an open bounty, transitioning the bounty to Matched status. Location: lib.rs:123-153

Parameters

None. The instruction derives all data from the provided accounts.

Accounts

researcher
Signer (mut)
required
The researcher submitting their NKA. Must be the owner of the null_result account. Pays for BountySubmission PDA creation.
null_result
Account<NullResult>
required
The NullResult PDA being submitted. Must satisfy has_one = researcher constraint.
bounty
Account<NullBounty> (mut)
required
The target bounty. Must have status 0 (Open).
submission
Account<BountySubmission> (init)
required
The BountySubmission PDA being created. Seeds: ["bounty_submission", bounty.key(), null_result.key()]
system_program
Program<System>
required
System program for account creation.

Validation

  • bounty.status == 0 (line 125) → else returns NullGraphError::InvalidBountyStatus
  • null_result.researcher == researcher.key() (enforced by Anchor has_one constraint)

Behavior

  1. Validates bounty status is 0 (Open)
  2. Creates the BountySubmission PDA
  3. Sets submission.researcher, submission.null_result, submission.bounty
  4. Sets submission.status to 0 (Pending)
  5. Records timestamp
  6. Updates bounty.status to 1 (Matched)
  7. Sets bounty.matched_submission to the new submission PDA’s address
  8. Emits BountySubmissionCreated event

Code Reference

pub fn submit_to_bounty(ctx: Context<SubmitToBounty>) -> Result<()> {
    let bounty_status = ctx.accounts.bounty.status;
    require!(bounty_status == 0, NullGraphError::InvalidBountyStatus); // Must be Open

    let researcher_key = ctx.accounts.researcher.key();
    let null_result_key = ctx.accounts.null_result.key();
    let bounty_key = ctx.accounts.bounty.key();
    let specimen_number = ctx.accounts.null_result.specimen_number;
    let now = Clock::get()?.unix_timestamp;

    let submission = &mut ctx.accounts.submission;
    submission.researcher = researcher_key;
    submission.null_result = null_result_key;
    submission.bounty = bounty_key;
    submission.status = 0; // Pending
    submission.created_at = now;
    submission.bump = ctx.bumps.submission;

    let submission_key = submission.key();

    let bounty = &mut ctx.accounts.bounty;
    bounty.status = 1; // Matched
    bounty.matched_submission = submission_key;

    emit!(BountySubmissionCreated {
        bounty_number: bounty.bounty_number,
        specimen_number,
        researcher: researcher_key,
    });
    Ok(())
}
The PDA seeds ensure each NKA can only be submitted to a given bounty once. Attempting to submit the same NKA to the same bounty twice will fail with “Account already exists”.

approve_bounty_submission

Bounty creator approves a submission, triggering automatic payout minus protocol fee. Location: lib.rs:155-229

Parameters

None. All data derived from accounts.

Accounts

creator
Signer (mut)
required
Bounty creator. Must match bounty.creator.
bounty
Box<Account<NullBounty>> (mut)
required
The bounty being fulfilled. Must satisfy has_one = creator constraint. Must have status 1 (Matched).
submission
Box<Account<BountySubmission>> (mut)
required
The submission being approved. Must match bounty.matched_submission. Must have status 0 (Pending).
null_result
Box<Account<NullResult>>
required
The NullResult linked to the submission. Used to emit specimen_number in event.
vault
InterfaceAccount<TokenAccount> (mut)
required
The bounty’s vault token account. Seeds: ["bounty_vault", bounty.key()]
researcher_usdc_ata
InterfaceAccount<TokenAccount> (mut)
required
Researcher’s BIO token associated token account. Receives reward - fee. Field name is researcher_usdc_ata in code but holds BIO tokens.
treasury_usdc_ata
InterfaceAccount<TokenAccount> (mut)
required
Treasury BIO token associated token account. Receives protocol fee. Field name is treasury_usdc_ata in code but holds BIO tokens.
protocol_state
Box<Account<ProtocolState>>
required
Protocol state to read fee_basis_points.
usdc_mint
InterfaceAccount<Mint>
required
BIO token mint for decimal validation in transfers. Field name is usdc_mint in code but refers to the BIO mint.
token_program
Interface<TokenInterface>
required
SPL Token program for CPI transfers.

Validation

  • bounty.status == 1 (line 157) → else returns NullGraphError::InvalidBountyStatus
  • bounty.matched_submission == submission.key() (line 158) → else returns NullGraphError::SubmissionMismatch
  • submission.status == 0 (line 164) → else returns NullGraphError::InvalidSubmissionStatus
  • All arithmetic operations use checked_mul, checked_div, checked_sub → else returns NullGraphError::FeeOverflow

Behavior

  1. Validates bounty status, submission match, and submission status
  2. Calculates fee: fee = (reward_amount * fee_basis_points) / 10000
  3. Calculates payout: payout = reward_amount - fee
  4. Derives vault signer seeds: ["bounty_vault", bounty.key(), &[bounty.vault_bump]]
  5. Transfers payout BIO tokens from vault to researcher via CPI with signer
  6. If fee > 0, transfers fee BIO tokens from vault to treasury via CPI with signer
  7. Updates bounty.status to 2 (Fulfilled)
  8. Updates submission.status to 1 (Approved)
  9. Emits BountyFulfilled event with breakdown

Code Reference

pub fn approve_bounty_submission(ctx: Context<ApproveBountySubmission>) -> Result<()> {
    let bounty = &mut ctx.accounts.bounty;
    require!(bounty.status == 1, NullGraphError::InvalidBountyStatus); // Must be Matched
    require!(
        bounty.matched_submission == ctx.accounts.submission.key(),
        NullGraphError::SubmissionMismatch
    );

    let submission = &mut ctx.accounts.submission;
    require!(submission.status == 0, NullGraphError::InvalidSubmissionStatus); // Must be Pending

    let protocol = &ctx.accounts.protocol_state;
    let fee_bps = protocol.fee_basis_points as u64;
    let total = bounty.reward_amount;
    let fee = total
        .checked_mul(fee_bps)
        .ok_or(NullGraphError::FeeOverflow)?
        .checked_div(10_000)
        .ok_or(NullGraphError::FeeOverflow)?;
    let payout = total.checked_sub(fee).ok_or(NullGraphError::FeeOverflow)?;

    let decimals = ctx.accounts.usdc_mint.decimals;
    let bounty_key = bounty.key();
    let vault_seeds: &[&[u8]] = &[
        b"bounty_vault",
        bounty_key.as_ref(),
        &[bounty.vault_bump],
    ];

    // Pay researcher
    transfer_checked(
        CpiContext::new_with_signer(
            ctx.accounts.token_program.to_account_info(),
            TransferChecked {
                from: ctx.accounts.vault.to_account_info(),
                mint: ctx.accounts.usdc_mint.to_account_info(),
                to: ctx.accounts.researcher_usdc_ata.to_account_info(),
                authority: ctx.accounts.vault.to_account_info(),
            },
            &[vault_seeds],
        ),
        payout,
        decimals,
    )?;

    // Pay treasury fee
    if fee > 0 {
        transfer_checked(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                TransferChecked {
                    from: ctx.accounts.vault.to_account_info(),
                    mint: ctx.accounts.usdc_mint.to_account_info(),
                    to: ctx.accounts.treasury_usdc_ata.to_account_info(),
                    authority: ctx.accounts.vault.to_account_info(),
                },
                &[vault_seeds],
            ),
            fee,
            decimals,
        )?;
    }

    bounty.status = 2; // Fulfilled
    submission.status = 1; // Approved

    emit!(BountyFulfilled {
        bounty_number: bounty.bounty_number,
        specimen_number: ctx.accounts.null_result.specimen_number,
        researcher: submission.researcher,
        payout,
        fee,
    });
    Ok(())
}
With the default fee of 250 basis points (2.5%), a 100 BIO bounty pays 97.5 BIO to the researcher and 2.5 BIO to the treasury.

close_bounty

Bounty creator reclaims escrowed BIO tokens from an Open or Matched bounty, setting status to Closed. Location: lib.rs:231-273

Parameters

None. All data derived from accounts.

Accounts

creator
Signer (mut)
required
Bounty creator. Must match bounty.creator.
bounty
Account<NullBounty> (mut)
required
The bounty being closed. Must satisfy has_one = creator constraint. Must have status 0 (Open) or 1 (Matched).
vault
InterfaceAccount<TokenAccount> (mut)
required
The bounty’s vault token account. Seeds: ["bounty_vault", bounty.key()]
creator_usdc_ata
InterfaceAccount<TokenAccount> (mut)
required
Creator’s BIO token associated token account. Receives the refund. Field name is creator_usdc_ata in code but holds BIO tokens.
usdc_mint
InterfaceAccount<Mint>
required
BIO token mint for decimal validation. Field name is usdc_mint in code but refers to the BIO mint.
token_program
Interface<TokenInterface>
required
SPL Token program for CPI transfer.

Validation

  • bounty.status == 0 || bounty.status == 1 (line 233) → else returns NullGraphError::InvalidBountyStatus

Behavior

  1. Validates bounty status is Open (0) or Matched (1)
  2. Reads the vault’s current token balance
  3. Derives vault signer seeds: ["bounty_vault", bounty.key(), &[bounty.vault_bump]]
  4. If vault_balance > 0, transfers entire balance back to creator via CPI with signer
  5. Updates bounty.status to 3 (Closed)
  6. Emits BountyClosed event with refunded amount

Code Reference

pub fn close_bounty(ctx: Context<CloseBounty>) -> Result<()> {
    let bounty = &mut ctx.accounts.bounty;
    require!(
        bounty.status == 0 || bounty.status == 1,
        NullGraphError::InvalidBountyStatus
    );

    let vault_balance = ctx.accounts.vault.amount;
    let decimals = ctx.accounts.usdc_mint.decimals;
    let bounty_key = bounty.key();
    let vault_seeds: &[&[u8]] = &[
        b"bounty_vault",
        bounty_key.as_ref(),
        &[bounty.vault_bump],
    ];

    // Refund creator
    if vault_balance > 0 {
        transfer_checked(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.to_account_info(),
                TransferChecked {
                    from: ctx.accounts.vault.to_account_info(),
                    mint: ctx.accounts.usdc_mint.to_account_info(),
                    to: ctx.accounts.creator_usdc_ata.to_account_info(),
                    authority: ctx.accounts.vault.to_account_info(),
                },
                &[vault_seeds],
            ),
            vault_balance,
            decimals,
        )?;
    }

    bounty.status = 3; // Closed

    emit!(BountyClosed {
        bounty_number: bounty.bounty_number,
        creator: bounty.creator,
        refunded_amount: vault_balance,
    });
    Ok(())
}
Once a bounty is approved (status 2 - Fulfilled), it cannot be closed. Attempting to call close_bounty on a fulfilled bounty will fail with InvalidBountyStatus.

Build docs developers (and LLMs) love