Protocol v1

HACKBET Protocol Reference

Complete technical reference for the HackBet rank-weighted conviction pool protocol. Covers staking mechanics, payout formulas, on-chain account structures, and admin operations.

Overview

HackBet is a Solana-native conviction pool protocol built for hackathons. Participants stake USDC behind hackathon projects before results are announced. When the official judges publish rankings, the total pool is redistributed to backers of well-ranked projects using a formula that rewards both early conviction and diversification.

HackBet is not a prediction market. There are no orderbooks, no counterparties, and no binary outcomes. It is a rank-weighted, crowd-adjusted conviction pool.

Key stakeholders

RoleDescription
Protocol AdminControls global settings: creates hackathons, whitelists stakers, resolves outcomes.
BuilderRegisters a project, pays a refundable deposit, and optionally self-stakes to signal conviction.
StakerBacks one or more projects with USDC. Earns a share of the pool proportional to rank, stake, and timing.
Fee RecipientReceives the protocol fee (default 1.5%) on each successful claim, and 1.5% of each early-exit penalty.

Where the protocol lives

The program is deployed on Solana mainnet at:

5QyJgZfUCLKZnoxSMu9ejraQ9365HrwBmn9WVPnUayDd

All funds are held in a per-hackathon USDC escrow PDA. No admin key can drain funds directly — only resolution-gated claim instructions can move tokens to stakers.

Protocol Lifecycle

Each hackathon follows a strict on-chain state machine. The diagram below shows the full flow from creation to payout.

1

1. Initialize Hackathon

Admin calls initialize_hackathon with name, USDC mint, tier percentages, deposit amount, and timestamps. Creates the HackathonState PDA and escrow token account.

2

2. Register Projects

Builders call register_project with their GitHub URL. PDA is seeded with sha256(github_url) — duplicate URLs revert automatically. If requires_approval is set, admin must whitelist first.

3

3. Staking Window

Stakers call stake() to deposit USDC into escrow. Shares are computed at stake time using the time-weighted multiplier (1.5× early → 1.0× at cutoff). UserStake PDAs are initialized.

4

4. Cutoff (−24 h)

irl_hackathon_deadline_timestamp − 86 400 seconds. All staking locks. Unstaking with a 3% penalty remains available until cutoff. At and after cutoff, unstaking is fully disabled.

5

5. Resolve

Admin calls resolve_project for each project to assign its rank (1-indexed, 0 = unranked). This is reversible until finalize_resolve is called.

6

6. Finalize Resolve

Admin calls finalize_resolve once all ranks are set. This snapshots tier_c_totals and effective_tier_pcts onto HackathonState — the values that claim will use. Irreversible.

7

7. Claim

Each staker calls claim() with their UserStake PDA. Payout is computed from snapshotted values — no iteration over remaining_accounts. Protocol fee is deducted at this point.

Staking & Unstaking

Staking

Any whitelisted wallet (or any wallet when requires_approval = false) can stake USDC behind a registered project from the moment the hackathon is initialized until the cutoff timestamp.

ConstraintValue
Max stake per wallet per project$250 USDC (250,000,000 μUSDC)
Max builder self-stake$250 USDC (250,000,000 μUSDC)
Builder self-stake requiresProject must have deposit paid and be declared
Staking window closesirl_hackathon_deadline_timestamp − 86,400 s (24 hours before results)
Amounts are always stored in micro-USDC (6 decimal places). 1 USDC = 1,000,000 μUSDC. All on-chain math uses integer arithmetic — no floats.

Unstaking

A staker can exit their position at any time before the cutoff, subject to a flat 3% penalty on the full staked amount. There is no time-based decay — the penalty is the same whether you exit one minute or one month after staking.

penalty = stake_amount × 300 / 10_000 // 3% flat to_protocol = stake_amount × 150 / 10_000 // 1.5% → fee recipient stays_in_pool = penalty − to_protocol // 1.5% stays in escrow

The staker receives stake_amount − penalty. After cutoff (now ≥ cutoff_timestamp), unstaking is fully disabled — positions are locked until claim.

Time-Weighted Shares

Shares determine each staker's fraction of a project's total stake pool. They are computed at stake time and never change — unstaking burns your shares entirely.

window = cutoff_timestamp − hackathon.start_timestamp elapsed = clamp(now − start_timestamp, 0, window) mult_bps = 15_000 − floor(5_000 × elapsed / window) // 15000 → 10000 shares = floor(amount × mult_bps / 10_000)

