feat(contract): возврат залога при ничьей (exit_price == entry_price)

Раньше при равенстве цен close_bet делал `return Ok(())` — ставка навсегда
оставалась в статусе open, а залог запирался в ваулте (замер: 33.6% окон).
Из-за правила «одна открытая ставка на адрес» игрок получал 409 пожизненно.

Теперь ничья возвращает залог целиком (без множителя) и финализирует ставку:
- constants.rs: STATUS_REFUNDED = 3 (0/1/2 не тронуты — зашиты в шлюз и в цепи)
- close_bet.rs: CPI-перевод amount из ваулта беттору под сидами ваулт-PDA,
  запись exit_price/status, событие BetClosed(status=3, payout=amount)
- settle.ts: STATUS_REFUNDED, statusName "refunded", статус 3 в условии финализации
- check.ts: проверка формы события refunded
- тест close_bet_equal_price_stays_open → close_bet_equal_price_refunds
  (+ повторное закрытие отклоняется)

Проверено: cargo test-sbf 10/10, npm run typecheck EXIT=0, две мутации
(снятие статуса, снятие перевода) валят именно новый тест.
Задеплоено: программа 9ALs… (хэш из цепи совпал побайтово), шлюз rev 15.
This commit is contained in:
Caffeine
2026-09-14 02:09:08 +03:00
parent a73e44de9a
commit 42dfa58582
6 changed files with 82 additions and 15 deletions
@@ -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;
@@ -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<CloseBet>, 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(());
}
@@ -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]
+1 -1
View File
@@ -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
+10
View File
@@ -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<void> {
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<string, unknown>;
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)");
+7 -1
View File
@@ -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<string, unknow
function statusName(status: number): string {
if (status === STATUS_PAYOUT_DONE) return "payout_done";
if (status === STATUS_HOUSE_WON) return "house_won";
if (status === STATUS_REFUNDED) return "refunded";
return "open";
}
@@ -358,7 +360,11 @@ export async function settleOnce(ctx: SchedulerCtx): Promise<{
}
continue;
}
if (after.status === STATUS_PAYOUT_DONE || after.status === STATUS_HOUSE_WON) {
if (
after.status === STATUS_PAYOUT_DONE ||
after.status === STATUS_HOUSE_WON ||
after.status === STATUS_REFUNDED
) {
stats.settled++;
if (lastState) lastState.delete(item.id);
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));