Skip to content

Commit abf6234

Browse files
Edward (Edwardbot)mikemaccana-edwardbot
authored andcommitted
feat(tokens/hackathon): add Squads-multisig hackathon prize program
A new Anchor 1.0 example: a hackathon prize program whose authority is an external Squads multisig vault PDA. The onchain program is multisig-agnostic - it stores an opaque `authority` pubkey and checks `signer == authority` on each privileged handler. Squads handles propose/vote/execute off-program. Instruction handlers: - create_hackathon: open a hackathon under `authority` - add_prize: register a prize with its own mint and vault ATA - set_winner: record the winning pubkey for a prize (multisig) - pay_winner: transfer exactly `prize.amount` to the recorded winner. Unpermissioned: anyone can call once the winner is set and the vault is funded. - cancel_prize: drain the vault back to a refund target and lock the prize (multisig) - close_hackathon: refund Hackathon rent once every prize is paid or cancelled (multisig) Uses the SPL Token Interface throughout so the same compiled program works for both classic SPL Token and Token-2022 mints. Per-prize mint and per-prize vault PDA so one hackathon can mix denominations and surface a clean PDA-derivation pattern. This commit is program code + smoke build only; LiteSVM tests with a real Squads 2-of-3 (Alice/Bob/Carol) committee follow in the next commit.
1 parent 8abb5d1 commit abf6234

18 files changed

Lines changed: 688 additions & 0 deletions

File tree

