Skip to main content

Bounty Marketplace

The NullGraph Bounty Marketplace connects researchers who own Null Knowledge Assets (NKAs) with BioDAOs and research organizations willing to pay for specific negative results.
All bounties are denominated in BIO tokens and use automatic escrow — rewards are locked on-chain the moment a bounty is created.

How It Works

1

BioDAO Posts Bounty

Creator describes needed null result, sets BIO reward amount, and chooses deadline
2

Automatic Escrow

BIO tokens are immediately transferred to a program-controlled vault
3

Researcher Submits NKA

A researcher with matching NKA links their asset to the bounty
4

Creator Reviews

Bounty creator reviews submission and either approves or closes bounty
5

Instant Payout

On approval: 97.5% to researcher, 2.5% to protocol treasury

Bounty Account Structure

Each bounty is stored in a NullBounty PDA:
pub struct NullBounty {
    pub creator: Pubkey,              // Bounty poster wallet
    pub bounty_number: u64,           // Sequential ID (NB-0001, NB-0002...)
    pub description: [u8; 256],       // Description of null result needed
    pub reward_amount: u64,           // BIO reward in base units (6 decimals)
    pub usdc_mint: Pubkey,            // Token mint address (BIO)
    pub vault: Pubkey,                // Vault token account PDA
    pub deadline: i64,                // Deadline as Unix timestamp
    pub status: u8,                   // 0=Open, 1=Matched, 2=Fulfilled, 3=Closed
    pub matched_submission: Pubkey,   // BountySubmission PDA (zeroed if unmatched)
    pub created_at: i64,              // Unix timestamp
    pub vault_bump: u8,               // Vault PDA bump
    pub bump: u8,                     // PDA bump seed
}
PDA Seeds:
  • Bounty: ["null_bounty", creator_pubkey, bounty_number_le_bytes]
  • Vault: ["bounty_vault", bounty_pda_key]

Bounty Status Flow

Bounties transition through four states:
StatusValueDescription
Open0Awaiting submissions, no NKA linked
Matched1An NKA has been submitted, awaiting approval
Fulfilled2Submission approved, payout complete
Closed3Creator reclaimed funds, no longer active

Creating a Bounty

Instruction: create_bounty

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();
    // ...

    // Transfer BIO from creator to vault
    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 { /* ... */ });
    Ok(())
}

Escrow Guarantee

The moment create_bounty succeeds, BIO tokens leave the creator’s wallet and are locked in the vault. The creator cannot access these funds except by closing the bounty.

Frontend Example

const createBounty = async (
  program: Program,
  description: string,
  rewardAmount: number,  // in BIO (human-readable)
  deadlineDate: Date
) => {
  const wallet = useAnchorWallet();
  if (!wallet) throw new Error('Wallet not connected');

  const BIO_MINT = new PublicKey('BioTokenMintAddress...');
  const BIO_DECIMALS = 6;

  // Convert to base units
  const rewardBaseUnits = Math.floor(rewardAmount * Math.pow(10, BIO_DECIMALS));
  const deadline = Math.floor(deadlineDate.getTime() / 1000);

  // Derive PDAs
  const [protocolState] = PublicKey.findProgramAddressSync(
    [Buffer.from('protocol_state')],
    program.programId
  );

  const state = await program.account.protocolState.fetch(protocolState);
  const nextBountyNumber = state.bountyCounter.toNumber() + 1;

  const [bounty] = PublicKey.findProgramAddressSync(
    [
      Buffer.from('null_bounty'),
      wallet.publicKey.toBuffer(),
      Buffer.from(new BigUint64Array([BigInt(nextBountyNumber)]).buffer),
    ],
    program.programId
  );

  const [vault] = PublicKey.findProgramAddressSync(
    [Buffer.from('bounty_vault'), bounty.toBuffer()],
    program.programId
  );

  const creatorAta = getAssociatedTokenAddressSync(
    BIO_MINT,
    wallet.publicKey,
    false,
    TOKEN_2022_PROGRAM_ID
  );

  // Encode description
  const descBuffer = new Uint8Array(256);
  descBuffer.set(new TextEncoder().encode(description.slice(0, 256)));

  const tx = await program.methods
    .createBounty(
      Array.from(descBuffer),
      new BN(rewardBaseUnits),
      new BN(deadline)
    )
    .accounts({
      creator: wallet.publicKey,
      protocolState,
      bounty,
      vault,
      creatorUsdcAta: creatorAta,
      usdcMint: BIO_MINT,
    })
    .rpc();

  console.log(`Bounty NB-${String(nextBountyNumber).padStart(4, '0')} created`);
  console.log(`${rewardAmount} BIO escrowed in vault`);
};

