diff --git a/program/programs/smart-updown/src/constants.rs b/program/programs/smart-updown/src/constants.rs index 9b0343a..82ecefd 100644 --- a/program/programs/smart-updown/src/constants.rs +++ b/program/programs/smart-updown/src/constants.rs @@ -17,5 +17,8 @@ pub const SIDE_DOWN: u64 = 1; pub const STATUS_OPEN: u64 = 0; pub const STATUS_PAYOUT_DONE: u64 = 1; pub const STATUS_HOUSE_WON: u64 = 2; +/// Возврат залога при ничьей (`exit_price == entry_price`). +/// Добавлен позже остальных — 0/1/2 уже зашиты в шлюз и в живые аккаунты цепи. +pub const STATUS_REFUNDED: u64 = 3; pub const BPS_DIVISOR: u64 = 10_000; diff --git a/program/programs/smart-updown/src/instructions/close_bet.rs b/program/programs/smart-updown/src/instructions/close_bet.rs index 30bdba7..f8cc327 100644 --- a/program/programs/smart-updown/src/instructions/close_bet.rs +++ b/program/programs/smart-updown/src/instructions/close_bet.rs @@ -4,7 +4,7 @@ use anchor_spl::token::{Token, TokenAccount}; use crate::{ constants::{ BPS_DIVISOR, BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_HOUSE_WON, - STATUS_PAYOUT_DONE, STATUS_OPEN, VAULT_SEED, + STATUS_PAYOUT_DONE, STATUS_OPEN, STATUS_REFUNDED, VAULT_SEED, }, error::SmartUpdownError, events::BetClosed, @@ -64,6 +64,40 @@ pub fn handler(ctx: &mut Context, id: u64, exit_price: u64) -> Result< let amount = ctx.accounts.bet.amount; if exit_price == entry { + require!( + ctx.accounts.vault_token.amount >= amount, + SmartUpdownError::InsufficientVault + ); + { + // Возврат залога: переводим amount (БЕЗ множителя) из ваулта беттору. + // Подпись — сидами ваулт-PDA (как в ветке выигрыша ниже). + let cpi_accounts = anchor_spl::token::Transfer { + from: ctx.accounts.vault_token.to_account_info(), + to: ctx.accounts.bettor_token.to_account_info(), + authority: ctx.accounts.vault_authority.to_account_info(), + }; + let signer_seeds: &[&[u8]] = &[ + GLOBAL_SEED, + VAULT_SEED, + &[ctx.bumps.vault_authority], + ]; + let vault_signer_seeds = [signer_seeds]; + let cpi_ctx = CpiContext::new_with_signer( + ctx.accounts.token_program.to_account_info(), + cpi_accounts, + &vault_signer_seeds, + ); + anchor_spl::token::transfer(cpi_ctx, amount)?; + } + ctx.accounts.bet.exit_price = exit_price; + ctx.accounts.bet.status = STATUS_REFUNDED; + emit!(BetClosed { + bet: ctx.accounts.bet.key(), + exit_price, + user_won: false, + payout: amount, + status: STATUS_REFUNDED, + }); return Ok(()); } diff --git a/program/programs/smart-updown/tests/test_smart_updown.rs b/program/programs/smart-updown/tests/test_smart_updown.rs index 89a434f..bb1af5f 100644 --- a/program/programs/smart-updown/tests/test_smart_updown.rs +++ b/program/programs/smart-updown/tests/test_smart_updown.rs @@ -441,7 +441,7 @@ async fn close_bet_house_wins_down() { } #[tokio::test] -async fn close_bet_equal_price_stays_open() { +async fn close_bet_equal_price_refunds() { let (mut ctx, admin) = setup().await; initialize(&mut ctx, &admin).await; let mint = create_token_env(&mut ctx, &admin).await; @@ -449,21 +449,35 @@ async fn close_bet_equal_price_stays_open() { 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); + run_close_tx( + &mut ctx, + &admin, + &make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey(), &ata), + ) + .await + .expect("equal close"); - 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"); + let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await); + assert_eq!(b.status, 3, "expected status REFUNDED=3"); + assert_eq!(b.exit_price, entry_price, "exit_price must be recorded as entry_price"); + assert_eq!(read_token_balance(&mut ctx, &ata).await, before + amount, "bettor +amount"); assert_eq!( - read_token_balance(&mut ctx, &ata).await, - before + payout + read_token_balance(&mut ctx, &vault_pda()).await, + VAULT_TOKEN_SUPPLY, + "vault -amount" ); - assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 1); + + // Повторный close по той же ставке → ошибка BetAlreadyFinalized. + // exit_price чуть другой, иначе banks дедуплицирует транзакцию + // (тот же signer + тот же blockhash + те же данные икса → Ok без перезапуска). + let res = run_close_tx( + &mut ctx, + &admin, + &make_close_ix(0, entry_price + 1, admin.pubkey(), bettor.pubkey(), &ata), + ) + .await; + assert!(res.is_err(), "expected second close after refund to be rejected"); } #[tokio::test] diff --git a/relayer/helm/values.yaml b/relayer/helm/values.yaml index 1638315..f9c58e5 100644 --- a/relayer/helm/values.yaml +++ b/relayer/helm/values.yaml @@ -17,7 +17,7 @@ ingress: smart: rpcUrl: 'http://192.168.76.181:8899' programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf' - tokenMint: '8dpXNUgWN4m8ohhi6dkVqw4XezbDF7oKDSgnbf2VKjQJ' + tokenMint: '6fxySAjTQTkzyyZ7u8EQqv3SyJtv2Ydr6hphquHbkGS9' adminKeypair: '/app/secrets/admin-keypair.json' mode: test allowAirdrop: true diff --git a/relayer/src/check.ts b/relayer/src/check.ts index c9db4dc..a2353f3 100644 --- a/relayer/src/check.ts +++ b/relayer/src/check.ts @@ -33,6 +33,7 @@ import { STATUS_OPEN, STATUS_PAYOUT_DONE, STATUS_HOUSE_WON, + STATUS_REFUNDED, } from "./settle"; const ok = (msg: string) => console.log(`ok: ${msg}`); @@ -421,6 +422,15 @@ async function main(): Promise { check(ev.statusName === "house_won", 'betClosedEvent house_won: statusName==="house_won"'); } + // Тест: betClosedEvent refunded. + { + const bettor = Keypair.generate().publicKey; + const bet = decodeBet(makeBetBuffer({ status: STATUS_REFUNDED, bettor })); + const ev = betClosedEvent(bet, 21, 10_112_000_000, STATUS_REFUNDED) as Record; + check(ev.status === 3, "betClosedEvent refunded: status (число 3)"); + check(ev.statusName === "refunded", 'betClosedEvent refunded: statusName==="refunded"'); + } + // Санити-проверка: STATUS_OPEN == 0 для совместимости с on-chain контрактом. check(STATUS_OPEN === 0, "settle: STATUS_OPEN === 0 (on-chain OPEN)"); diff --git a/relayer/src/settle.ts b/relayer/src/settle.ts index 3c772eb..6d2f419 100644 --- a/relayer/src/settle.ts +++ b/relayer/src/settle.ts @@ -43,6 +43,7 @@ const SENTINEL_UNMAPPED_LOGGED = -1; export const STATUS_OPEN = 0; export const STATUS_PAYOUT_DONE = 1; export const STATUS_HOUSE_WON = 2; +export const STATUS_REFUNDED = 3; export const SIDE_UP = 0; export const SIDE_DOWN = 1; @@ -173,6 +174,7 @@ export function betOpenEvent(bet: DecodedBet, id: number): Record