The multiplier decreases linearly from 1.5× at the hackathon start to 1.0× at the cutoff. Stakers who commit early receive proportionally more shares for the same USDC amount.

Example

Stake timeElapsed / WindowMultiplier1,000 USDC → Shares
T+0 (open)0%1.5× (15,000 bps)1,500
T+25%25%1.375× (13,750 bps)1,375
T+50%50%1.25× (12,500 bps)1,250
T+75%75%1.125× (11,250 bps)1,125
T+100% (cutoff)100%1.0× (10,000 bps)1,000
The builder self-stake, if any, is computed by the same formula. A builder who stakes at genesis gets the maximum 1.5× boost.

Payout Formula

The payout calculation runs in two stages: first allocate the prize pool across rank tiers, then distribute equally within each tier.

Stage 1 — Tier allocation

The admin configures up to 8 tiers at hackathon creation, each with a percentage (must sum to 100). For example: [12, 88] means the rank-1 project gets 12% of the pool, and 22 rank-2 projects share 88% equally (4% each).

At finalize_resolve, empty tiers (no projects assigned that rank) have their allocation redistributed proportionally to occupied tiers. The resultingeffective_tier_pcts are stored on HackathonState.

Stage 2 — Equal split (within tier)

Every ranked project in a tier gets an equal share of that tier's pool. The number of ranked projects per tier (N) is counted and snapshotted at finalize_resolve.

N = hackathon.tier_c_totals[tier] // project count, snapshotted payout = user_shares × effective_tier_pcts[tier] × total_pool ────────────────────────────────────────── project.total_shares × N × 10_000

The protocol fee is then deducted from payout at claim time:

fee = payout × protocol_fee_bps / 10_000 // default 1.5% staker_receives = payout − fee

Worked example

Config: 1st = 12%, 2nd = 88% (22 expected). Pool = $1,000. Fee = 1.5%. Alice stakes $200 on Project A (1st), Bob stakes $200 on Project B (2nd). Both stake Day 0 (1.5× multiplier = 300 shares each).

ProjectRankPer-projectUser Shares / TotalGross PayoutNet (−1.5%)
A1st60% ($600)300 / 300$600$591
B2nd20% ($200)300 / 300$200$197
Tier 1 draws 12% × 1 project = 1,200 bps. Tier 2 draws 4% × 1 project = 400 bps. The unused 8,400 bps cascades 3:1 to the occupied tiers — 1st draws 3× more per project (12%) than 2nd (4%), so it gets 3× more cascade. Final: 60% vs 20%. Higher rank always earns more per project.

On-Chain Constants

These values are compiled into lib.rs and apply to every hackathon unless otherwise overridable at initialization.

ConstantValueDescription
SELL_CUTOFF_SECS86,400Staking locks 24 h before irl_hackathon_deadline_timestamp
UNSTAKE_PENALTY_BPS300 (3%)Flat early-exit penalty on full stake
UNSTAKE_PROTOCOL_BPS150 (1.5%)Portion of penalty sent to fee_recipient
EARLY_MULTIPLIER_BPS15,000 (1.5×)Share multiplier at hackathon start
BASE_MULTIPLIER_BPS10,000 (1.0×)Share multiplier at cutoff
MAX_STAKE_PER_WALLET250,000,000$250 USDC per wallet per project
MAX_SELF_STAKE250,000,000$250 USDC builder self-stake cap
DEFAULT_PROTOCOL_FEE_BPS150 (1.5%)Protocol fee deducted at claim
DEFAULT_DEPOSIT_AMOUNT10,000,000$10 USDC builder commitment deposit
MAX_TIERS8Maximum number of rank tiers per hackathon
PROTOCOL_ADMINCqrz…BXjkDOnly wallet that can initialize hackathons

protocol_fee_bps is configurable per hackathon but is capped at3000 (30%) as an on-chain constraint.

Account Structures

HackathonState

One per hackathon. Holds all configuration, state, and post-resolution snapshots.

