Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions tokens/hackathon/anchor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.anchor
.DS_Store
target
**/*.rs.bk
node_modules
test-ledger
.yarn
16 changes: 16 additions & 0 deletions tokens/hackathon/anchor/Anchor.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[toolchain]
solana_version = "3.1.8"

[features]
resolution = true
skip-lint = false

[programs.localnet]
hackathon = "71AxoNytgqQrSFMvGREPeJ1E2btEoTMw8J4FALsmNcGx"

[provider]
cluster = "Localnet"
wallet = "~/.config/solana/id.json"

[scripts]
test = "cargo test"
15 changes: 15 additions & 0 deletions tokens/hackathon/anchor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[workspace]
members = [
"programs/*"
]
resolver = "2"

[profile.release]
overflow-checks = true
lto = "fat"
codegen-units = 1

[profile.release.build-override]
opt-level = 3
incremental = false
codegen-units = 1
137 changes: 137 additions & 0 deletions tokens/hackathon/anchor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Anchor Hackathon Prize Program (Squads multisig committee)

A small Anchor 1.0 program for running a hackathon where a Squads multisig
committee controls prize creation and award decisions, but anyone can trigger
the actual on-chain payment once a winner is recorded.

## Why it exists

Real hackathon organisers want:

1. **A committee, not a single key.** No one person can quietly mint a
prize, change the winner, or run off with the funds.
2. **Public, auditable awards.** Once the committee has voted "Alice wins
prize #3", anyone can execute the payout — the committee doesn't have to
stay online to hit a button.
3. **Surplus reclaim.** If a prize is funded but never claimed, the
committee can refund it.

This program does the on-chain half. Squads handles the off-chain voting and
PDA-signing flow.

## How the multisig integration works

The program is multisig-agnostic. Each `Hackathon` account stores a single
`authority: Pubkey`, and every privileged instruction handler checks
`signer == authority`. In practice that pubkey is a Squads vault PDA: the
committee proposes a vault transaction, votes on it, and when the threshold
is reached the Squads program signs the inner instruction with the vault's
PDA. Our program just sees a signed CPI from the vault and proceeds.

This means:

- You can swap Squads for any other multisig (Realms, Mean, a custom one)
without touching this program.
- The program doesn't need to know multisig threshold, member set, or
voting state.
- The program stays under 350 KB of compiled BPF.

## Accounts

```text
Hackathon
authority : Pubkey // Squads vault PDA
name : String // human-readable; hashed into seeds
prize_count : u8 // monotonic counter for Prize PDA seeding
bump : u8
seeds = ["hackathon", authority, sha256(name)]

Prize
hackathon : Pubkey
index : u8 // stable assignment from prize_count
mint : Pubkey // one mint per prize
amount : u64 // exact payout amount
winner : Option<Pubkey>
paid : bool
cancelled : bool
bump : u8
seeds = ["prize", hackathon, index]

Vault = ATA(prize, mint) // Prize PDA owns its own vault
```

Per-prize mints let one hackathon mix denominations (USDC for cash
prizes, governance tokens for runner-up awards). Storing the prize index
in the PDA seed avoids reallocating the `Hackathon` account every time a
prize is added.

## Instruction handlers

| Handler | Signer | Behaviour |
| ------------------ | ---------------- | ---------------------------------------------------------------------- |
| `create_hackathon` | Multisig | Initialise `Hackathon` under `authority`. |
| `add_prize` | Multisig | Register a `Prize` with its own mint and a new vault ATA. |
| `set_winner` | Multisig | Record the winner pubkey for a prize. |
| `pay_winner` | **Anyone** | Transfer exactly `prize.amount` to the winner's token account. |
| `cancel_prize` | Multisig | Drain the vault to a refund target and lock the prize against payout. |
| `close_hackathon` | Multisig | Refund `Hackathon` rent once every prize is paid or cancelled. |

`pay_winner` being permissionless is deliberate. Once the committee has
voted, anyone — the winner, a bot, an organiser's intern — can submit the
transaction. The committee doesn't need to stay online to deliver prizes.

## Token model

SPL Token Interface throughout (`InterfaceAccount<Mint>`,
`InterfaceAccount<TokenAccount>`, `Interface<TokenInterface>`,
`transfer_checked` from `anchor_spl::token_interface`). The same compiled
program works for both classic SPL Token and Token-2022 mints; the choice
is made per prize, at `add_prize` time, by passing the relevant mint.

## Tests

LiteSVM-based Rust integration tests build a real Squads v4 multisig
(Alice / Bob / Carol, threshold 2-of-3) and drive the program end-to-end
through Squads' propose / vote / execute flow.

The Squads on-chain program is loaded from a `.so` fixture at
`programs/hackathon/tests/fixtures/squads_multisig.so`. To refresh it from
mainnet:

```
solana program dump --url mainnet-beta \
SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf \
programs/hackathon/tests/fixtures/squads_multisig.so
```

Squads instructions (`multisig_create_v2`, `vault_transaction_create`,
`proposal_create`, `proposal_approve`, `vault_transaction_execute`) are
built by hand in `tests/common/squads.rs`. We don't depend on the
`squads-multisig` SDK crate because it pulls in `solana-client 1.17`,
which conflicts with our Anchor 1.0 / Solana 3.x stack.

The Squads `ProgramConfig` account (normally written by a Squads admin
instruction) is forged directly into LiteSVM with
`multisig_creation_fee = 0`, so test setup is one synchronous call.

### Coverage

- **Happy path**: create → add_prize → fund → set_winner (via multisig
vote) → pay_winner (unpermissioned). Verifies the winner's token
balance equals `prize.amount`.
- **Failure cases**: `pay_winner` rejects when no winner is set, when the
vault is under-funded, and when the prize has already been paid.
`set_winner` rejects a non-multisig signer.
- **Lifecycle**: `cancel_prize` drains a funded vault to a refund target
and locks the prize. `close_hackathon` succeeds once every prize is
resolved and fails while any prize is still active.

## Usage

```
cargo build-sbf
cargo test
```

`cargo build-sbf` must run first because the integration tests load the
compiled `.so` via `include_bytes!`.
37 changes: 37 additions & 0 deletions tokens/hackathon/anchor/programs/hackathon/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[package]
name = "hackathon"
version = "0.1.0"
description = "Created with Anchor"
edition = "2021"

[lib]
crate-type = ["cdylib", "lib"]
name = "hackathon"

[features]
default = []
cpi = ["no-entrypoint"]
no-entrypoint = []
no-idl = []
no-log-ix-name = []
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
anchor-debug = []
custom-heap = []
custom-panic = []

[dependencies]
anchor-lang = "1.0.0"
anchor-spl = "1.0.0"
sha2 = { version = "0.10", default-features = false }

[dev-dependencies]
litesvm = "0.11.0"
solana-signer = "3.0.0"
solana-keypair = "3.0.1"
solana-account = "3.0.0"
solana-kite = "0.3.0"
borsh = "1.6.1"
sha2 = "0.10"

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
2 changes: 2 additions & 0 deletions tokens/hackathon/anchor/programs/hackathon/Xargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[target.bpfel-unknown-unknown.dependencies.std]
features = []
23 changes: 23 additions & 0 deletions tokens/hackathon/anchor/programs/hackathon/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
use anchor_lang::prelude::*;

#[error_code]
pub enum HackathonError {
#[msg("Hackathon name must not be empty")]
EmptyName,
#[msg("Hackathon name exceeds the maximum length")]
NameTooLong,
#[msg("Prize has already been paid")]
AlreadyPaid,
#[msg("Prize has been cancelled")]
Cancelled,
#[msg("Prize has no winner set")]
NoWinner,
#[msg("Recorded winner does not match the supplied winner token account owner")]
WinnerMismatch,
#[msg("Vault balance is less than the prize amount")]
Underfunded,
#[msg("Prize counter overflow: this hackathon already holds the maximum prizes")]
PrizeCounterOverflow,
#[msg("Cannot close hackathon: at least one prize is still active")]
PrizesStillActive,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
use anchor_lang::prelude::*;
use anchor_spl::{
associated_token::AssociatedToken,
token_interface::{Mint, TokenAccount, TokenInterface},
};

use crate::error::HackathonError;
use crate::state::{Hackathon, Prize};

#[derive(Accounts)]
pub struct AddPrize<'info> {
// Rent payer. Separate from `authority` to allow a non-signing Squads
// vault PDA to be the authority while a human keypair funds the call.
#[account(mut)]
pub payer: Signer<'info>,

// Hackathon admin. Must match `hackathon.authority`.
pub authority: Signer<'info>,

#[account(
mut,
has_one = authority,
seeds = [b"hackathon", authority.key().as_ref(), super::name_seed(&hackathon.name).as_ref()],
bump = hackathon.bump,
)]
pub hackathon: Account<'info, Hackathon>,

// Per-prize mint. Using the token interface so the same compiled program
// works for classic SPL Token (e.g. USDC) and Token-2022 mints.
#[account(mint::token_program = token_program)]
pub mint: InterfaceAccount<'info, Mint>,

#[account(
init,
payer = payer,
space = Prize::DISCRIMINATOR.len() + Prize::INIT_SPACE,
seeds = [b"prize", hackathon.key().as_ref(), &[hackathon.prize_count]],
bump
)]
pub prize: Account<'info, Prize>,

// Vault ATA for this prize. Owned by the Prize PDA so `pay_winner` can
// sign the outgoing transfer with the prize's seeds.
#[account(
init,
payer = payer,
associated_token::mint = mint,
associated_token::authority = prize,
associated_token::token_program = token_program,
)]
pub vault: InterfaceAccount<'info, TokenAccount>,

pub associated_token_program: Program<'info, AssociatedToken>,
pub token_program: Interface<'info, TokenInterface>,
pub system_program: Program<'info, System>,
}

pub fn handle_add_prize(context: Context<AddPrize>, amount: u64) -> Result<()> {
let hackathon = &mut context.accounts.hackathon;
let index = hackathon.prize_count;

context.accounts.prize.set_inner(Prize {
hackathon: hackathon.key(),
index,
mint: context.accounts.mint.key(),
amount,
winner: None,
paid: false,
cancelled: false,
bump: context.bumps.prize,
});

hackathon.prize_count = hackathon
.prize_count
.checked_add(1)
.ok_or(HackathonError::PrizeCounterOverflow)?;

Ok(())
}
Loading
Loading