tokens/hackathon/anchor/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.anchor
2+
.DS_Store
3+
target
4+
**/*.rs.bk
5+
node_modules
6+
test-ledger
7+
.yarn
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[toolchain]
2+
solana_version = "3.1.8"
3+
4+
[features]
5+
resolution = true
6+
skip-lint = false
7+
8+
[programs.localnet]
9+
hackathon = "71AxoNytgqQrSFMvGREPeJ1E2btEoTMw8J4FALsmNcGx"
10+
11+
[provider]
12+
cluster = "Localnet"
13+
wallet = "~/.config/solana/id.json"
14+
15+
[scripts]
16+
test = "cargo test"

tokens/hackathon/anchor/Cargo.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[workspace]
2+
members = [
3+
"programs/*"
4+
]
5+
resolver = "2"
6+
7+
[profile.release]
8+
overflow-checks = true
9+
lto = "fat"
10+
codegen-units = 1
11+
12+
[profile.release.build-override]
13+
opt-level = 3
14+
incremental = false
15+
codegen-units = 1
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "hackathon"
3+
version = "0.1.0"
4+
description = "Created with Anchor"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "hackathon"
10+
11+
[features]
12+
default = []
13+
cpi = ["no-entrypoint"]
14+
no-entrypoint = []
15+
no-idl = []
16+
no-log-ix-name = []
17+
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
18+
anchor-debug = []
19+
custom-heap = []
20+
custom-panic = []
21+
22+
[dependencies]
23+
anchor-lang = "1.0.0"
24+
anchor-spl = "1.0.0"
25+
sha2 = { version = "0.10", default-features = false }
26+
27+
[dev-dependencies]
28+
litesvm = "0.11.0"
29+
solana-signer = "3.0.0"
30+
solana-keypair = "3.0.1"
31+
solana-kite = "0.3.0"
32+
borsh = "1.6.1"
33+
34+
[lints.rust]
35+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[target.bpfel-unknown-unknown.dependencies.std]
2+
features = []
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use anchor_lang::prelude::*;
2+
3+
#[error_code]
4+
pub enum HackathonError {
5+
#[msg("Hackathon name must not be empty")]
6+
EmptyName,
7+
#[msg("Hackathon name exceeds the maximum length")]
8+
NameTooLong,
9+
#[msg("Prize has already been paid")]
10+
AlreadyPaid,
11+
#[msg("Prize has been cancelled")]
12+
Cancelled,
13+
#[msg("Prize has no winner set")]
14+
NoWinner,
15+
#[msg("Recorded winner does not match the supplied winner token account owner")]
16+
WinnerMismatch,
17+
#[msg("Vault balance is less than the prize amount")]
18+
Underfunded,
19+
#[msg("Prize counter overflow: this hackathon already holds the maximum prizes")]
20+
PrizeCounterOverflow,
21+
#[msg("Cannot close hackathon: at least one prize is still active")]
22+
PrizesStillActive,
23+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
use anchor_lang::prelude::*;
2+
use anchor_spl::{
3+
associated_token::AssociatedToken,
4+
token_interface::{Mint, TokenAccount, TokenInterface},
5+
};
6+
7+
use crate::error::HackathonError;
8+
use crate::state::{Hackathon, Prize};
9+
10+
#[derive(Accounts)]
11+
pub struct AddPrize<'info> {
12+
// Rent payer. Separate from `authority` to allow a non-signing Squads
13+
// vault PDA to be the authority while a human keypair funds the call.
14+
#[account(mut)]
15+
pub payer: Signer<'info>,
16+
17+
// Hackathon admin. Must match `hackathon.authority`.
18+
pub authority: Signer<'info>,
19+
20+
#[account(
21+
mut,
22+
has_one = authority,
23+
seeds = [b"hackathon", authority.key().as_ref(), super::name_seed(&hackathon.name).as_ref()],
24+
bump = hackathon.bump,
25+
)]
26+
pub hackathon: Account<'info, Hackathon>,
27+
28+
// Per-prize mint. Using the token interface so the same compiled program
29+
// works for classic SPL Token (e.g. USDC) and Token-2022 mints.
30+
#[account(mint::token_program = token_program)]
31+
pub mint: InterfaceAccount<'info, Mint>,
32+
33+
#[account(
34+
init,
35+
payer = payer,
36+
space = Prize::DISCRIMINATOR.len() + Prize::INIT_SPACE,
37+
seeds = [b"prize", hackathon.key().as_ref(), &[hackathon.prize_count]],
38+
bump
39+
)]
40+
pub prize: Account<'info, Prize>,
41+
42+
// Vault ATA for this prize. Owned by the Prize PDA so `pay_winner` can
43+
// sign the outgoing transfer with the prize's seeds.
44+
#[account(
45+
init,
46+
payer = payer,
47+
associated_token::mint = mint,
48+
associated_token::authority = prize,
49+
associated_token::token_program = token_program,
50+
)]
51+
pub vault: InterfaceAccount<'info, TokenAccount>,
52+
53+
pub associated_token_program: Program<'info, AssociatedToken>,
54+
pub token_program: Interface<'info, TokenInterface>,
55+
pub system_program: Program<'info, System>,
56+
}
57+
58+
pub fn handle_add_prize(context: Context<AddPrize>, amount: u64) -> Result<()> {
59+
let hackathon = &mut context.accounts.hackathon;
60+
let index = hackathon.prize_count;
61+
62+
context.accounts.prize.set_inner(Prize {
63+
hackathon: hackathon.key(),
64+
index,
65+
mint: context.accounts.mint.key(),
66+
amount,
67+
winner: None,
68+
paid: false,
69+
cancelled: false,
70+
bump: context.bumps.prize,
71+
});
72+
73+
hackathon.prize_count = hackathon
74+
.prize_count
75+
.checked_add(1)
76+
.ok_or(HackathonError::PrizeCounterOverflow)?;
77+
78+
Ok(())
79+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
use anchor_lang::prelude::*;
2+
use anchor_spl::token_interface::{
3+
close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface,
4+
TransferChecked,
5+
};
6+
7+
use crate::error::HackathonError;
8+
use crate::state::{Hackathon, Prize};
9+
10+
// Cancel an unpaid prize: drain the vault to `refund_token_account`, close
11+
// the vault, and lock the prize so `pay_winner` can no longer run. Useful
12+
// when a prize is funded but never claimed, or when the committee wants to
13+
// reclaim surplus tokens left in a vault after `pay_winner` paid the exact
14+
// `prize.amount`.
15+
#[derive(Accounts)]
16+
#[instruction(prize_index: u8)]
17+
pub struct CancelPrize<'info> {
18+
// Hackathon admin. Must match `hackathon.authority`.
19+
pub authority: Signer<'info>,
20+
21+
// Where the vault's reclaimed rent lamports go. Separate from `authority`
22+
// so a Squads vault PDA (which cannot directly receive non-account
23+
// lamports in this context) can still authorise the cancellation while a
24+
// human keypair takes the rent refund.
25+
#[account(mut)]
26+
pub rent_destination: SystemAccount<'info>,
27+
28+
#[account(
29+
has_one = authority,
30+
seeds = [b"hackathon", authority.key().as_ref(), super::name_seed(&hackathon.name).as_ref()],
31+
bump = hackathon.bump,
32+
)]
33+
pub hackathon: Account<'info, Hackathon>,
34+
35+
#[account(
36+
mut,
37+
seeds = [b"prize", hackathon.key().as_ref(), &[prize_index]],
38+
bump = prize.bump,
39+
has_one = mint,
40+
constraint = prize.hackathon == hackathon.key(),
41+
)]
42+
pub prize: Account<'info, Prize>,
43+
44+
pub mint: InterfaceAccount<'info, Mint>,
45+
46+
#[account(
47+
mut,
48+
associated_token::mint = mint,
49+
associated_token::authority = prize,
50+
associated_token::token_program = token_program,
51+
)]
52+
pub vault: InterfaceAccount<'info, TokenAccount>,
53+
54+
// Token account that receives any tokens currently held in the vault.
55+
// Must match `mint` but otherwise unconstrained — the committee picks
56+
// where to send the refund.
57+
#[account(
58+
mut,
59+
token::mint = mint,
60+
token::token_program = token_program,
61+
)]
62+
pub refund_token_account: InterfaceAccount<'info, TokenAccount>,
63+
64+
pub token_program: Interface<'info, TokenInterface>,
65+
}
66+
67+
pub fn handle_cancel_prize(context: Context<CancelPrize>, _prize_index: u8) -> Result<()> {
68+
let prize = &mut context.accounts.prize;
69+
require!(!prize.paid, HackathonError::AlreadyPaid);
70+
require!(!prize.cancelled, HackathonError::Cancelled);
71+
72+
let hackathon_key = context.accounts.hackathon.key();
73+
let prize_index_byte = [prize.index];
74+
let bump = [prize.bump];
75+
let seeds = &[
76+
b"prize".as_ref(),
77+
hackathon_key.as_ref(),
78+
prize_index_byte.as_ref(),
79+
bump.as_ref(),
80+
];
81+
let signer_seeds = [&seeds[..]];
82+
83+
// Drain whatever is in the vault back to the refund target. This may be
84+
// zero (vault never funded) or more than `prize.amount` (vault was
85+
// over-funded); either is fine.
86+
let vault_amount = context.accounts.vault.amount;
87+
if vault_amount > 0 {
88+
let transfer_accounts = TransferChecked {
89+
from: context.accounts.vault.to_account_info(),
90+
mint: context.accounts.mint.to_account_info(),
91+
to: context.accounts.refund_token_account.to_account_info(),
92+
authority: prize.to_account_info(),
93+
};
94+
let cpi_context = CpiContext::new_with_signer(
95+
context.accounts.token_program.key(),
96+
transfer_accounts,
97+
&signer_seeds,
98+
);
99+
transfer_checked(cpi_context, vault_amount, context.accounts.mint.decimals)?;
100+
}
101+
102+
// Close the vault so its rent comes back. Prize account itself stays
103+
// open: it's an immutable record that this prize was cancelled.
104+
let close_accounts = CloseAccount {
105+
account: context.accounts.vault.to_account_info(),
106+
destination: context.accounts.rent_destination.to_account_info(),
107+
authority: prize.to_account_info(),
108+
};
109+
let cpi_context = CpiContext::new_with_signer(
110+
context.accounts.token_program.key(),
111+
close_accounts,
112+
&signer_seeds,
113+
);
114+
close_account(cpi_context)?;
115+
116+
prize.cancelled = true;
117+
Ok(())
118+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use anchor_lang::prelude::*;
2+
3+
use crate::error::HackathonError;
4+
use crate::state::Hackathon;
5+
6+
// Close the Hackathon account and refund its rent to `rent_destination`.
7+
// Permitted only once every registered Prize is either paid or cancelled —
8+
// the handler reads each Prize account from `remaining_accounts` and checks
9+
// its state. This avoids storing a separate `active_prize_count` field that
10+
// could drift out of sync with the per-Prize flags.
11+
#[derive(Accounts)]
12+
pub struct CloseHackathon<'info> {
13+
pub authority: Signer<'info>,
14+
15+
#[account(mut)]
16+
pub rent_destination: SystemAccount<'info>,
17+
18+
#[account(
19+
mut,
20+
has_one = authority,
21+
close = rent_destination,
22+
seeds = [b"hackathon", authority.key().as_ref(), super::name_seed(&hackathon.name).as_ref()],
23+
bump = hackathon.bump,
24+
)]
25+
pub hackathon: Account<'info, Hackathon>,
26+
}
27+
28+
pub fn handle_close_hackathon(context: Context<CloseHackathon>) -> Result<()> {
29+
let hackathon = &context.accounts.hackathon;
30+
31+
// Caller must pass every Prize account for this hackathon as remaining
32+
// accounts (in index order). Each one must either be paid or cancelled.
33+
require!(
34+
context.remaining_accounts.len() == hackathon.prize_count as usize,
35+
HackathonError::PrizesStillActive
36+
);
37+
38+
for (expected_index, prize_account_info) in context.remaining_accounts.iter().enumerate() {
39+
let prize = Account::<crate::state::Prize>::try_from(prize_account_info)?;
40+
require_keys_eq!(
41+
prize.hackathon,
42+
hackathon.key(),
43+
HackathonError::PrizesStillActive
44+
);
45+
require!(
46+
prize.index == expected_index as u8,
47+
HackathonError::PrizesStillActive
48+
);
49+
require!(
50+
prize.paid || prize.cancelled,
51+
HackathonError::PrizesStillActive
52+
);
53+
}
54+
55+
Ok(())
56+
}

0 commit comments

Comments
 (0)