use { anchor_lang::{ solana_program::{ instruction::Instruction, program_option::COption, program_pack::Pack, pubkey::Pubkey, rent::Rent, system_instruction, }, AccountDeserialize, InstructionData, ToAccountMetas, }, smart_updown::{constants::VAULT_SEED, state::{Bet, Global}}, solana_account::{Account as SolanaAccount}, solana_clock::Clock, solana_keypair::Keypair, solana_program_test::{BanksClient, ProgramTest, ProgramTestContext}, solana_signer::Signer, solana_transaction::Transaction, spl_associated_token_account, spl_token, spl_token::state::{Account as TokenData, AccountState, Mint}, }; const CLOCK_ADDR: &str = "SysvarC1ock11111111111111111111111111111111"; const SYSTEM_ADDR: &str = "11111111111111111111111111111111"; const MIN_AMOUNT: u64 = 1_000_000_000; // minimum bet, token units const MULTIPLIER_BPS: u64 = 13_000; // 1.3x const EXPIRY_SECONDS: i64 = 15; const START_TIME: i64 = 1_700_000_000; // SPL token (фантики) environment const TOKEN_DECIMALS: u8 = 6; const MINT_SIZE: u64 = 82; // classic SPL token mint account size const TOKEN_ACCOUNT_SIZE: u64 = 165; // classic SPL token account size const VAULT_TOKEN_SUPPLY: u64 = 100 * MIN_AMOUNT; // vault seed, covers many payouts const BETTOR_TOKEN_SUPPLY: u64 = 10 * MIN_AMOUNT; // per-bettor token supply /// Fixed фантики mint: mint + vault token accounts are pre-loaded into /// genesis because the vault is a PDA and cannot sign its own create_account. const MINT: Pubkey = Pubkey::new_from_array([3u8; 32]); fn pid() -> Pubkey { smart_updown::id() } fn global_pda() -> Pubkey { Pubkey::find_program_address(&[b"global"], &pid()).0 } fn vault_pda() -> Pubkey { Pubkey::find_program_address(&[b"global", VAULT_SEED], &pid()).0 } fn bet_pda(id: u64) -> Pubkey { Pubkey::find_program_address(&[b"bet", &id.to_le_bytes()], &pid()).0 } /// Boot the program-test context with the фантики mint and the program's /// token vault (PDA([global, vault])) pre-loaded into genesis. The vault is a /// PDA owned by itself, so it can never sign a top-level create_account; the /// account data below matches what the token program would have created. async fn setup() -> (ProgramTestContext, Keypair) { let admin = Keypair::new(); let mint_data = { let m = Mint { mint_authority: COption::Some(admin.pubkey()), supply: 0, decimals: TOKEN_DECIMALS, is_initialized: true, freeze_authority: COption::None, }; let mut buf = [0u8; MINT_SIZE as usize]; Pack::pack(m, &mut buf).expect("pack mint"); buf }; let vault_data = { let a = TokenData { mint: MINT, owner: vault_pda(), amount: VAULT_TOKEN_SUPPLY, delegate: COption::None, state: AccountState::Initialized, is_native: COption::None, delegated_amount: 0, close_authority: COption::None, }; let mut buf = [0u8; TOKEN_ACCOUNT_SIZE as usize]; Pack::pack(a, &mut buf).expect("pack vault token account"); buf }; let mut pt = ProgramTest::default(); pt.add_program("smart_updown", pid(), None); let rent = Rent::default(); pt.add_account( MINT, SolanaAccount { lamports: rent.minimum_balance(MINT_SIZE as usize), data: mint_data.to_vec(), owner: spl_token::ID, executable: false, rent_epoch: 0, }, ); pt.add_account( vault_pda(), SolanaAccount { lamports: rent.minimum_balance(TOKEN_ACCOUNT_SIZE as usize), data: vault_data.to_vec(), owner: spl_token::ID, executable: false, rent_epoch: 0, }, ); // Fund the deploy keypair separately in the test; the validator in CI funds it. let context = pt.start_with_context().await; context.banks_client.process_transaction(Transaction::new_signed_with_payer( &[system_instruction::transfer( &context.payer.pubkey(), &admin.pubkey(), 100_000_000_000, )], Some(&context.payer.pubkey()), &[&context.payer], context.last_blockhash, )).await.unwrap(); (context, admin) } async fn set_clock(ctx: &mut ProgramTestContext, unix: i64) { let mut clk: Clock = ctx.banks_client.get_sysvar().await.unwrap(); clk.unix_timestamp = unix; ctx.set_sysvar(&clk); } async fn read_account_data( bank: &BanksClient, addr: &Pubkey, ) -> Vec { bank.get_account(*addr).await.unwrap().expect("account exists").data } fn read_global_typed(data: &[u8]) -> Global { Global::try_deserialize(&mut &data[..]).unwrap() } fn read_bet_typed(data: &[u8]) -> Bet { Bet::try_deserialize(&mut &data[..]).unwrap() } fn make_initialize_ix(admin: Pubkey) -> Instruction { Instruction::new_with_bytes( pid(), &smart_updown::instruction::Initialize { min_amount: MIN_AMOUNT, max_amount: 1_000_000_000_000, multiplier_bps: MULTIPLIER_BPS, expiry_seconds: EXPIRY_SECONDS, } .data(), smart_updown::accounts::Initialize { admin, global: global_pda(), system_program: SYSTEM_ADDR.parse().unwrap(), } .to_account_metas(None), ) } fn make_create_ix( id: u64, side: u64, amount: u64, entry_price: u64, bettor: Pubkey, bettor_ata: &Pubkey, ) -> Instruction { Instruction::new_with_bytes( pid(), &smart_updown::instruction::CreateBet { id, side, amount, entry_price, } .data(), smart_updown::accounts::CreateBet { bettor, global: global_pda(), bet: bet_pda(id), clock: CLOCK_ADDR.parse().unwrap(), system_program: SYSTEM_ADDR.parse().unwrap(), bettor_token: *bettor_ata, vault_token: vault_pda(), token_program: spl_token::ID, } .to_account_metas(None), ) } fn make_close_ix( id: u64, exit_price: u64, relayer: Pubkey, bettor: Pubkey, bettor_ata: &Pubkey, ) -> Instruction { Instruction::new_with_bytes( pid(), &smart_updown::instruction::CloseBet { id, exit_price }.data(), smart_updown::accounts::CloseBet { relayer, bet: bet_pda(id), global: global_pda(), bettor, clock: CLOCK_ADDR.parse().unwrap(), vault_token: vault_pda(), bettor_token: *bettor_ata, token_program: spl_token::ID, } .to_account_metas(None), ) } async fn run_ix( ctx: &mut ProgramTestContext, signer: &Keypair, ix: Instruction, ) -> Result<(), String> { let tx = Transaction::new_signed_with_payer( &[ix], Some(&signer.pubkey()), &[signer], ctx.last_blockhash, ); ctx.banks_client .process_transaction(tx) .await .map(|_| ()) .map_err(|e| format!("{:?}", e)) } async fn initialize(ctx: &mut ProgramTestContext, admin: &Keypair) { run_ix(ctx, admin, make_initialize_ix(admin.pubkey())) .await .expect("initialize"); } async fn airdrop_to(ctx: &mut ProgramTestContext, to: &Pubkey, lamports: u64) { let tx = Transaction::new_signed_with_payer( &[system_instruction::transfer( &ctx.payer.pubkey(), to, lamports, )], Some(&ctx.payer.pubkey()), &[&ctx.payer], ctx.last_blockhash, ); ctx.banks_client.process_transaction(tx).await.unwrap(); } /// Create the token mint, the program's token vault (PDA([global, vault]), /// owned by the global PDA), and seed the vault with tokens. // The фантики env (mint + seeded vault) is pre-loaded into genesis by setup(); // a PDA vault can never sign its own top-level create_account, so this only // returns the fixed mint the genesis accounts were built around. async fn create_token_env(_ctx: &mut ProgramTestContext, _admin: &Keypair) -> Pubkey { MINT } /// Mint tokens to a destination account (admin is the mint authority). async fn mint_to( ctx: &mut ProgramTestContext, admin: &Keypair, mint: &Pubkey, dest: &Pubkey, amount: u64, ) { let ix = spl_token::instruction::mint_to(&spl_token::ID, mint, dest, &admin.pubkey(), &[], amount) .expect("mint_to"); run_ix(ctx, admin, ix).await.expect("mint_to"); } /// New bettor: lamports account + associated token account + token supply. async fn create_bettor( ctx: &mut ProgramTestContext, admin: &Keypair, mint: &Pubkey, ) -> (Keypair, Pubkey) { let bettor = Keypair::new(); airdrop_to(ctx, &bettor.pubkey(), 100_000_000_000).await; let ata = spl_associated_token_account::get_associated_token_address(&bettor.pubkey(), mint); let ix = spl_associated_token_account::instruction::create_associated_token_account( &admin.pubkey(), &bettor.pubkey(), mint, &spl_token::ID, ); run_ix(ctx, admin, ix).await.expect("create ata"); mint_to(ctx, admin, mint, &ata, BETTOR_TOKEN_SUPPLY).await; (bettor, ata) } /// Read the `amount` field of a classic SPL token account (offset 64). async fn read_token_balance(ctx: &mut ProgramTestContext, addr: &Pubkey) -> u64 { let data = read_account_data(&ctx.banks_client, addr).await; u64::from_le_bytes(data[64..72].try_into().expect("amount offset")) } /// close_bet pays out by moving tokens out of the vault; the vault PDA signs /// that transfer CPI inside the program, so the transaction needs only the /// relayer's signature. async fn run_close_tx( ctx: &mut ProgramTestContext, signer: &Keypair, ix: &Instruction, ) -> Result<(), String> { // The vault PDA signs the payout CPI inside the program (via signer_seeds); // the transaction itself needs only the relayer's signature. let tx = Transaction::new_signed_with_payer( std::slice::from_ref(ix), Some(&signer.pubkey()), &[signer], ctx.last_blockhash, ); ctx.banks_client .process_transaction(tx) .await .map(|_| ()) .map_err(|e| format!("{:?}", e)) } async fn create_up_bet( ctx: &mut ProgramTestContext, side: u64, mint: &Pubkey, admin: &Keypair, ) -> (Keypair, Pubkey, u64, u64) { let (bettor, ata) = create_bettor(ctx, admin, mint).await; set_clock(ctx, START_TIME).await; let amount = MIN_AMOUNT; let entry_price = 100_000_000u64; let create_ix = make_create_ix(0, side, amount, entry_price, bettor.pubkey(), &ata); run_ix(ctx, &bettor, create_ix).await.expect("create_bet"); (bettor, ata, amount, entry_price) } #[tokio::test] async fn initialize_creates_global() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let acc = read_account_data(&ctx.banks_client, &global_pda()).await; let g = read_global_typed(&acc); assert_eq!(g.admin, admin.pubkey()); assert_eq!(g.relayer, admin.pubkey()); assert_eq!(g.counter, 0); assert_eq!(g.multiplier_bps, MULTIPLIER_BPS); assert_eq!(g.expiry_seconds, EXPIRY_SECONDS); assert_eq!(g.paused, 0); } #[tokio::test] async fn create_bet_escrows_tokens() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; assert_eq!( read_token_balance(&mut ctx, &vault_pda()).await, VAULT_TOKEN_SUPPLY + amount ); assert_eq!( read_token_balance(&mut ctx, &ata).await, BETTOR_TOKEN_SUPPLY - amount ); let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await); assert_eq!(b.bettor, bettor.pubkey()); assert_eq!(b.side, 0); assert_eq!(b.amount, amount); assert_eq!(b.entry_price, entry_price); assert_eq!(b.bet_time, START_TIME); assert_eq!(b.expire_time, START_TIME + EXPIRY_SECONDS); assert_eq!(b.status, 0); assert_eq!(read_global_typed(&read_account_data(&ctx.banks_client, &global_pda()).await).counter, 1); } #[tokio::test] async fn close_bet_user_wins_up() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let exit_price = entry_price + 1_000; let before = read_token_balance(&mut ctx, &ata).await; let payout = amount * MULTIPLIER_BPS / 10_000; run_close_tx( &mut ctx, &admin, &make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata), ) .await .expect("close_bet"); assert_eq!(read_token_balance(&mut ctx, &ata).await, before + payout); assert_eq!( read_token_balance(&mut ctx, &vault_pda()).await, VAULT_TOKEN_SUPPLY + amount - payout ); let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await); assert_eq!(b.status, 1); assert_eq!(b.exit_price, exit_price); } #[tokio::test] async fn close_bet_house_wins_down() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 1, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let exit_price = entry_price + 1_000; // side=DOWN but price rose -> lose let before = read_token_balance(&mut ctx, &ata).await; run_close_tx( &mut ctx, &admin, &make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata), ) .await .expect("close_bet"); assert_eq!(read_token_balance(&mut ctx, &ata).await, before); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 2); } #[tokio::test] async fn close_bet_equal_price_stays_open() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata, amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let before = read_token_balance(&mut ctx, &ata).await; let payout = amount * MULTIPLIER_BPS / 10_000; let equal_ix = make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey(), &ata); run_close_tx(&mut ctx, &admin, &equal_ix).await.expect("equal close"); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0); assert_eq!(read_token_balance(&mut ctx, &ata).await, before); let exit_price = entry_price + 1_000; let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata); run_close_tx(&mut ctx, &admin, &second_ix).await.expect("second close"); assert_eq!( read_token_balance(&mut ctx, &ata).await, before + payout ); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 1); } #[tokio::test] async fn close_bet_already_finalized() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let win_price = entry_price + 500; run_close_tx( &mut ctx, &admin, &make_close_ix(0, win_price, admin.pubkey(), bettor.pubkey(), &ata), ) .await .expect("first close"); let res = run_close_tx( &mut ctx, &admin, &make_close_ix(0, win_price + 1, admin.pubkey(), bettor.pubkey(), &ata), ) .await; assert!(res.is_err(), "expected close after finalization to be rejected"); let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await); assert_eq!(b.status, 1); assert_eq!(b.exit_price, win_price); } #[tokio::test] async fn close_bet_before_expiry_rejected() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey(), &ata); let res = run_close_tx(&mut ctx, &admin, &close_ix).await; assert!(res.is_err(), "expected premature close to be rejected"); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0); } #[tokio::test] async fn close_bet_wrong_relayer_rejected() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; let attacker = Keypair::new(); airdrop_to(&mut ctx, &attacker.pubkey(), 100_000_000_000).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let close_ix = make_close_ix(0, entry_price + 500, attacker.pubkey(), bettor.pubkey(), &ata); let res = run_close_tx(&mut ctx, &attacker, &close_ix).await; assert!(res.is_err(), "expected wrong relayer to be rejected"); assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0); } #[tokio::test] async fn close_bet_replay_rejected() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata, _amount, entry_price) = create_up_bet(&mut ctx, 0, &mint, &admin).await; set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await; let close_ix = make_close_ix(0, entry_price + 500, admin.pubkey(), bettor.pubkey(), &ata); run_close_tx(&mut ctx, &admin, &close_ix).await.expect("first close"); let before = read_token_balance(&mut ctx, &ata).await; let res = run_close_tx( &mut ctx, &admin, &make_close_ix(0, entry_price + 501, admin.pubkey(), bettor.pubkey(), &ata), ) .await; assert!(res.is_err(), "expected replay close to be rejected"); assert_eq!(read_token_balance(&mut ctx, &ata).await, before); } #[tokio::test] async fn create_bet_rejects_out_of_range() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; let (bettor, ata) = create_bettor(&mut ctx, &admin, &mint).await; set_clock(&mut ctx, START_TIME).await; let vault_before = read_token_balance(&mut ctx, &vault_pda()).await; let ix = make_create_ix(0, 0, 1, 100_000_000u64, bettor.pubkey(), &ata); let res = run_ix(&mut ctx, &bettor, ix).await; assert!(res.is_err(), "expected below-min bet to be rejected"); assert_eq!(read_token_balance(&mut ctx, &vault_pda()).await, vault_before); }