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
+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));