diff --git a/tokens/hackathon/anchor/.gitignore b/tokens/hackathon/anchor/.gitignore new file mode 100644 index 000000000..2e0446b07 --- /dev/null +++ b/tokens/hackathon/anchor/.gitignore @@ -0,0 +1,7 @@ +.anchor +.DS_Store +target +**/*.rs.bk +node_modules +test-ledger +.yarn diff --git a/tokens/hackathon/anchor/Anchor.toml b/tokens/hackathon/anchor/Anchor.toml new file mode 100644 index 000000000..b20fc7935 --- /dev/null +++ b/tokens/hackathon/anchor/Anchor.toml @@ -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" diff --git a/tokens/hackathon/anchor/Cargo.toml b/tokens/hackathon/anchor/Cargo.toml new file mode 100644 index 000000000..14a951cee --- /dev/null +++ b/tokens/hackathon/anchor/Cargo.toml @@ -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 diff --git a/tokens/hackathon/anchor/README.md b/tokens/hackathon/anchor/README.md new file mode 100644 index 000000000..5296e953c --- /dev/null +++ b/tokens/hackathon/anchor/README.md @@ -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 + 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`, +`InterfaceAccount`, `Interface`, +`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!`. diff --git a/tokens/hackathon/anchor/programs/hackathon/Cargo.toml b/tokens/hackathon/anchor/programs/hackathon/Cargo.toml new file mode 100644 index 000000000..9d9e60f10 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/Cargo.toml @@ -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"))'] } diff --git a/tokens/hackathon/anchor/programs/hackathon/Xargo.toml b/tokens/hackathon/anchor/programs/hackathon/Xargo.toml new file mode 100644 index 000000000..475fb71ed --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tokens/hackathon/anchor/programs/hackathon/src/error.rs b/tokens/hackathon/anchor/programs/hackathon/src/error.rs new file mode 100644 index 000000000..142bc81dd --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/error.rs @@ -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, +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/instructions/add_prize.rs b/tokens/hackathon/anchor/programs/hackathon/src/instructions/add_prize.rs new file mode 100644 index 000000000..d77ec5de2 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/instructions/add_prize.rs @@ -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, 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(()) +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/instructions/cancel_prize.rs b/tokens/hackathon/anchor/programs/hackathon/src/instructions/cancel_prize.rs new file mode 100644 index 000000000..2eef9af08 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/instructions/cancel_prize.rs @@ -0,0 +1,118 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + close_account, transfer_checked, CloseAccount, Mint, TokenAccount, TokenInterface, + TransferChecked, +}; + +use crate::error::HackathonError; +use crate::state::{Hackathon, Prize}; + +// Cancel an unpaid prize: drain the vault to `refund_token_account`, close +// the vault, and lock the prize so `pay_winner` can no longer run. Useful +// when a prize is funded but never claimed, or when the committee wants to +// reclaim surplus tokens left in a vault after `pay_winner` paid the exact +// `prize.amount`. +#[derive(Accounts)] +#[instruction(prize_index: u8)] +pub struct CancelPrize<'info> { + // Hackathon admin. Must match `hackathon.authority`. + pub authority: Signer<'info>, + + // Where the vault's reclaimed rent lamports go. Separate from `authority` + // so a Squads vault PDA (which cannot directly receive non-account + // lamports in this context) can still authorise the cancellation while a + // human keypair takes the rent refund. + #[account(mut)] + pub rent_destination: SystemAccount<'info>, + + #[account( + 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>, + + #[account( + mut, + seeds = [b"prize", hackathon.key().as_ref(), &[prize_index]], + bump = prize.bump, + has_one = mint, + constraint = prize.hackathon == hackathon.key(), + )] + pub prize: Account<'info, Prize>, + + pub mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = prize, + associated_token::token_program = token_program, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + // Token account that receives any tokens currently held in the vault. + // Must match `mint` but otherwise unconstrained — the committee picks + // where to send the refund. + #[account( + mut, + token::mint = mint, + token::token_program = token_program, + )] + pub refund_token_account: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handle_cancel_prize(context: Context, _prize_index: u8) -> Result<()> { + let prize = &mut context.accounts.prize; + require!(!prize.paid, HackathonError::AlreadyPaid); + require!(!prize.cancelled, HackathonError::Cancelled); + + let hackathon_key = context.accounts.hackathon.key(); + let prize_index_byte = [prize.index]; + let bump = [prize.bump]; + let seeds = &[ + b"prize".as_ref(), + hackathon_key.as_ref(), + prize_index_byte.as_ref(), + bump.as_ref(), + ]; + let signer_seeds = [&seeds[..]]; + + // Drain whatever is in the vault back to the refund target. This may be + // zero (vault never funded) or more than `prize.amount` (vault was + // over-funded); either is fine. + let vault_amount = context.accounts.vault.amount; + if vault_amount > 0 { + let transfer_accounts = TransferChecked { + from: context.accounts.vault.to_account_info(), + mint: context.accounts.mint.to_account_info(), + to: context.accounts.refund_token_account.to_account_info(), + authority: prize.to_account_info(), + }; + let cpi_context = CpiContext::new_with_signer( + context.accounts.token_program.key(), + transfer_accounts, + &signer_seeds, + ); + transfer_checked(cpi_context, vault_amount, context.accounts.mint.decimals)?; + } + + // Close the vault so its rent comes back. Prize account itself stays + // open: it's an immutable record that this prize was cancelled. + let close_accounts = CloseAccount { + account: context.accounts.vault.to_account_info(), + destination: context.accounts.rent_destination.to_account_info(), + authority: prize.to_account_info(), + }; + let cpi_context = CpiContext::new_with_signer( + context.accounts.token_program.key(), + close_accounts, + &signer_seeds, + ); + close_account(cpi_context)?; + + prize.cancelled = true; + Ok(()) +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/instructions/close_hackathon.rs b/tokens/hackathon/anchor/programs/hackathon/src/instructions/close_hackathon.rs new file mode 100644 index 000000000..e31697f04 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/instructions/close_hackathon.rs @@ -0,0 +1,56 @@ +use anchor_lang::prelude::*; + +use crate::error::HackathonError; +use crate::state::Hackathon; + +// Close the Hackathon account and refund its rent to `rent_destination`. +// Permitted only once every registered Prize is either paid or cancelled — +// the handler reads each Prize account from `remaining_accounts` and checks +// its state. This avoids storing a separate `active_prize_count` field that +// could drift out of sync with the per-Prize flags. +#[derive(Accounts)] +pub struct CloseHackathon<'info> { + pub authority: Signer<'info>, + + #[account(mut)] + pub rent_destination: SystemAccount<'info>, + + #[account( + mut, + has_one = authority, + close = rent_destination, + seeds = [b"hackathon", authority.key().as_ref(), super::name_seed(&hackathon.name).as_ref()], + bump = hackathon.bump, + )] + pub hackathon: Account<'info, Hackathon>, +} + +pub fn handle_close_hackathon(context: Context) -> Result<()> { + let hackathon = &context.accounts.hackathon; + + // Caller must pass every Prize account for this hackathon as remaining + // accounts (in index order). Each one must either be paid or cancelled. + require!( + context.remaining_accounts.len() == hackathon.prize_count as usize, + HackathonError::PrizesStillActive + ); + + for (expected_index, prize_account_info) in context.remaining_accounts.iter().enumerate() { + let prize = Account::::try_from(prize_account_info)?; + require_keys_eq!( + prize.hackathon, + hackathon.key(), + HackathonError::PrizesStillActive + ); + require!( + prize.index == expected_index as u8, + HackathonError::PrizesStillActive + ); + require!( + prize.paid || prize.cancelled, + HackathonError::PrizesStillActive + ); + } + + Ok(()) +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/instructions/create_hackathon.rs b/tokens/hackathon/anchor/programs/hackathon/src/instructions/create_hackathon.rs new file mode 100644 index 000000000..28ae187fa --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/instructions/create_hackathon.rs @@ -0,0 +1,50 @@ +use anchor_lang::prelude::*; + +use crate::error::HackathonError; +use crate::state::{Hackathon, HACKATHON_NAME_MAX_LEN}; + +use super::name_seed; + +#[derive(Accounts)] +#[instruction(name: String)] +pub struct CreateHackathon<'info> { + // Pays rent for the Hackathon account. Separate from `authority` so a + // Squads vault PDA (which cannot pay rent directly) can still be the + // authority — a human keypair funds the create call. + #[account(mut)] + pub payer: Signer<'info>, + + // The eventual administrator of this hackathon. Stored on the account + // verbatim. Does not need to sign `create_hackathon` (the payer signs + // for rent), but every privileged handler thereafter requires this key + // to sign. + /// CHECK: stored verbatim as `hackathon.authority`; no on-chain reads. + pub authority: UncheckedAccount<'info>, + + #[account( + init, + payer = payer, + space = Hackathon::DISCRIMINATOR.len() + Hackathon::INIT_SPACE, + seeds = [b"hackathon", authority.key().as_ref(), name_seed(&name).as_ref()], + bump + )] + pub hackathon: Account<'info, Hackathon>, + + pub system_program: Program<'info, System>, +} + +pub fn handle_create_hackathon(context: Context, name: String) -> Result<()> { + require!(!name.is_empty(), HackathonError::EmptyName); + require!( + name.len() <= HACKATHON_NAME_MAX_LEN, + HackathonError::NameTooLong + ); + + context.accounts.hackathon.set_inner(Hackathon { + authority: context.accounts.authority.key(), + prize_count: 0, + bump: context.bumps.hackathon, + name, + }); + Ok(()) +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/instructions/mod.rs b/tokens/hackathon/anchor/programs/hackathon/src/instructions/mod.rs new file mode 100644 index 000000000..17d4a7173 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/instructions/mod.rs @@ -0,0 +1,15 @@ +pub mod add_prize; +pub mod cancel_prize; +pub mod close_hackathon; +pub mod create_hackathon; +pub mod pay_winner; +pub mod set_winner; +pub mod shared; + +pub use add_prize::*; +pub use cancel_prize::*; +pub use close_hackathon::*; +pub use create_hackathon::*; +pub use pay_winner::*; +pub use set_winner::*; +pub use shared::*; diff --git a/tokens/hackathon/anchor/programs/hackathon/src/instructions/pay_winner.rs b/tokens/hackathon/anchor/programs/hackathon/src/instructions/pay_winner.rs new file mode 100644 index 000000000..482dddf87 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/instructions/pay_winner.rs @@ -0,0 +1,96 @@ +use anchor_lang::prelude::*; +use anchor_spl::token_interface::{ + transfer_checked, Mint, TokenAccount, TokenInterface, TransferChecked, +}; + +use crate::error::HackathonError; +use crate::state::{Hackathon, Prize}; + +// Unpermissioned: anyone can trigger payment once a winner is set and the +// vault holds enough tokens. The caller pays only the transaction fee; the +// transferred tokens come from the prize vault. +#[derive(Accounts)] +#[instruction(prize_index: u8)] +pub struct PayWinner<'info> { + #[account(mut)] + pub caller: Signer<'info>, + + #[account( + seeds = [b"hackathon", hackathon.authority.as_ref(), super::name_seed(&hackathon.name).as_ref()], + bump = hackathon.bump, + )] + pub hackathon: Account<'info, Hackathon>, + + #[account( + mut, + seeds = [b"prize", hackathon.key().as_ref(), &[prize_index]], + bump = prize.bump, + has_one = mint, + constraint = prize.hackathon == hackathon.key(), + )] + pub prize: Account<'info, Prize>, + + pub mint: InterfaceAccount<'info, Mint>, + + #[account( + mut, + associated_token::mint = mint, + associated_token::authority = prize, + associated_token::token_program = token_program, + )] + pub vault: InterfaceAccount<'info, TokenAccount>, + + // Winner's token account. Validated against `prize.winner` in the + // handler (Anchor cannot express `authority = prize.winner.unwrap()`). + #[account( + mut, + token::mint = mint, + token::token_program = token_program, + )] + pub winner_token_account: InterfaceAccount<'info, TokenAccount>, + + pub token_program: Interface<'info, TokenInterface>, +} + +pub fn handle_pay_winner(context: Context, _prize_index: u8) -> Result<()> { + let prize = &mut context.accounts.prize; + require!(!prize.paid, HackathonError::AlreadyPaid); + require!(!prize.cancelled, HackathonError::Cancelled); + let winner = prize.winner.ok_or(HackathonError::NoWinner)?; + require_keys_eq!( + context.accounts.winner_token_account.owner, + winner, + HackathonError::WinnerMismatch + ); + require!( + context.accounts.vault.amount >= prize.amount, + HackathonError::Underfunded + ); + + let hackathon_key = context.accounts.hackathon.key(); + let prize_index_byte = [prize.index]; + let bump = [prize.bump]; + let seeds = &[ + b"prize".as_ref(), + hackathon_key.as_ref(), + prize_index_byte.as_ref(), + bump.as_ref(), + ]; + let signer_seeds = [&seeds[..]]; + + let transfer_accounts = TransferChecked { + from: context.accounts.vault.to_account_info(), + mint: context.accounts.mint.to_account_info(), + to: context.accounts.winner_token_account.to_account_info(), + authority: prize.to_account_info(), + }; + let cpi_context = CpiContext::new_with_signer( + context.accounts.token_program.key(), + transfer_accounts, + &signer_seeds, + ); + transfer_checked(cpi_context, prize.amount, context.accounts.mint.decimals)?; + + prize.paid = true; + Ok(()) +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/instructions/set_winner.rs b/tokens/hackathon/anchor/programs/hackathon/src/instructions/set_winner.rs new file mode 100644 index 000000000..937118fbd --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/instructions/set_winner.rs @@ -0,0 +1,38 @@ +use anchor_lang::prelude::*; + +use crate::error::HackathonError; +use crate::state::{Hackathon, Prize}; + +#[derive(Accounts)] +#[instruction(prize_index: u8)] +pub struct SetWinner<'info> { + // Hackathon admin. Must match `hackathon.authority`. + pub authority: Signer<'info>, + + #[account( + 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>, + + #[account( + mut, + seeds = [b"prize", hackathon.key().as_ref(), &[prize_index]], + bump = prize.bump, + constraint = prize.hackathon == hackathon.key(), + )] + pub prize: Account<'info, Prize>, +} + +pub fn handle_set_winner( + context: Context, + _prize_index: u8, + winner: Pubkey, +) -> Result<()> { + let prize = &mut context.accounts.prize; + require!(!prize.paid, HackathonError::AlreadyPaid); + require!(!prize.cancelled, HackathonError::Cancelled); + prize.winner = Some(winner); + Ok(()) +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/instructions/shared.rs b/tokens/hackathon/anchor/programs/hackathon/src/instructions/shared.rs new file mode 100644 index 000000000..a769fc809 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/instructions/shared.rs @@ -0,0 +1,11 @@ +use sha2::{Digest, Sha256}; + +// Hash the hackathon name into a fixed-size seed slice so the Hackathon PDA +// has a fixed seed layout regardless of input length. We use SHA-256 via the +// `sha2` crate because Anchor 1.0's curated `solana_program` re-export does +// not include a hash module. +pub fn name_seed(name: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(name.as_bytes()); + hasher.finalize().into() +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/lib.rs b/tokens/hackathon/anchor/programs/hackathon/src/lib.rs new file mode 100644 index 000000000..05ac07a76 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/lib.rs @@ -0,0 +1,74 @@ +// On-chain prize program for a hackathon run by a Squads multisig committee. +// +// The program does not implement multisig logic. It treats the +// `Hackathon.authority` as an opaque "admin" pubkey and only checks +// `signer == hackathon.authority` on privileged instruction handlers. In +// practice the authority is a Squads vault PDA: Squads handles propose/vote +// /execute off-program, and when execution lands the program just sees a +// signed CPI from the vault. +// +// This keeps the program small and lets the committee swap multisig +// implementations without touching the prize program. + +pub mod error; +pub mod instructions; +pub mod state; + +use anchor_lang::prelude::*; + +pub use instructions::*; +pub use state::*; + +declare_id!("71AxoNytgqQrSFMvGREPeJ1E2btEoTMw8J4FALsmNcGx"); + +#[program] +pub mod hackathon { + use super::*; + + // Create a hackathon controlled by `authority` (in practice a Squads vault + // PDA). The hackathon's name is hashed into the PDA seeds so the same + // authority can run multiple hackathons. + pub fn create_hackathon(context: Context, name: String) -> Result<()> { + instructions::create_hackathon::handle_create_hackathon(context, name) + } + + // Register a new prize under an existing hackathon. The mint and target + // amount are recorded on the Prize account; the vault ATA is created here + // with the Prize PDA as its authority. Must be signed by the hackathon + // authority. + pub fn add_prize(context: Context, amount: u64) -> Result<()> { + instructions::add_prize::handle_add_prize(context, amount) + } + + // Record the winner for a prize. Must be signed by the hackathon + // authority. Errors if the prize has already been paid or cancelled. + pub fn set_winner(context: Context, prize_index: u8, winner: Pubkey) -> Result<()> { + instructions::set_winner::handle_set_winner(context, prize_index, winner) + } + + // Pay the recorded winner the exact `prize.amount`. Unpermissioned: any + // signer can trigger payment once the winner is set and the vault is + // funded. Any surplus left in the vault stays there and can be reclaimed + // by the authority via `cancel_prize`. + pub fn pay_winner(context: Context, prize_index: u8) -> Result<()> { + instructions::pay_winner::handle_pay_winner(context, prize_index) + } + + // Cancel a prize that has not yet been paid: drains the vault to + // `refund_to`, closes the vault, and marks the prize cancelled so it can + // no longer be paid. Must be signed by the hackathon authority. + pub fn cancel_prize(context: Context, prize_index: u8) -> Result<()> { + instructions::cancel_prize::handle_cancel_prize(context, prize_index) + } + + // Close the hackathon and refund its rent to the authority. Only allowed + // once every registered prize is either paid or cancelled. Must be signed + // by the hackathon authority. + // + // Individual Prize accounts are not closed here. They remain on-chain as + // an immutable record of who won what; closing them would erase that + // history for a small rent refund. + pub fn close_hackathon(context: Context) -> Result<()> { + instructions::close_hackathon::handle_close_hackathon(context) + } +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/state/hackathon.rs b/tokens/hackathon/anchor/programs/hackathon/src/state/hackathon.rs new file mode 100644 index 000000000..5ab2fd625 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/state/hackathon.rs @@ -0,0 +1,23 @@ +use anchor_lang::prelude::*; + +// Upper bound on the length of `Hackathon.name`. Picked to keep the account +// small while still allowing reasonable human-readable names. Names longer +// than this should be hashed off-chain before being passed in. +pub const HACKATHON_NAME_MAX_LEN: usize = 64; + +#[account] +#[derive(InitSpace)] +pub struct Hackathon { + // The "admin" key. In practice this is a Squads vault PDA, but the + // program treats it as an opaque pubkey: privileged handlers check + // `signer == authority` and nothing more. + pub authority: Pubkey, + // Monotonic counter used to seed Prize PDAs. u8 caps at 255 prizes per + // hackathon, which is plenty for the target use case (30-100 prizes). + pub prize_count: u8, + pub bump: u8, + // Free-form human-readable name. Hashed into the Hackathon PDA seeds so a + // single authority can run multiple hackathons. + #[max_len(HACKATHON_NAME_MAX_LEN)] + pub name: String, +} diff --git a/tokens/hackathon/anchor/programs/hackathon/src/state/mod.rs b/tokens/hackathon/anchor/programs/hackathon/src/state/mod.rs new file mode 100644 index 000000000..6d71e7015 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/state/mod.rs @@ -0,0 +1,5 @@ +pub mod hackathon; +pub mod prize; + +pub use hackathon::*; +pub use prize::*; diff --git a/tokens/hackathon/anchor/programs/hackathon/src/state/prize.rs b/tokens/hackathon/anchor/programs/hackathon/src/state/prize.rs new file mode 100644 index 000000000..050cdc5aa --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/src/state/prize.rs @@ -0,0 +1,25 @@ +use anchor_lang::prelude::*; + +#[account] +#[derive(InitSpace)] +pub struct Prize { + // Parent hackathon. Kept here so a Prize can be loaded and validated + // without first loading the Hackathon. + pub hackathon: Pubkey, + // Stable index assigned at creation time (= `hackathon.prize_count` at + // the moment `add_prize` ran). Used in the Prize PDA seeds, so it never + // changes. + pub index: u8, + // The token mint this prize is denominated in. One mint per prize, so a + // single hackathon can mix denominations (e.g. USDC for cash prizes, + // governance token for runner-up prizes). + pub mint: Pubkey, + // Exact amount paid to the winner. `pay_winner` always transfers this + // amount; surplus in the vault remains until reclaimed via cancel_prize. + pub amount: u64, + // Recorded winner. `None` until `set_winner` runs. + pub winner: Option, + pub paid: bool, + pub cancelled: bool, + pub bump: u8, +} diff --git a/tokens/hackathon/anchor/programs/hackathon/tests/common/mod.rs b/tokens/hackathon/anchor/programs/hackathon/tests/common/mod.rs new file mode 100644 index 000000000..aa47c5667 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/tests/common/mod.rs @@ -0,0 +1,11 @@ +// Test-side helpers. Split into modules so each file has a single +// responsibility and the test files stay focused on behaviour, not plumbing. +// +// Each integration test compiles `common/` into a separate binary, so an +// item used by one test binary but not another shows up as `dead_code`. The +// allow attribute below silences those false positives across the whole +// helper surface; real dead code is still caught by `cargo clippy`. +#![allow(dead_code)] + +pub mod squads; +pub mod world; diff --git a/tokens/hackathon/anchor/programs/hackathon/tests/common/squads.rs b/tokens/hackathon/anchor/programs/hackathon/tests/common/squads.rs new file mode 100644 index 000000000..bccdc91a4 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/tests/common/squads.rs @@ -0,0 +1,456 @@ +// Hand-rolled builders for the Squads v4 multisig program. We use the on-chain +// program (loaded as a `.so` fixture) rather than the `squads-multisig` SDK +// crate because that SDK pulls in Solana 1.17 deps that conflict with our +// Anchor 1.0 / Solana 3.x stack on `zeroize`. +// +// What we build by hand: +// - The Anchor 8-byte instruction discriminator (`sha256("global:")[..8]`). +// - The Borsh wire format for each instruction's arg struct. +// - PDA derivations for `multisig`, `vault`, `transaction`, `proposal`. +// - A forged `ProgramConfig` account injected directly into LiteSVM, so we +// don't have to run the Squads admin instruction first (which is gated on a +// key we don't control). +// +// References (Squads v4 main branch): +// programs/squads_multisig_program/src/instructions/{multisig_create, +// vault_transaction_create, proposal_create, proposal_vote, +// vault_transaction_execute}.rs +// programs/squads_multisig_program/src/state/{multisig,seeds,program_config}.rs + +use std::str::FromStr; + +use anchor_lang::prelude::Pubkey; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::solana_program::system_program; +use borsh::{BorshDeserialize, BorshSerialize}; +use litesvm::types::FailedTransactionMetadata; +use litesvm::LiteSVM; +use sha2::{Digest, Sha256}; +use solana_keypair::Keypair; +use solana_signer::Signer; + +// Squads v4 deployed program id on mainnet. Matches the `.so` we dump into +// `tests/fixtures/squads_multisig.so`. +pub fn squads_program_id() -> Pubkey { + Pubkey::from_str("SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf").unwrap() +} + +// Squads v4 PDA seed strings, copied verbatim from the upstream +// `state/seeds.rs` so a future Squads-side rename will fail loudly here too. +pub const SEED_PREFIX: &[u8] = b"multisig"; +pub const SEED_PROGRAM_CONFIG: &[u8] = b"program_config"; +pub const SEED_MULTISIG: &[u8] = b"multisig"; +pub const SEED_PROPOSAL: &[u8] = b"proposal"; +pub const SEED_TRANSACTION: &[u8] = b"transaction"; +pub const SEED_VAULT: &[u8] = b"vault"; + +// Squads `Permissions` bitmask (Initiate | Vote | Execute = 7). Every committee +// member in our tests holds all three so they can both propose and execute. +pub const PERMISSION_ALL: u8 = 0b0000_0111; + +// Build the 8-byte Anchor discriminator: sha256("global:")[..8]. +fn anchor_discriminator(name: &str) -> [u8; 8] { + let mut hasher = Sha256::new(); + hasher.update(format!("global:{}", name).as_bytes()); + let full = hasher.finalize(); + let mut out = [0u8; 8]; + out.copy_from_slice(&full[..8]); + out +} + +// ----- PDA helpers ------------------------------------------------------- + +pub fn program_config_pda() -> Pubkey { + Pubkey::find_program_address(&[SEED_PREFIX, SEED_PROGRAM_CONFIG], &squads_program_id()).0 +} + +pub fn multisig_pda(create_key: &Pubkey) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[SEED_PREFIX, SEED_MULTISIG, create_key.as_ref()], + &squads_program_id(), + ) +} + +pub fn vault_pda(multisig: &Pubkey, vault_index: u8) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + SEED_PREFIX, + multisig.as_ref(), + SEED_VAULT, + &vault_index.to_le_bytes(), + ], + &squads_program_id(), + ) +} + +pub fn transaction_pda(multisig: &Pubkey, transaction_index: u64) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + SEED_PREFIX, + multisig.as_ref(), + SEED_TRANSACTION, + &transaction_index.to_le_bytes(), + ], + &squads_program_id(), + ) +} + +pub fn proposal_pda(multisig: &Pubkey, transaction_index: u64) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[ + SEED_PREFIX, + multisig.as_ref(), + SEED_TRANSACTION, + &transaction_index.to_le_bytes(), + SEED_PROPOSAL, + ], + &squads_program_id(), + ) +} + +// ----- Forged ProgramConfig -------------------------------------------- + +// The on-chain `multisig_create_v2` instruction reads `program_config` to +// learn the treasury pubkey and the creation fee. On mainnet this is a real +// account written by a Squads admin instruction we cannot run here. Instead +// we build the same byte layout in-process and `set_account` it directly, +// with `multisig_creation_fee = 0` so the transfer-to-treasury branch is a +// no-op. +// +// Layout (from `state/program_config.rs`, Anchor #[account] #[derive(InitSpace)]): +// 8 bytes Anchor account discriminator +// 32 bytes authority +// 8 bytes multisig_creation_fee (u64) +// 32 bytes treasury +// 64 bytes _reserved +pub fn forge_program_config(svm: &mut LiteSVM, treasury: &Pubkey) { + let discriminator = anchor_discriminator_for_account("ProgramConfig"); + let authority = Keypair::new().pubkey(); + let mut data = Vec::with_capacity(8 + 32 + 8 + 32 + 64); + data.extend_from_slice(&discriminator); + data.extend_from_slice(authority.as_ref()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(treasury.as_ref()); + data.extend_from_slice(&[0u8; 64]); + + let lamports = svm.minimum_balance_for_rent_exemption(data.len()); + let account = solana_account::Account { + lamports, + data, + owner: squads_program_id(), + executable: false, + rent_epoch: 0, + }; + svm.set_account(program_config_pda(), account).unwrap(); +} + +// Anchor account discriminator: sha256("account:")[..8]. +fn anchor_discriminator_for_account(type_name: &str) -> [u8; 8] { + let mut hasher = Sha256::new(); + hasher.update(format!("account:{}", type_name).as_bytes()); + let full = hasher.finalize(); + let mut out = [0u8; 8]; + out.copy_from_slice(&full[..8]); + out +} + +// ----- Instruction builders -------------------------------------------- + +// Squads `Member` struct: pubkey + 1-byte permissions mask. +#[derive(BorshSerialize, BorshDeserialize)] +pub struct Member { + pub key: Pubkey, + pub permissions_mask: u8, +} + +#[derive(BorshSerialize, BorshDeserialize)] +struct MultisigCreateArgsV2 { + config_authority: Option, + threshold: u16, + members: Vec, + time_lock: u32, + rent_collector: Option, + memo: Option, +} + +pub fn multisig_create_v2_instruction( + create_key: &Pubkey, + creator: &Pubkey, + treasury: &Pubkey, + members: Vec, + threshold: u16, +) -> Instruction { + let (multisig, _bump) = multisig_pda(create_key); + + let args = MultisigCreateArgsV2 { + config_authority: None, + threshold, + members, + time_lock: 0, + rent_collector: None, + memo: None, + }; + + let mut data = anchor_discriminator("multisig_create_v2").to_vec(); + args.serialize(&mut data).unwrap(); + + Instruction { + program_id: squads_program_id(), + accounts: vec![ + AccountMeta::new_readonly(program_config_pda(), false), + AccountMeta::new(*treasury, false), + AccountMeta::new(multisig, false), + AccountMeta::new_readonly(*create_key, true), + AccountMeta::new(*creator, true), + AccountMeta::new_readonly(system_program::id(), false), + ], + data, + } +} + +// ----- Compiled inner-transaction-message wire format ------------------ +// +// `vault_transaction_create` takes an opaque `transaction_message: Vec` +// which is a Borsh-serialized `TransactionMessage`. The fields use +// `SmallVec` — i.e. a 1-byte length prefix followed by the items +// concatenated. We reproduce that layout exactly. + +// `MessageAddressTableLookup` — kept as documentation of the upstream +// `TransactionMessage` schema even though we always serialise an empty list +// of lookups in tests. +#[derive(BorshSerialize)] +struct MessageAddressTableLookup { + account_key: Pubkey, + writable_indexes: Vec, // SmallVec + readonly_indexes: Vec, // SmallVec +} + +pub struct CompiledInstruction { + pub program_id_index: u8, + pub account_indexes: Vec, + pub data: Vec, +} + +// Hand-encode the upstream `TransactionMessage` layout. We do this manually +// (rather than via derive) because `SmallVec` uses a single-byte length +// prefix where standard Borsh `Vec` uses four; deriving would produce the +// wrong wire format. +pub fn serialize_transaction_message( + num_signers: u8, + num_writable_signers: u8, + num_writable_non_signers: u8, + account_keys: &[Pubkey], + instructions: &[CompiledInstruction], +) -> Vec { + let mut out = Vec::new(); + out.push(num_signers); + out.push(num_writable_signers); + out.push(num_writable_non_signers); + + // SmallVec + out.push(account_keys.len() as u8); + for key in account_keys { + out.extend_from_slice(key.as_ref()); + } + + // SmallVec + out.push(instructions.len() as u8); + for ix in instructions { + out.push(ix.program_id_index); + // SmallVec + out.push(ix.account_indexes.len() as u8); + out.extend_from_slice(&ix.account_indexes); + // SmallVec for data + let data_len: u16 = ix.data.len().try_into().unwrap(); + out.extend_from_slice(&data_len.to_le_bytes()); + out.extend_from_slice(&ix.data); + } + + // SmallVec — empty in all our tests. + out.push(0); + + out +} + +#[derive(BorshSerialize)] +struct VaultTransactionCreateArgs { + vault_index: u8, + ephemeral_signers: u8, + transaction_message: Vec, + memo: Option, +} + +pub fn vault_transaction_create_instruction( + multisig: &Pubkey, + transaction_index: u64, + vault_index: u8, + creator: &Pubkey, + rent_payer: &Pubkey, + transaction_message: Vec, +) -> Instruction { + let (transaction, _bump) = transaction_pda(multisig, transaction_index); + + let args = VaultTransactionCreateArgs { + vault_index, + ephemeral_signers: 0, + transaction_message, + memo: None, + }; + + let mut data = anchor_discriminator("vault_transaction_create").to_vec(); + args.serialize(&mut data).unwrap(); + + Instruction { + program_id: squads_program_id(), + accounts: vec![ + AccountMeta::new(*multisig, false), + AccountMeta::new(transaction, false), + AccountMeta::new_readonly(*creator, true), + AccountMeta::new(*rent_payer, true), + AccountMeta::new_readonly(system_program::id(), false), + ], + data, + } +} + +#[derive(BorshSerialize)] +struct ProposalCreateArgs { + transaction_index: u64, + draft: bool, +} + +pub fn proposal_create_instruction( + multisig: &Pubkey, + transaction_index: u64, + creator: &Pubkey, + rent_payer: &Pubkey, +) -> Instruction { + let (proposal, _bump) = proposal_pda(multisig, transaction_index); + + let args = ProposalCreateArgs { + transaction_index, + draft: false, + }; + + let mut data = anchor_discriminator("proposal_create").to_vec(); + args.serialize(&mut data).unwrap(); + + Instruction { + program_id: squads_program_id(), + accounts: vec![ + AccountMeta::new_readonly(*multisig, false), + AccountMeta::new(proposal, false), + AccountMeta::new_readonly(*creator, true), + AccountMeta::new(*rent_payer, true), + AccountMeta::new_readonly(system_program::id(), false), + ], + data, + } +} + +#[derive(BorshSerialize)] +struct ProposalVoteArgs { + memo: Option, +} + +pub fn proposal_approve_instruction( + multisig: &Pubkey, + transaction_index: u64, + member: &Pubkey, +) -> Instruction { + let (proposal, _bump) = proposal_pda(multisig, transaction_index); + + let args = ProposalVoteArgs { memo: None }; + let mut data = anchor_discriminator("proposal_approve").to_vec(); + args.serialize(&mut data).unwrap(); + + Instruction { + program_id: squads_program_id(), + accounts: vec![ + AccountMeta::new_readonly(*multisig, false), + AccountMeta::new(*member, true), + AccountMeta::new(proposal, false), + ], + data, + } +} + +// `vault_transaction_execute` takes no instruction args (just the +// discriminator). The accounts vector is: +// 1. multisig +// 2. proposal +// 3. transaction +// 4. member (signer) +// ...then remaining_accounts in the order: account_keys (we always have +// zero address-table lookups), with writable/signer bits inferred from +// the compiled message. +pub fn vault_transaction_execute_instruction( + multisig: &Pubkey, + transaction_index: u64, + member: &Pubkey, + remaining_accounts: Vec, +) -> Instruction { + let (proposal, _bump) = proposal_pda(multisig, transaction_index); + let (transaction, _bump) = transaction_pda(multisig, transaction_index); + + let data = anchor_discriminator("vault_transaction_execute").to_vec(); + + let mut accounts = vec![ + AccountMeta::new_readonly(*multisig, false), + AccountMeta::new(proposal, false), + AccountMeta::new_readonly(transaction, false), + AccountMeta::new_readonly(*member, true), + ]; + accounts.extend(remaining_accounts); + + Instruction { + program_id: squads_program_id(), + accounts, + data, + } +} + +// ----- Convenience wrappers -------------------------------------------- + +pub struct Committee { + pub alice: Keypair, + pub bob: Keypair, + pub carol: Keypair, + pub create_key: Keypair, + pub multisig: Pubkey, + pub vault: Pubkey, + // Vault PDA bump. We don't use it directly in tests yet, but the Squads + // program reads it on every execute, and exposing it makes future + // sign-as-vault helpers trivial to add. + pub vault_bump: u8, +} + +impl Committee { + pub fn members_sorted(&self) -> Vec { + let mut members = vec![ + Member { + key: self.alice.pubkey(), + permissions_mask: PERMISSION_ALL, + }, + Member { + key: self.bob.pubkey(), + permissions_mask: PERMISSION_ALL, + }, + Member { + key: self.carol.pubkey(), + permissions_mask: PERMISSION_ALL, + }, + ]; + members.sort_by_key(|m| m.key); + members + } +} + +pub fn install_squads_program(svm: &mut LiteSVM) { + let bytes = include_bytes!("../fixtures/squads_multisig.so"); + svm.add_program(squads_program_id(), bytes).unwrap(); +} + +// Re-export so test files can compose their own send_transaction error +// reporting. +pub type SvmError = FailedTransactionMetadata; diff --git a/tokens/hackathon/anchor/programs/hackathon/tests/common/world.rs b/tokens/hackathon/anchor/programs/hackathon/tests/common/world.rs new file mode 100644 index 000000000..972afee30 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/tests/common/world.rs @@ -0,0 +1,563 @@ +// World setup: spin up a LiteSVM with our hackathon program + Squads loaded, +// fund the committee, create a USDC-style mint, and bring up a 2-of-3 Squads +// multisig (Alice / Bob / Carol). Returns a `World` that the per-test files +// use as their starting state. + +use anchor_lang::prelude::Pubkey; +use anchor_lang::solana_program::instruction::{AccountMeta, Instruction}; +use anchor_lang::{InstructionData, ToAccountMetas}; +use anchor_spl::associated_token::get_associated_token_address; +use litesvm::LiteSVM; +use solana_keypair::Keypair; +use solana_kite::{ + create_associated_token_account, create_token_mint, create_wallet, + mint_tokens_to_token_account, send_transaction_from_instructions, +}; +use solana_signer::Signer; + +use super::squads::{ + forge_program_config, install_squads_program, multisig_create_v2_instruction, multisig_pda, + proposal_approve_instruction, proposal_create_instruction, serialize_transaction_message, + vault_pda, vault_transaction_create_instruction, vault_transaction_execute_instruction, + CompiledInstruction, Committee, +}; + + +// USDC has 6 decimals, so we mirror that here for a realistic prize-amount +// feel in tests. +pub const PRIZE_MINT_DECIMALS: u8 = 6; +pub const ONE_TOKEN: u64 = 10u64.pow(PRIZE_MINT_DECIMALS as u32); + +pub struct World { + pub svm: LiteSVM, + pub payer: Keypair, + pub committee: Committee, + pub mint: Pubkey, + pub mint_authority: Keypair, + // Treasury keypair used as the Squads program-config treasury. With a + // creation fee of 0, it never receives lamports, but it must still be a + // System-owned account so the SystemProgram transfer accounts validate. + pub treasury: Keypair, +} + +pub fn setup_world() -> World { + let mut svm = LiteSVM::new(); + + // Load our hackathon program. The `.so` is built by `cargo build-sbf` — + // the test harness expects it to exist at the canonical anchor target + // path. + let hackathon_so = include_bytes!("../../../../target/deploy/hackathon.so"); + svm.add_program(hackathon::id(), hackathon_so).unwrap(); + + // Load Squads v4 from the vendored fixture. + install_squads_program(&mut svm); + + let payer = create_wallet(&mut svm, 100_000_000_000).unwrap(); + + // Treasury account: a fresh system-owned wallet. Funded so it stays + // rent-exempt as a SystemAccount even though the creation fee is 0. + let treasury = create_wallet(&mut svm, 1_000_000_000).unwrap(); + forge_program_config(&mut svm, &treasury.pubkey()); + + // Committee. Each member is independently funded so they can pay + // transaction fees when voting. + let alice = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let bob = create_wallet(&mut svm, 10_000_000_000).unwrap(); + let carol = create_wallet(&mut svm, 10_000_000_000).unwrap(); + + let create_key = Keypair::new(); + let (multisig, _bump) = multisig_pda(&create_key.pubkey()); + let (vault, vault_bump) = vault_pda(&multisig, 0); + + let committee = Committee { + alice, + bob, + carol, + create_key, + multisig, + vault, + vault_bump, + }; + + // Create the multisig (threshold 2-of-3, all members hold every + // permission). Payer funds the multisig account rent. + let create_ix = multisig_create_v2_instruction( + &committee.create_key.pubkey(), + &payer.pubkey(), + &treasury.pubkey(), + committee.members_sorted(), + 2, + ); + send_transaction_from_instructions( + &mut svm, + vec![create_ix], + &[&payer, &committee.create_key], + &payer.pubkey(), + ) + .expect("multisig_create_v2 succeeded"); + + // Vault PDA needs lamports to fund downstream ATAs and to sign CPIs + // (rent is paid from the executing member, but the vault itself must + // be rent-exempt for any SystemProgram interactions). + svm.airdrop(&committee.vault, 5_000_000_000).unwrap(); + + // Prize-denominating mint. Mint authority is a throwaway keypair held + // by the test, not the committee. solana_kite::create_token_mint takes + // the mint authority as a Keypair and returns the mint Pubkey directly; + // the authority also pays the initialize-mint transaction fee, so it has + // to be funded first. + let mint_authority = create_wallet(&mut svm, 1_000_000_000).unwrap(); + let mint = create_token_mint(&mut svm, &mint_authority, PRIZE_MINT_DECIMALS, None) + .expect("mint created"); + + World { + svm, + payer, + committee, + mint, + mint_authority, + treasury, + } +} + +// Run a full Squads multisig flow against a single instruction signed by the +// vault: create vault transaction, create proposal, approve with two members +// (Alice and Bob — Carol's vote is unnecessary at threshold 2), execute. +// +// `inner` is the instruction the vault should sign. Its `AccountMeta.pubkey` +// list is taken verbatim as the message account_keys; `is_signer` / +// `is_writable` flags are used both to encode the compiled message and to +// build the `remaining_accounts` vector passed to `vault_transaction_execute`. +pub fn run_through_multisig(world: &mut World, inner: Instruction) -> u64 { + let multisig_account = world.svm.get_account(&world.committee.multisig).unwrap(); + let current_index = u64::from_le_bytes( + multisig_account.data[8 + 32 + 32 + 2 + 4..8 + 32 + 32 + 2 + 4 + 8] + .try_into() + .unwrap(), + ); + let next_index = current_index + 1; + + // ----- Compile the inner instruction into Squads' message format ----- + // + // Layout we need: + // account_keys = [vault, ...other_accounts..., inner.program_id] + // + // The vault must be at index 0 and is the (only) signer in this + // message. Squads validates that account_keys[0..num_signers] are signed + // by the vault PDA at execute time. + + let vault = world.committee.vault; + let inner_program_id = inner.program_id; + + // Collect unique accounts referenced by the inner instruction, excluding + // the vault (which we always place at index 0) and the inner program + // (which we always append last). Preserve first-seen order. + let mut account_keys: Vec = vec![vault]; + let mut is_writable: Vec = vec![true]; // vault is always treated as writable in our tests + let mut is_signer_flags: Vec = vec![true]; // vault signs (via PDA) + + for meta in &inner.accounts { + if meta.pubkey == vault { + // Already inserted at index 0; merge writable flag conservatively. + if meta.is_writable { + is_writable[0] = true; + } + continue; + } + if let Some(existing) = account_keys.iter().position(|k| *k == meta.pubkey) { + if meta.is_writable { + is_writable[existing] = true; + } + // Inner instructions never have non-vault signers in our tests. + } else { + account_keys.push(meta.pubkey); + is_writable.push(meta.is_writable); + is_signer_flags.push(false); + } + } + + // Append program id. Programs are never signers and never writable. + let program_index = account_keys.len() as u8; + account_keys.push(inner_program_id); + is_writable.push(false); + is_signer_flags.push(false); + + // Squads message header counts. Layout convention: + // keys[0..num_signers] = signers + // of which [0..num_writable_signers] are writable + // keys[num_signers..num_signers+num_writable_non_signers] = writable non-signers + // rest = readonly non-signers + // + // Reorder so the layout matches that contract. + let num_signers = is_signer_flags.iter().filter(|s| **s).count() as u8; + debug_assert!(num_signers >= 1, "vault must sign"); + + // After our construction above, only `vault` is a signer and it sits at + // index 0. Verify and split signers/non-signers without further + // shuffling: signers = [0], non-signers = [1..]. + debug_assert!(is_signer_flags[0], "vault index 0 must be signer"); + for flag in is_signer_flags.iter().skip(1) { + debug_assert!(!*flag, "no other signers expected in inner ix"); + } + + let num_writable_signers = if is_writable[0] { 1 } else { 0 }; + + // Reorder non-signers so writable ones come first. + let mut writable_non_signers: Vec<(Pubkey, usize)> = Vec::new(); + let mut readonly_non_signers: Vec<(Pubkey, usize)> = Vec::new(); + for (i, key) in account_keys.iter().enumerate().skip(1) { + if is_writable[i] { + writable_non_signers.push((*key, i)); + } else { + readonly_non_signers.push((*key, i)); + } + } + let num_writable_non_signers = writable_non_signers.len() as u8; + + // Rebuild `account_keys` and an index-remapping table. + let mut remap = vec![0u8; account_keys.len()]; + let mut new_keys: Vec = Vec::with_capacity(account_keys.len()); + new_keys.push(account_keys[0]); // vault + remap[0] = 0; + let mut next_slot: u8 = 1; + for (key, old_index) in &writable_non_signers { + new_keys.push(*key); + remap[*old_index] = next_slot; + next_slot += 1; + } + for (key, old_index) in &readonly_non_signers { + new_keys.push(*key); + remap[*old_index] = next_slot; + next_slot += 1; + } + // Program id: find its old index and remap. + let old_program_index = program_index as usize; + let new_program_index = remap[old_program_index]; + + let compiled_account_indexes: Vec = inner + .accounts + .iter() + .map(|meta| { + if meta.pubkey == vault { + 0 + } else { + let old = account_keys.iter().position(|k| *k == meta.pubkey).unwrap(); + remap[old] + } + }) + .collect(); + + let compiled = CompiledInstruction { + program_id_index: new_program_index, + account_indexes: compiled_account_indexes, + data: inner.data.clone(), + }; + + let message = serialize_transaction_message( + num_signers, + num_writable_signers, + num_writable_non_signers, + &new_keys, + &[compiled], + ); + + // ----- 1. vault_transaction_create ----- + let create_tx_ix = vault_transaction_create_instruction( + &world.committee.multisig, + next_index, + 0, // vault_index + &world.committee.alice.pubkey(), + &world.payer.pubkey(), + message, + ); + + // ----- 2. proposal_create ----- + let create_proposal_ix = proposal_create_instruction( + &world.committee.multisig, + next_index, + &world.committee.alice.pubkey(), + &world.payer.pubkey(), + ); + + // ----- 3. proposal_approve x2 (Alice + Bob, threshold = 2) ----- + let approve_alice_ix = proposal_approve_instruction( + &world.committee.multisig, + next_index, + &world.committee.alice.pubkey(), + ); + let approve_bob_ix = proposal_approve_instruction( + &world.committee.multisig, + next_index, + &world.committee.bob.pubkey(), + ); + + // ----- 4. vault_transaction_execute ----- + // + // remaining_accounts must follow new_keys order, with writable / signer + // flags matching the compiled-message contract. Squads sets the vault's + // signer bit via its own PDA signing — we must NOT mark it as a signer + // in the outer transaction. + let remaining_accounts: Vec = new_keys + .iter() + .enumerate() + .map(|(i, key)| { + let writable = if i == 0 { + is_writable[0] + } else { + let old = account_keys.iter().position(|k| k == key).unwrap(); + is_writable[old] + }; + AccountMeta { + pubkey: *key, + is_signer: false, + is_writable: writable, + } + }) + .collect(); + + let execute_ix = vault_transaction_execute_instruction( + &world.committee.multisig, + next_index, + &world.committee.alice.pubkey(), + remaining_accounts, + ); + + send_transaction_from_instructions( + &mut world.svm, + vec![create_tx_ix, create_proposal_ix], + &[&world.payer, &world.committee.alice], + &world.payer.pubkey(), + ) + .expect("create vault transaction + proposal"); + + send_transaction_from_instructions( + &mut world.svm, + vec![approve_alice_ix], + &[&world.committee.alice], + &world.committee.alice.pubkey(), + ) + .expect("alice approves"); + + send_transaction_from_instructions( + &mut world.svm, + vec![approve_bob_ix], + &[&world.committee.bob], + &world.committee.bob.pubkey(), + ) + .expect("bob approves"); + + send_transaction_from_instructions( + &mut world.svm, + vec![execute_ix], + &[&world.committee.alice], + &world.committee.alice.pubkey(), + ) + .expect("vault_transaction_execute"); + + next_index +} + +// ----- Hackathon program instruction builders -------------------------- +// +// Built via Anchor's generated `accounts::*` and `instruction::*` modules so +// we get compile-time wire-format correctness for our own program. We only +// hand-roll the wire format for Squads. + +pub fn create_hackathon_instruction( + payer: &Pubkey, + authority: &Pubkey, + hackathon: &Pubkey, + name: String, +) -> Instruction { + Instruction { + program_id: hackathon::id(), + accounts: hackathon::accounts::CreateHackathon { + payer: *payer, + authority: *authority, + hackathon: *hackathon, + system_program: anchor_lang::solana_program::system_program::ID, + } + .to_account_metas(None), + data: hackathon::instruction::CreateHackathon { name }.data(), + } +} + +pub fn add_prize_instruction( + payer: &Pubkey, + authority: &Pubkey, + hackathon: &Pubkey, + mint: &Pubkey, + prize: &Pubkey, + vault: &Pubkey, + amount: u64, +) -> Instruction { + Instruction { + program_id: hackathon::id(), + accounts: hackathon::accounts::AddPrize { + payer: *payer, + authority: *authority, + hackathon: *hackathon, + mint: *mint, + prize: *prize, + vault: *vault, + associated_token_program: anchor_spl::associated_token::ID, + token_program: anchor_spl::token::ID, + system_program: anchor_lang::solana_program::system_program::ID, + } + .to_account_metas(None), + data: hackathon::instruction::AddPrize { amount }.data(), + } +} + +pub fn set_winner_instruction( + authority: &Pubkey, + hackathon: &Pubkey, + prize: &Pubkey, + prize_index: u8, + winner: Pubkey, +) -> Instruction { + Instruction { + program_id: hackathon::id(), + accounts: hackathon::accounts::SetWinner { + authority: *authority, + hackathon: *hackathon, + prize: *prize, + } + .to_account_metas(None), + data: hackathon::instruction::SetWinner { + prize_index, + winner, + } + .data(), + } +} + +pub fn pay_winner_instruction( + caller: &Pubkey, + hackathon: &Pubkey, + prize: &Pubkey, + mint: &Pubkey, + vault: &Pubkey, + winner_token_account: &Pubkey, + prize_index: u8, +) -> Instruction { + Instruction { + program_id: hackathon::id(), + accounts: hackathon::accounts::PayWinner { + caller: *caller, + hackathon: *hackathon, + prize: *prize, + mint: *mint, + vault: *vault, + winner_token_account: *winner_token_account, + token_program: anchor_spl::token::ID, + } + .to_account_metas(None), + data: hackathon::instruction::PayWinner { prize_index }.data(), + } +} + +pub fn cancel_prize_instruction( + authority: &Pubkey, + rent_destination: &Pubkey, + hackathon: &Pubkey, + prize: &Pubkey, + mint: &Pubkey, + vault: &Pubkey, + refund_token_account: &Pubkey, + prize_index: u8, +) -> Instruction { + Instruction { + program_id: hackathon::id(), + accounts: hackathon::accounts::CancelPrize { + authority: *authority, + rent_destination: *rent_destination, + hackathon: *hackathon, + prize: *prize, + mint: *mint, + vault: *vault, + refund_token_account: *refund_token_account, + token_program: anchor_spl::token::ID, + } + .to_account_metas(None), + data: hackathon::instruction::CancelPrize { prize_index }.data(), + } +} + +pub fn close_hackathon_instruction( + authority: &Pubkey, + rent_destination: &Pubkey, + hackathon: &Pubkey, + prize_accounts: &[Pubkey], +) -> Instruction { + let mut accounts = hackathon::accounts::CloseHackathon { + authority: *authority, + rent_destination: *rent_destination, + hackathon: *hackathon, + } + .to_account_metas(None); + for prize in prize_accounts { + accounts.push(AccountMeta::new(*prize, false)); + } + Instruction { + program_id: hackathon::id(), + accounts, + data: hackathon::instruction::CloseHackathon {}.data(), + } +} + +// ----- PDA helpers for hackathon accounts ------------------------------ + +pub fn hackathon_pda(authority: &Pubkey, name: &str) -> (Pubkey, u8) { + let name_seed = hackathon_name_seed(name); + Pubkey::find_program_address( + &[b"hackathon", authority.as_ref(), name_seed.as_ref()], + &hackathon::id(), + ) +} + +pub fn prize_pda(hackathon_account: &Pubkey, index: u8) -> (Pubkey, u8) { + Pubkey::find_program_address( + &[b"prize", hackathon_account.as_ref(), &[index]], + &hackathon::id(), + ) +} + +pub fn prize_vault_address(prize: &Pubkey, mint: &Pubkey) -> Pubkey { + get_associated_token_address(prize, mint) +} + +// Local mirror of the program's `name_seed` so tests can derive the PDA +// without depending on the program's private `instructions::shared` module. +fn hackathon_name_seed(name: &str) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(name.as_bytes()); + hasher.finalize().into() +} + +// Mint `amount` tokens to the prize's vault ATA. Used by tests that need a +// funded prize. solana_kite arg order: (svm, mint, token_account, amount, +// mint_authority). +// +// We expire the blockhash before each call so identical `fund_prize_vault` +// invocations within one test (e.g. fund → consume → fund again) produce +// unique signatures and avoid LiteSVM's `AlreadyProcessed` dedup. +pub fn fund_prize_vault(world: &mut World, vault: &Pubkey, amount: u64) { + world.svm.expire_blockhash(); + mint_tokens_to_token_account( + &mut world.svm, + &world.mint, + vault, + amount, + &world.mint_authority, + ) + .expect("fund prize vault"); +} + +// Create a token account owned by `owner` for the world's mint. solana_kite +// arg order: (svm, owner, mint, payer). +pub fn create_token_account_for(world: &mut World, owner: &Pubkey) -> Pubkey { + create_associated_token_account(&mut world.svm, owner, &world.mint, &world.payer) + .expect("create token account") +} diff --git a/tokens/hackathon/anchor/programs/hackathon/tests/fixtures/squads_multisig.so b/tokens/hackathon/anchor/programs/hackathon/tests/fixtures/squads_multisig.so new file mode 100644 index 000000000..017fdadcb Binary files /dev/null and b/tokens/hackathon/anchor/programs/hackathon/tests/fixtures/squads_multisig.so differ diff --git a/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_failure_cases.rs b/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_failure_cases.rs new file mode 100644 index 000000000..9fc6abb1b --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_failure_cases.rs @@ -0,0 +1,217 @@ +// Failure cases: pay_winner before funded / before winner / after paid, and +// the program-level signer check refusing a non-multisig caller on a +// privileged handler. + +mod common; + +use common::world::*; +use solana_kite::send_transaction_from_instructions; +use solana_signer::Signer; + +const HACKATHON_NAME: &str = "Anchor Bash 2026"; +const PRIZE_AMOUNT: u64 = 100 * ONE_TOKEN; + +// Bring a hackathon + one registered prize into existence, returning the +// hackathon PDA, prize PDA and vault ATA. Vault is NOT funded; no winner is +// set. Each failure-case test starts from this minimal state and only +// performs the steps it needs. +struct PrizeWorld { + world: World, + hackathon: anchor_lang::prelude::Pubkey, + prize: anchor_lang::prelude::Pubkey, + vault: anchor_lang::prelude::Pubkey, +} + +fn setup_with_one_prize() -> PrizeWorld { + let mut world = setup_world(); + let (hackathon, _) = hackathon_pda(&world.committee.vault, HACKATHON_NAME); + let create_ix = create_hackathon_instruction( + &world.committee.vault, + &world.committee.vault, + &hackathon, + HACKATHON_NAME.to_string(), + ); + run_through_multisig(&mut world, create_ix); + + let (prize, _) = prize_pda(&hackathon, 0); + let vault = prize_vault_address(&prize, &world.mint); + let add_ix = add_prize_instruction( + &world.committee.vault, + &world.committee.vault, + &hackathon, + &world.mint, + &prize, + &vault, + PRIZE_AMOUNT, + ); + run_through_multisig(&mut world, add_ix); + + PrizeWorld { + world, + hackathon, + prize, + vault, + } +} + +#[test] +fn pay_winner_fails_when_no_winner_set() { + let mut pw = setup_with_one_prize(); + fund_prize_vault(&mut pw.world, &pw.vault, PRIZE_AMOUNT); + + let winner = solana_keypair::Keypair::new(); + pw.world + .svm + .airdrop(&winner.pubkey(), 1_000_000_000) + .unwrap(); + let winner_ata = create_token_account_for(&mut pw.world, &winner.pubkey()); + + let caller = solana_kite::create_wallet(&mut pw.world.svm, 1_000_000_000).unwrap(); + let ix = pay_winner_instruction( + &caller.pubkey(), + &pw.hackathon, + &pw.prize, + &pw.world.mint, + &pw.vault, + &winner_ata, + 0, + ); + let result = send_transaction_from_instructions( + &mut pw.world.svm, + vec![ix], + &[&caller], + &caller.pubkey(), + ); + assert!(result.is_err(), "expected NoWinner failure"); +} + +#[test] +fn pay_winner_fails_when_vault_underfunded() { + let mut pw = setup_with_one_prize(); + // Fund the vault with less than the prize amount. + fund_prize_vault(&mut pw.world, &pw.vault, PRIZE_AMOUNT - 1); + + let winner = solana_keypair::Keypair::new(); + pw.world + .svm + .airdrop(&winner.pubkey(), 1_000_000_000) + .unwrap(); + let winner_ata = create_token_account_for(&mut pw.world, &winner.pubkey()); + + let set_ix = set_winner_instruction( + &pw.world.committee.vault, + &pw.hackathon, + &pw.prize, + 0, + winner.pubkey(), + ); + run_through_multisig(&mut pw.world, set_ix); + + let caller = solana_kite::create_wallet(&mut pw.world.svm, 1_000_000_000).unwrap(); + let ix = pay_winner_instruction( + &caller.pubkey(), + &pw.hackathon, + &pw.prize, + &pw.world.mint, + &pw.vault, + &winner_ata, + 0, + ); + let result = send_transaction_from_instructions( + &mut pw.world.svm, + vec![ix], + &[&caller], + &caller.pubkey(), + ); + assert!(result.is_err(), "expected Underfunded failure"); +} + +#[test] +fn pay_winner_fails_when_already_paid() { + let mut pw = setup_with_one_prize(); + fund_prize_vault(&mut pw.world, &pw.vault, PRIZE_AMOUNT); + + let winner = solana_keypair::Keypair::new(); + pw.world + .svm + .airdrop(&winner.pubkey(), 1_000_000_000) + .unwrap(); + let winner_ata = create_token_account_for(&mut pw.world, &winner.pubkey()); + + let set_ix = set_winner_instruction( + &pw.world.committee.vault, + &pw.hackathon, + &pw.prize, + 0, + winner.pubkey(), + ); + run_through_multisig(&mut pw.world, set_ix); + + // First payment succeeds. + let caller = solana_kite::create_wallet(&mut pw.world.svm, 1_000_000_000).unwrap(); + let ix1 = pay_winner_instruction( + &caller.pubkey(), + &pw.hackathon, + &pw.prize, + &pw.world.mint, + &pw.vault, + &winner_ata, + 0, + ); + send_transaction_from_instructions( + &mut pw.world.svm, + vec![ix1], + &[&caller], + &caller.pubkey(), + ) + .expect("first pay_winner succeeds"); + + // Second payment must fail. Re-fund the vault first so we're testing + // the AlreadyPaid guard, not the Underfunded guard. + fund_prize_vault(&mut pw.world, &pw.vault, PRIZE_AMOUNT); + let ix2 = pay_winner_instruction( + &caller.pubkey(), + &pw.hackathon, + &pw.prize, + &pw.world.mint, + &pw.vault, + &winner_ata, + 0, + ); + let result = send_transaction_from_instructions( + &mut pw.world.svm, + vec![ix2], + &[&caller], + &caller.pubkey(), + ); + assert!(result.is_err(), "expected AlreadyPaid failure"); +} + +#[test] +fn set_winner_fails_when_signer_is_not_multisig_authority() { + let mut pw = setup_with_one_prize(); + + // An attacker signs `set_winner` directly with their own keypair instead + // of going through the Squads vault. The program's `has_one = authority` + // constraint on the Hackathon account must reject this. + let attacker = solana_kite::create_wallet(&mut pw.world.svm, 1_000_000_000).unwrap(); + let winner = solana_keypair::Keypair::new(); + + let ix = set_winner_instruction( + &attacker.pubkey(), + &pw.hackathon, + &pw.prize, + 0, + winner.pubkey(), + ); + let result = send_transaction_from_instructions( + &mut pw.world.svm, + vec![ix], + &[&attacker], + &attacker.pubkey(), + ); + assert!( + result.is_err(), + "non-multisig signer must not be accepted on set_winner" + ); +} diff --git a/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_happy_path.rs b/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_happy_path.rs new file mode 100644 index 000000000..579c99fa5 --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_happy_path.rs @@ -0,0 +1,89 @@ +// Happy-path end-to-end test: a Squads 2-of-3 committee +// (Alice / Bob / Carol) creates a hackathon, registers a prize, funds the +// prize vault, sets a winner via multisig vote, then any wallet pays the +// winner. Verifies token balances at the end. + +mod common; + +use common::world::*; +use solana_kite::{get_token_account_balance, send_transaction_from_instructions}; +use solana_signer::Signer; + +#[test] +fn happy_path_create_fund_set_winner_pay() { + let mut world = setup_world(); + + // 1. create_hackathon (multisig-signed) + let hackathon_name = "Anchor Bash 2026".to_string(); + let (hackathon_account, _bump) = + hackathon_pda(&world.committee.vault, &hackathon_name); + + let create_ix = create_hackathon_instruction( + &world.committee.vault, + &world.committee.vault, + &hackathon_account, + hackathon_name.clone(), + ); + run_through_multisig(&mut world, create_ix); + + // 2. add_prize (multisig-signed) + let (prize_account, _bump) = prize_pda(&hackathon_account, 0); + let vault_ata = prize_vault_address(&prize_account, &world.mint); + let prize_amount = 100 * ONE_TOKEN; + + let add_ix = add_prize_instruction( + &world.committee.vault, + &world.committee.vault, + &hackathon_account, + &world.mint, + &prize_account, + &vault_ata, + prize_amount, + ); + run_through_multisig(&mut world, add_ix); + + // 3. Fund the prize vault. This is just an SPL mint_to from the test's + // mint authority — in production the committee would fund the vault + // via a Squads-signed token transfer. + fund_prize_vault(&mut world, &vault_ata, prize_amount); + + // 4. set_winner (multisig-signed) + let winner_wallet = solana_keypair::Keypair::new(); + world.svm.airdrop(&winner_wallet.pubkey(), 1_000_000_000).unwrap(); + let winner_ata = create_token_account_for(&mut world, &winner_wallet.pubkey()); + + let set_winner_ix = set_winner_instruction( + &world.committee.vault, + &hackathon_account, + &prize_account, + 0, + winner_wallet.pubkey(), + ); + run_through_multisig(&mut world, set_winner_ix); + + // 5. pay_winner — unpermissioned. Anyone can sign. We use a totally + // unrelated keypair to prove the call is not multisig-gated. + let bystander = solana_kite::create_wallet(&mut world.svm, 1_000_000_000).unwrap(); + let pay_ix = pay_winner_instruction( + &bystander.pubkey(), + &hackathon_account, + &prize_account, + &world.mint, + &vault_ata, + &winner_ata, + 0, + ); + send_transaction_from_instructions( + &mut world.svm, + vec![pay_ix], + &[&bystander], + &bystander.pubkey(), + ) + .expect("bystander pays winner"); + + // 6. Winner now holds exactly `prize_amount`. + let balance = get_token_account_balance(&world.svm, &winner_ata).unwrap(); + assert_eq!(balance, prize_amount); +} + + diff --git a/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_lifecycle.rs b/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_lifecycle.rs new file mode 100644 index 000000000..c1b8082ea --- /dev/null +++ b/tokens/hackathon/anchor/programs/hackathon/tests/test_hackathon_lifecycle.rs @@ -0,0 +1,234 @@ +// Lifecycle tests: cancel_prize (multisig refunds vault) and close_hackathon +// (only after every prize is paid or cancelled). + +mod common; + +use common::world::*; +use solana_kite::{get_token_account_balance, send_transaction_from_instructions}; +use solana_signer::Signer; + +const HACKATHON_NAME: &str = "Anchor Bash 2026"; +const PRIZE_AMOUNT: u64 = 100 * ONE_TOKEN; + +#[test] +fn cancel_prize_refunds_vault_to_committee_account() { + let mut world = setup_world(); + // Bind keys to locals up front so we can pass them by value while also + // holding a mutable borrow on `world`. + let vault_key = world.committee.vault; + let mint = world.mint; + + let (hackathon, _) = hackathon_pda(&vault_key, HACKATHON_NAME); + let ix = create_hackathon_instruction( + &vault_key, + &vault_key, + &hackathon, + HACKATHON_NAME.to_string(), + ); + run_through_multisig(&mut world, ix); + + let (prize, _) = prize_pda(&hackathon, 0); + let prize_vault = prize_vault_address(&prize, &mint); + let ix = add_prize_instruction( + &vault_key, + &vault_key, + &hackathon, + &mint, + &prize, + &prize_vault, + PRIZE_AMOUNT, + ); + run_through_multisig(&mut world, ix); + + fund_prize_vault(&mut world, &prize_vault, PRIZE_AMOUNT); + let refund_target = create_token_account_for(&mut world, &vault_key); + + let ix = cancel_prize_instruction( + &vault_key, + &vault_key, // rent destination + &hackathon, + &prize, + &mint, + &prize_vault, + &refund_target, + 0, + ); + run_through_multisig(&mut world, ix); + + // Refund target now holds the full prize amount. + assert_eq!( + get_token_account_balance(&world.svm, &refund_target).unwrap(), + PRIZE_AMOUNT + ); + + // Vault account is closed. + assert!( + world + .svm + .get_account(&prize_vault) + .map(|a| a.data.is_empty()) + .unwrap_or(true), + "vault should be closed after cancel_prize" + ); + + // A subsequent pay_winner attempt must fail: the prize is now cancelled. + let winner = solana_keypair::Keypair::new(); + world + .svm + .airdrop(&winner.pubkey(), 1_000_000_000) + .unwrap(); + let winner_ata = create_token_account_for(&mut world, &winner.pubkey()); + let attacker = solana_kite::create_wallet(&mut world.svm, 1_000_000_000).unwrap(); + let pay_ix = pay_winner_instruction( + &attacker.pubkey(), + &hackathon, + &prize, + &mint, + &prize_vault, + &winner_ata, + 0, + ); + let result = send_transaction_from_instructions( + &mut world.svm, + vec![pay_ix], + &[&attacker], + &attacker.pubkey(), + ); + assert!( + result.is_err(), + "pay_winner on cancelled prize must fail (vault closed + Cancelled flag)" + ); +} + +#[test] +fn close_hackathon_succeeds_once_all_prizes_resolved() { + let mut world = setup_world(); + let vault_key = world.committee.vault; + let mint = world.mint; + + let (hackathon, _) = hackathon_pda(&vault_key, HACKATHON_NAME); + let ix = create_hackathon_instruction( + &vault_key, + &vault_key, + &hackathon, + HACKATHON_NAME.to_string(), + ); + run_through_multisig(&mut world, ix); + + // Add two prizes. We'll pay one and cancel the other. + let mut prize_pdas = Vec::new(); + let mut prize_vaults = Vec::new(); + for index in 0..2u8 { + let (prize, _) = prize_pda(&hackathon, index); + let vault = prize_vault_address(&prize, &mint); + let ix = add_prize_instruction( + &vault_key, + &vault_key, + &hackathon, + &mint, + &prize, + &vault, + PRIZE_AMOUNT, + ); + run_through_multisig(&mut world, ix); + prize_pdas.push(prize); + prize_vaults.push(vault); + } + + // Pay prize 0. + fund_prize_vault(&mut world, &prize_vaults[0], PRIZE_AMOUNT); + let winner = solana_keypair::Keypair::new(); + world.svm.airdrop(&winner.pubkey(), 1_000_000_000).unwrap(); + let winner_ata = create_token_account_for(&mut world, &winner.pubkey()); + let ix = set_winner_instruction( + &vault_key, + &hackathon, + &prize_pdas[0], + 0, + winner.pubkey(), + ); + run_through_multisig(&mut world, ix); + let caller = solana_kite::create_wallet(&mut world.svm, 1_000_000_000).unwrap(); + let pay_ix = pay_winner_instruction( + &caller.pubkey(), + &hackathon, + &prize_pdas[0], + &mint, + &prize_vaults[0], + &winner_ata, + 0, + ); + send_transaction_from_instructions( + &mut world.svm, + vec![pay_ix], + &[&caller], + &caller.pubkey(), + ) + .expect("pay prize 0"); + + // Cancel prize 1. + let refund_target = create_token_account_for(&mut world, &vault_key); + let ix = cancel_prize_instruction( + &vault_key, + &vault_key, + &hackathon, + &prize_pdas[1], + &mint, + &prize_vaults[1], + &refund_target, + 1, + ); + run_through_multisig(&mut world, ix); + + // Now close_hackathon should succeed. + let close_ix = close_hackathon_instruction(&vault_key, &vault_key, &hackathon, &prize_pdas); + run_through_multisig(&mut world, close_ix); + + // Hackathon account is closed (zero-length data or absent). + let closed = world.svm.get_account(&hackathon); + assert!( + closed.map(|a| a.data.is_empty()).unwrap_or(true), + "hackathon account should be closed" + ); +} + +#[test] +fn close_hackathon_fails_while_prize_is_still_active() { + let mut world = setup_world(); + let vault_key = world.committee.vault; + let mint = world.mint; + + let (hackathon, _) = hackathon_pda(&vault_key, HACKATHON_NAME); + let ix = create_hackathon_instruction( + &vault_key, + &vault_key, + &hackathon, + HACKATHON_NAME.to_string(), + ); + run_through_multisig(&mut world, ix); + + let (prize, _) = prize_pda(&hackathon, 0); + let prize_vault = prize_vault_address(&prize, &mint); + let ix = add_prize_instruction( + &vault_key, + &vault_key, + &hackathon, + &mint, + &prize, + &prize_vault, + PRIZE_AMOUNT, + ); + run_through_multisig(&mut world, ix); + + // Try to close while the prize is neither paid nor cancelled. The + // `vault_transaction_execute` instruction must fail because the inner + // close_hackathon returns `PrizesStillActive`. + let close_ix = close_hackathon_instruction(&vault_key, &vault_key, &hackathon, &[prize]); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_through_multisig(&mut world, close_ix); + })); + assert!( + result.is_err(), + "close_hackathon must fail while prizes remain active" + ); +}