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.
Key stakeholders
| Role | Description |
|---|---|
| Protocol Admin | Controls global settings: creates hackathons, whitelists stakers, resolves outcomes. |
| Builder | Registers a project, pays a refundable deposit, and optionally self-stakes to signal conviction. |
| Staker | Backs one or more projects with USDC. Earns a share of the pool proportional to rank, stake, and timing. |
| Fee Recipient | Receives 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:
5QyJgZfUCLKZnoxSMu9ejraQ9365HrwBmn9WVPnUayDdAll 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. 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. 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. 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. 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. 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. 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. 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.
| Constraint | Value |
|---|---|
| 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 requires | Project must have deposit paid and be declared |
| Staking window closes | irl_hackathon_deadline_timestamp − 86,400 s (24 hours before results) |
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 escrowThe 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 time | Elapsed / Window | Multiplier | 1,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 |
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_000The protocol fee is then deducted from payout at claim time:
fee = payout × protocol_fee_bps / 10_000 // default 1.5%
staker_receives = payout − feeWorked 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).
| Project | Rank | Per-project | User Shares / Total | Gross Payout | Net (−1.5%) |
|---|---|---|---|---|---|
| A | 1st | 60% ($600) | 300 / 300 | $600 | $591 |
| B | 2nd | 20% ($200) | 300 / 300 | $200 | $197 |
On-Chain Constants
These values are compiled into lib.rs and apply to every hackathon unless otherwise overridable at initialization.
| Constant | Value | Description |
|---|---|---|
SELL_CUTOFF_SECS | 86,400 | Staking locks 24 h before irl_hackathon_deadline_timestamp |
UNSTAKE_PENALTY_BPS | 300 (3%) | Flat early-exit penalty on full stake |
UNSTAKE_PROTOCOL_BPS | 150 (1.5%) | Portion of penalty sent to fee_recipient |
EARLY_MULTIPLIER_BPS | 15,000 (1.5×) | Share multiplier at hackathon start |
BASE_MULTIPLIER_BPS | 10,000 (1.0×) | Share multiplier at cutoff |
MAX_STAKE_PER_WALLET | 250,000,000 | $250 USDC per wallet per project |
MAX_SELF_STAKE | 250,000,000 | $250 USDC builder self-stake cap |
DEFAULT_PROTOCOL_FEE_BPS | 150 (1.5%) | Protocol fee deducted at claim |
DEFAULT_DEPOSIT_AMOUNT | 10,000,000 | $10 USDC builder commitment deposit |
MAX_TIERS | 8 | Maximum number of rank tiers per hackathon |
PROTOCOL_ADMIN | Cqrz…BXjkD | Only 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.
| Account | Seeds |
|---|---|
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 |
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_accountslist 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 projects | Claim CU |
|---|---|
| 5 | 18,111 |
| 10 | 18,111 |
| 20 | 18,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_recipientProtocol 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 − feeprotocol_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.
deposit_forfeited = true, claim_deposit_refund will revert. This is enforced on-chain (Audit finding H-01 fix).