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
BioDAO Posts Bounty
Creator describes needed null result, sets BIO reward amount, and chooses deadline
Automatic Escrow
BIO tokens are immediately transferred to a program-controlled vault
Researcher Submits NKA
A researcher with matching NKA links their asset to the bounty
Creator Reviews
Bounty creator reviews submission and either approves or closes bounty
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:
Status Value Description Open 0Awaiting submissions, no NKA linked Matched 1An NKA has been submitted, awaiting approval Fulfilled 2Submission approved, payout complete Closed 3Creator 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