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_OPEN: u64 = 0;
|
||||||
pub const STATUS_PAYOUT_DONE: u64 = 1;
|
pub const STATUS_PAYOUT_DONE: u64 = 1;
|
||||||
pub const STATUS_HOUSE_WON: u64 = 2;
|
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;
|
pub const BPS_DIVISOR: u64 = 10_000;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use anchor_spl::token::{Token, TokenAccount};
|
|||||||
use crate::{
|
use crate::{
|
||||||
constants::{
|
constants::{
|
||||||
BPS_DIVISOR, BET_SEED, GLOBAL_SEED, SIDE_DOWN, SIDE_UP, STATUS_HOUSE_WON,
|
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,
|
error::SmartUpdownError,
|
||||||
events::BetClosed,
|
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;
|
let amount = ctx.accounts.bet.amount;
|
||||||
|
|
||||||
if exit_price == entry {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ async fn close_bet_house_wins_down() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn close_bet_equal_price_stays_open() {
|
async fn close_bet_equal_price_refunds() {
|
||||||
let (mut ctx, admin) = setup().await;
|
let (mut ctx, admin) = setup().await;
|
||||||
initialize(&mut ctx, &admin).await;
|
initialize(&mut ctx, &admin).await;
|
||||||
let mint = create_token_env(&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;
|
set_clock(&mut ctx, START_TIME + EXPIRY_SECONDS + 1).await;
|
||||||
let before = read_token_balance(&mut ctx, &ata).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(
|
||||||
run_close_tx(&mut ctx, &admin, &equal_ix).await.expect("equal close");
|
&mut ctx,
|
||||||
assert_eq!(read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await).status, 0);
|
&admin,
|
||||||
assert_eq!(read_token_balance(&mut ctx, &ata).await, before);
|
&make_close_ix(0, entry_price, admin.pubkey(), bettor.pubkey(), &ata),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("equal close");
|
||||||
|
|
||||||
let exit_price = entry_price + 1_000;
|
let b = read_bet_typed(&read_account_data(&ctx.banks_client, &bet_pda(0)).await);
|
||||||
let second_ix = make_close_ix(0, exit_price, admin.pubkey(), bettor.pubkey(), &ata);
|
assert_eq!(b.status, 3, "expected status REFUNDED=3");
|
||||||
run_close_tx(&mut ctx, &admin, &second_ix).await.expect("second close");
|
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!(
|
assert_eq!(
|
||||||
read_token_balance(&mut ctx, &ata).await,
|
read_token_balance(&mut ctx, &vault_pda()).await,
|
||||||
before + payout
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ ingress:
|
|||||||
smart:
|
smart:
|
||||||
rpcUrl: 'http://192.168.76.181:8899'
|
rpcUrl: 'http://192.168.76.181:8899'
|
||||||
programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf'
|
programId: '9ALsnxXNzDBv3mokngCHbruRTWUZiWR7pf7vswS1fCpf'
|
||||||
tokenMint: '8dpXNUgWN4m8ohhi6dkVqw4XezbDF7oKDSgnbf2VKjQJ'
|
tokenMint: '6fxySAjTQTkzyyZ7u8EQqv3SyJtv2Ydr6hphquHbkGS9'
|
||||||
adminKeypair: '/app/secrets/admin-keypair.json'
|
adminKeypair: '/app/secrets/admin-keypair.json'
|
||||||
mode: test
|
mode: test
|
||||||
allowAirdrop: true
|
allowAirdrop: true
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
STATUS_OPEN,
|
STATUS_OPEN,
|
||||||
STATUS_PAYOUT_DONE,
|
STATUS_PAYOUT_DONE,
|
||||||
STATUS_HOUSE_WON,
|
STATUS_HOUSE_WON,
|
||||||
|
STATUS_REFUNDED,
|
||||||
} from "./settle";
|
} from "./settle";
|
||||||
|
|
||||||
const ok = (msg: string) => console.log(`ok: ${msg}`);
|
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"');
|
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 контрактом.
|
// Санити-проверка: STATUS_OPEN == 0 для совместимости с on-chain контрактом.
|
||||||
check(STATUS_OPEN === 0, "settle: STATUS_OPEN === 0 (on-chain OPEN)");
|
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_OPEN = 0;
|
||||||
export const STATUS_PAYOUT_DONE = 1;
|
export const STATUS_PAYOUT_DONE = 1;
|
||||||
export const STATUS_HOUSE_WON = 2;
|
export const STATUS_HOUSE_WON = 2;
|
||||||
|
export const STATUS_REFUNDED = 3;
|
||||||
|
|
||||||
export const SIDE_UP = 0;
|
export const SIDE_UP = 0;
|
||||||
export const SIDE_DOWN = 1;
|
export const SIDE_DOWN = 1;
|
||||||
@@ -173,6 +174,7 @@ export function betOpenEvent(bet: DecodedBet, id: number): Record<string, unknow
|
|||||||
function statusName(status: number): string {
|
function statusName(status: number): string {
|
||||||
if (status === STATUS_PAYOUT_DONE) return "payout_done";
|
if (status === STATUS_PAYOUT_DONE) return "payout_done";
|
||||||
if (status === STATUS_HOUSE_WON) return "house_won";
|
if (status === STATUS_HOUSE_WON) return "house_won";
|
||||||
|
if (status === STATUS_REFUNDED) return "refunded";
|
||||||
return "open";
|
return "open";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,7 +360,11 @@ export async function settleOnce(ctx: SchedulerCtx): Promise<{
|
|||||||
}
|
}
|
||||||
continue;
|
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++;
|
stats.settled++;
|
||||||
if (lastState) lastState.delete(item.id);
|
if (lastState) lastState.delete(item.id);
|
||||||
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));
|
ctx.broadcast(betClosedEvent(after, item.id, exitPrice, after.status));
|
||||||
|
|||||||
Reference in New Issue
Block a user