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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)");
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user