Submitting to a Bounty

Instruction: submit_to_bounty

Researchers link their existing NKA to an open bounty:
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 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 = Clock::get()?.unix_timestamp;
    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 { /* ... */ });
    Ok(())
}
Security: The has_one = researcher constraint in the account context ensures only the NKA owner can submit it.

Submission Account

Each submission creates a BountySubmission PDA:
pub struct BountySubmission {
    pub researcher: Pubkey,       // Claimant wallet
    pub null_result: Pubkey,      // NullResult PDA key
    pub bounty: Pubkey,           // NullBounty PDA key
    pub status: u8,               // 0=Pending, 1=Approved, 2=Rejected
    pub created_at: i64,          // Unix timestamp
    pub bump: u8,                 // PDA bump seed
}
Seeds: ["bounty_submission", bounty_pda_key, null_result_pda_key]
Submission PDAs are unique per bounty-NKA pair, preventing duplicate submissions of the same NKA to the same bounty.

Approving a Submission

Instruction: approve_bounty_submission

Bounty creator approves the submission, triggering automatic payout:
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;
    
    // Calculate fee (default 2.5%)
    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 (97.5%)
    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 (2.5%)
    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(())
}

Atomic Payout

Both transfers (researcher + treasury) happen in a single transaction. Either both succeed or both fail.

Vault Authority

Vault uses PDA signer seeds to authorize transfers. No private keys involved.

Closing a Bounty

Instruction: close_bounty

Creators can reclaim escrowed BIO from Open or Matched bounties:
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(())
}
Cannot close Fulfilled bounties — once approved, the bounty is permanently marked as Fulfilled and vault is already empty.

BIO Token Integration

SPL Token Interface

All token operations use the SPL Token Interface (anchor-spl/token_interface):
use anchor_spl::token_interface::{
    Mint, TokenAccount, TokenInterface,
    transfer_checked, TransferChecked,
};

Why transfer_checked?

  • Validates mint — ensures tokens are actually BIO, not a fake mint
  • Validates decimals — prevents precision errors
  • Safer than transfer — explicit validation at instruction level
transfer_checked(
    ctx,
    amount,        // Raw base units (e.g., 1_000_000 = 1 BIO)
    decimals,      // Must match mint (6 for BIO)
)?;

BIO Decimals

BIO tokens use 6 decimals:
1 BIO = 1_000_000 base units
0.5 BIO = 500_000 base units
100 BIO = 100_000_000 base units
Frontend conversion:
const bioToBaseUnits = (bio: number) => Math.floor(bio * 1_000_000);
const baseUnitsToBio = (raw: number) => raw / 1_000_000;

Events

All bounty lifecycle actions emit events for indexing:
#[event]
pub struct BountyCreated {
    pub bounty_number: u64,
    pub creator: Pubkey,
    pub reward_amount: u64,
    pub deadline: i64,
}

#[event]
pub struct BountySubmissionCreated {
    pub bounty_number: u64,
    pub specimen_number: u64,
    pub researcher: Pubkey,
}

#[event]
pub struct BountyFulfilled {
    pub bounty_number: u64,
    pub specimen_number: u64,
    pub researcher: Pubkey,
    pub payout: u64,
    pub fee: u64,
}

#[event]
pub struct BountyClosed {
    pub bounty_number: u64,
    pub creator: Pubkey,
    pub refunded_amount: u64,
}

Security Model

Signer Checks

Only creator can approve/close their bounty (enforced via has_one = creator)

Status Guards

Each instruction validates current status before mutation

Submission Match

approve_bounty_submission verifies matched_submission PDA matches provided submission

Safe Math

All fee/payout calculations use checked_mul, checked_div, checked_sub

Error Handling

#[error_code]
pub enum NullGraphError {
    #[msg("Bounty is not in the expected status")]
    InvalidBountyStatus,
    #[msg("Submission is not in the expected status")]
    InvalidSubmissionStatus,
    #[msg("Matched submission mismatch")]
    SubmissionMismatch,
    #[msg("Reward amount must be > 0")]
    InvalidRewardAmount,
    #[msg("Fee calculation overflow")]
    FeeOverflow,
}

Browse Bounties

Fetch all bounties from the program:
const bounties = await program.account.nullBounty.all();

const openBounties = bounties.filter(b => b.account.status === 0);
const fulfilledBounties = bounties.filter(b => b.account.status === 2);

console.log(`Open: ${openBounties.length}`);
console.log(`Fulfilled: ${fulfilledBounties.length}`);

Next Steps

Protocol Fees

Deep dive into the 2.5% fee model and treasury distribution

BIO Integration

Learn about Bio Protocol alignment and token economics

Build docs developers (and LLMs) love