HackathonState { admin: Pubkey, usdc_mint: Pubkey, name: String, // max 50 bytes; also used in PDA seed start_timestamp: i64, irl_hackathon_deadline_timestamp: i64, cutoff_timestamp: i64, // = irl_hackathon_deadline_timestamp − 86_400 total_pool: u64, // running USDC balance in escrow is_resolved: bool, tier_count: u8, tier_pcts: [u8; 8], // raw percentages, must sum to 100 effective_tier_pcts: [u16; 8], // redistributed — set at finalize_resolve tier_c_totals: [u64; 8], // number of payout-eligible projects per tier — set at finalize_resolve fee_recipient: Pubkey, protocol_fee_bps: u16, // capped at 3000 deposit_amount: u64, requires_approval: bool, bump: u8, }

ProjectAccount

One per project per hackathon.

ProjectAccount { hackathon: Pubkey, github_url: String, // max 200 chars; sha256 used for PDA seed total_staked: u64, total_shares: u64, rank: u8, // 0 = unresolved; 1-indexed builder_wallet: Pubkey, deposit_amount_paid: u64, builder_staked: u64, builder_declared: bool, submitted: bool, is_refund_enabled: bool, deposit_forfeited: bool, deposit_refunded: bool, bump: u8, }

UserStake

One per (user, project) pair. Holds the raw stake and computed shares.

UserStake { user: Pubkey, project: Pubkey, amount: u64, shares: u64, stake_timestamp: i64, is_claimed: bool, bump: u8, }

WhitelistedWallet

Existence of this PDA grants a wallet permission to stake when requires_approval = true.

WhitelistedWallet { hackathon: Pubkey, wallet: Pubkey, bump: u8, }

PDA Seeds

All program-derived addresses are deterministic from the seeds below.

AccountSeeds
HackathonState"hackathon" + admin pubkey + name (UTF-8 bytes)
ProjectAccount"project" + hackathon pubkey + sha256(github_url)
UserStake"stake" + user pubkey + project pubkey
Escrow (token acct)"escrow" + hackathon pubkey
WhitelistedWallet"whitelist" + hackathon pubkey + wallet pubkey
ProjectAccount uses the SHA-256 hash of the URL, not the raw bytes. This is critical: passing the wrong hash will derive a different PDA address and the transaction will fail. Always derive the hash client-side before constructing the instruction.

All helpers live in frontend/lib/pda.ts.

Claim Architecture

Early versions of the protocol computed C_total_t inside the claim instruction by iterating all project accounts passed as remaining_accounts. This had two problems:

  • CU cost scaled linearly with the number of projects (O(N)).
  • A malicious caller could pass a crafted remaining_accounts list that manipulated the denominator and inflated their payout (Audit finding C-01).

The fix (C-01)

finalize_resolve now iterates all projects and snapshots the per-tier project count into hackathon.tier_c_totals[t] (equal split — no sqrt weighting). The claim instruction reads this value directly fromHackathonState — no remaining_accounts needed.

// finalize_resolve — runs once, O(N): for each project p in tier t: tier_c_totals[t] += 1 // count projects per tier (equal split) // claim — O(1), caller-manipulation-proof: let N = hackathon.tier_c_totals[project.rank − 1]

CU benchmark (Bankrun, post-fix)

N projectsClaim CU
518,111
1018,111
2018,111

Flat CU — independent of the number of projects in the hackathon.

Fee Architecture

The protocol charges fees at two distinct events:

Early-exit penalty (unstake)

Applied whenever a staker withdraws before the cutoff. The full 3% is split: 1.5% leaves the escrow and goes to fee_recipient; the remaining 1.5% stays in the escrow and becomes part of the prize pool for remaining stakers.

penalty = amount × 300 / 10_000 // 3% to_fee_recipient = amount × 150 / 10_000 // 1.5% exits escrow stays_in_pool = penalty − to_fee_recipient

Protocol fee (claim)

Deducted from each winning staker's gross payout at claim time.

fee = gross_payout × protocol_fee_bps / 10_000 // default 1.5% staker_receives = gross_payout − fee

protocol_fee_bps is set per-hackathon at initialization and capped at 3,000 (30%) by the program. If 0, no fee is deducted and no transfer to fee_recipient occurs.

Builder deposit

Builders pay a configurable deposit (default $10 USDC) when registering. This commitment deposit is added to the prize pool and refundable only if the project is formally submitted and the admin enables refunds. If the builder fails to submit, the deposit is forfeited to the pool.

If deposit_forfeited = true, claim_deposit_refund will revert. This is enforced on-chain (Audit finding H-01 fix).