Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions finance/betting-market/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [Unreleased] - 2026-10-05

### Changed

- `settle_event` rounds the fee up: `fee = ceil(losing_pool * fee_bps / 10_000)`,
and `distributable_losing_pool` is the losing pool minus that fee, so the
rounding on the fee goes the program's way as the rounding on each payout
does. A losing pool that is an exact multiple of the fee rate pays the same
fee as before; any other pays one minor unit more. The Kani harness
`proof_settlement_fee_and_split` proves the fee is that ceiling.
- `test_double_claim_fails` asserts the error the second claim hits: Anchor's
own `AccountNotInitialized` (3012), since the first claim closed the Bet account.

### Added

- `test_only_admin_can_settle_or_cancel_event`: `settle_event` and
`cancel_event` from a non-admin both fail with `Unauthorized`, the event stays
`Open`, and the admin then settles it.

## [Unreleased] - 2026-10-04

### Added
Expand Down
22 changes: 14 additions & 8 deletions finance/betting-market/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ When an event settles to a winning outcome:

```
losing_pool = total_pool - winning_pool
fee = losing_pool * fee_bps / 10000 // charged only on the losing side
distributable_losing = losing_pool - fee
fee = ceil(losing_pool * fee_bps / 10000) // charged only on the losing side, rounded up
distributable_losing = losing_pool - fee // what the winners share
```

Each winning bet then claims:
Expand All @@ -81,13 +81,16 @@ Each winning bet then claims:
payout = stake + stake * distributable_losing / winning_pool
```

A winner always gets their own stake back; the fee is only ever taken from losing stakes. Integer
division floors each share, leaving at most a few minor units of dust in the vault, which
`close_event` pays to the fee recipient once every bet is closed.
A winner always gets their own stake back; the fee is only ever taken from losing stakes. Every
rounding goes the program's way: the fee is the ceiling of its fraction of the losing pool, and
each winner's share is floored, so the payouts and the fee together never exceed what the vault
holds. The flooring leaves at most a few minor units of dust in the vault, which `close_event` pays
to the fee recipient once every bet is closed.

**Example:** Outcome A pool 100, Outcome B pool 50, `fee_bps = 200` (2%). A wins.
`losing_pool = 50`, `fee = 1`, `distributable_losing = 49`. A bettor who staked 40 claims
`40 + 40 * 49 / 100 = 59`.
`40 + 40 * 49 / 100 = 59`. Had the losing pool been 55, the fee would be `ceil(1.1) = 2` and
`distributable_losing = 53`.

### Instruction handlers

Expand Down Expand Up @@ -164,8 +167,11 @@ anchor build
Tests are Rust integration tests running against
[LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) with
[solana-kite](https://crates.io/crates/solana-kite) helpers. They cover the full lifecycle (bet →
settle → claim with exact payout and fee assertions), admin authorization, the bet-after-settle and
double-claim guards, the outcome list locking when betting opens, the two-outcome minimum, both
settle → claim with exact payout and fee assertions), admin authorization of every admin handler
(`test_only_admin_can_settle_or_cancel_event` covers settling and cancelling), the bet-after-settle
guard, the double-claim guard (the first claim closes the Bet account, so Anchor refuses the second
while loading the accounts, and the test asserts that error), the outcome list locking when betting
opens, the two-outcome minimum, both
edges of the betting close time, settling an outcome with no bets, the cancel/refund path, the
`close_losing_bet` guards, and a wallet holding forty open bets at once, which shows there is no
per-wallet cap.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,11 @@ pub fn handle_settle_event(

// Winners always get their own stake back; the fee is only ever charged on
// the losing side, so a winner can never receive less than they staked.
let fee =
(losing_pool as u128 * context.accounts.event.fee_bps as u128 / BPS_DENOMINATOR) as u64;
// The fee rounds up, in the program's favor: fee = ceil(losing_pool *
// fee_bps / 10_000), and the winners share what the fee leaves. fee_bps
// never exceeds 10_000, so the fee never exceeds the losing pool.
let fee = (losing_pool as u128 * context.accounts.event.fee_bps as u128)
.div_ceil(BPS_DENOMINATOR) as u64;
let distributable_losing_pool = losing_pool - fee;

if fee > 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use {
},
AccountDeserialize, InstructionData, ToAccountMetas,
},
betting_market::{error::BettingError, Bet, Event},
betting_market::{error::BettingError, Bet, Event, EventStatus},
litesvm::LiteSVM,
solana_keypair::Keypair,
solana_kite::{
Expand Down Expand Up @@ -116,7 +116,20 @@ fn warp_to(svm: &mut LiteSVM, unix_timestamp: i64) {
// failed transaction reports the number as `Custom(n)`. Matching it proves the
// transaction failed for the rule under test, not for some unrelated reason.
fn assert_fails_with(result: Result<(), SolanaKiteError>, expected: BettingError) {
let code = 6000 + expected as u32;
assert_fails_with_code(result, 6000 + expected as u32);
}

// Anchor's own errors (account checks that fail before any handler runs) are
// numbered from the framework's `ErrorCode`, below 6000, and reported the same
// way.
fn assert_fails_with_anchor_error(
result: Result<(), SolanaKiteError>,
expected: anchor_lang::error::ErrorCode,
) {
assert_fails_with_code(result, expected as u32);
}

fn assert_fails_with_code(result: Result<(), SolanaKiteError>, code: u32) {
let error = format!("{:?}", result.expect_err("transaction should have failed"));
assert!(
error.contains(&format!("Custom({code})")),
Expand Down Expand Up @@ -773,6 +786,60 @@ fn test_only_admin_can_initialize_event() {
assert_fails_with(unauthorized_open, BettingError::Unauthorized);
}

// Ending a market is the admin's alone: a stranger can neither settle it to
// an outcome nor cancel it, and the event stays open for the admin to settle.
#[test]
fn test_only_admin_can_settle_or_cancel_event() {
let mut market = setup();
let event_id: u64 = 14;
let (alice, alice_ata) = create_bettor(&mut market, 1_000);
let (carol, carol_ata) = create_bettor(&mut market, 1_000);

init_config(&mut market);
let admin = market.admin.pubkey();
let mint = market.mint;
let fee_recipient = market.fee_recipient.pubkey();
let fee_recipient_ata = market.fee_recipient_ata;
open_yes_no_market(&mut market, event_id, "Will it rain on Sunday?");
place_bet(&mut market, &alice, &alice_ata, event_id, 0, 100);
place_bet(&mut market, &carol, &carol_ata, event_id, 1, 200);

let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap();
let unauthorized_cancel = send_transaction_from_instructions(
&mut market.svm,
vec![cancel_event_ix(mallory.pubkey(), event_id)],
&[&mallory],
&mallory.pubkey(),
);
assert_fails_with(unauthorized_cancel, BettingError::Unauthorized);

// Betting has closed, so the only thing stopping Mallory is the signer.
warp_to(&mut market.svm, BETTING_CLOSES_AT);
let unauthorized_settle = send_transaction_from_instructions(
&mut market.svm,
vec![settle_event_ix(
mallory.pubkey(),
mint,
fee_recipient,
fee_recipient_ata,
event_id,
0,
)],
&[&mallory],
&mallory.pubkey(),
);
assert_fails_with(unauthorized_settle, BettingError::Unauthorized);
assert!(read_event(&market, event_id).status == EventStatus::Open);
assert_eq!(
get_token_account_balance(&market.svm, &fee_recipient_ata).unwrap(),
0
);

// The admin settles the same event, so the refusals were the signer's.
settle(&mut market, event_id, 0);
assert!(read_event(&market, event_id).status == EventStatus::Settled);
}

#[test]
fn test_cannot_bet_after_settle() {
let mut market = setup();
Expand Down Expand Up @@ -940,7 +1007,11 @@ fn test_double_claim_fails() {
&[&alice],
&alice.pubkey(),
);
assert!(second.is_err(), "claiming the same bet twice must fail");
// The first claim closed the Bet account (`close = bettor`), so the second
// transaction passes an address with no lamports and no data. Anchor
// refuses it while loading the accounts, before the handler runs, with
// its own `AccountNotInitialized` (3012) rather than a `BettingError`.
assert_fails_with_anchor_error(second, anchor_lang::error::ErrorCode::AccountNotInitialized);
}

#[test]
Expand Down
19 changes: 19 additions & 0 deletions finance/betting-market/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [Unreleased] - 2026-10-05

### Changed

- `settle_event` rounds the fee up: `fee = ceil(losing_pool * fee_bps / 10_000)`,
and `distributable_losing_pool` is the losing pool minus that fee, so the
rounding on the fee goes the program's way as the rounding on each payout
does. A losing pool that is an exact multiple of the fee rate pays the same
fee as before; any other pays one minor unit more. The Kani harness
`proof_settlement_fee_and_split` proves the fee is that ceiling.
- `test_double_claim_fails` asserts the error the second claim hits: Anchor's
own account check, reported as the runtime's `UninitializedAccount`, since the first claim closed the Bet account.

### Added

- `test_only_admin_can_settle_or_cancel_event`: `settle_event` and
`cancel_event` from a non-admin both fail with `Unauthorized`, the event stays
`Open`, and the admin then settles it.

## [Unreleased] - 2026-10-04

### Added
Expand Down
22 changes: 14 additions & 8 deletions finance/betting-market/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ When an event settles to a winning outcome:

```
losing_pool = total_pool - winning_pool
fee = losing_pool * fee_bps / 10000 // charged only on the losing side
distributable_losing = losing_pool - fee
fee = ceil(losing_pool * fee_bps / 10000) // charged only on the losing side, rounded up
distributable_losing = losing_pool - fee // what the winners share
```

Each winning bet then claims:
Expand All @@ -81,13 +81,16 @@ Each winning bet then claims:
payout = stake + stake * distributable_losing / winning_pool
```

A winner always gets their own stake back; the fee is only ever taken from losing stakes. Integer
division floors each share, leaving at most a few minor units of dust in the vault, which
`close_event` pays to the fee recipient once every bet is closed.
A winner always gets their own stake back; the fee is only ever taken from losing stakes. Every
rounding goes the program's way: the fee is the ceiling of its fraction of the losing pool, and
each winner's share is floored, so the payouts and the fee together never exceed what the vault
holds. The flooring leaves at most a few minor units of dust in the vault, which `close_event` pays
to the fee recipient once every bet is closed.

**Example:** Outcome A pool 100, Outcome B pool 50, `fee_bps = 200` (2%). A wins.
`losing_pool = 50`, `fee = 1`, `distributable_losing = 49`. A bettor who staked 40 claims
`40 + 40 * 49 / 100 = 59`.
`40 + 40 * 49 / 100 = 59`. Had the losing pool been 55, the fee would be `ceil(1.1) = 2` and
`distributable_losing = 53`.

### Instruction handlers

Expand Down Expand Up @@ -164,8 +167,11 @@ anchor build
Tests are Rust integration tests running against
[LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) with
[solana-kite](https://crates.io/crates/solana-kite) helpers. They cover the full lifecycle (bet →
settle → claim with exact payout and fee assertions), admin authorization, the bet-after-settle and
double-claim guards, the outcome list locking when betting opens, the two-outcome minimum, both
settle → claim with exact payout and fee assertions), admin authorization of every admin handler
(`test_only_admin_can_settle_or_cancel_event` covers settling and cancelling), the bet-after-settle
guard, the double-claim guard (the first claim closes the Bet account, so Anchor refuses the second
while loading the accounts, and the test asserts that error), the outcome list locking when betting
opens, the two-outcome minimum, both
edges of the betting close time, settling an outcome with no bets, the cancel/refund path, the
`close_losing_bet` guards, and a wallet holding forty open bets at once, which shows there is no
per-wallet cap.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,11 @@ pub fn handle_settle_event(

// Winners always get their own stake back; the fee is only ever charged on
// the losing side, so a winner can never receive less than they staked.
let fee =
(losing_pool as u128 * context.accounts.event.fee_bps as u128 / BPS_DENOMINATOR) as u64;
// The fee rounds up, in the program's favor: fee = ceil(losing_pool *
// fee_bps / 10_000), and the winners share what the fee leaves. fee_bps
// never exceeds 10_000, so the fee never exceeds the losing pool.
let fee = (losing_pool as u128 * context.accounts.event.fee_bps as u128)
.div_ceil(BPS_DENOMINATOR) as u64;
let distributable_losing_pool = losing_pool - fee;

if fee > 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ use {
anchor_v2_testing::{Keypair, LiteSVM, Signer},
// `Event` through `state`: the prelude's `Event` trait shadows the glob
// re-export at the crate root.
betting_market::{error::BettingError, state::Event, Bet},
betting_market::{
error::BettingError,
state::{Event, EventStatus},
Bet,
},
// LiteSVM's get_sysvar / set_sysvar want the host-side Clock.
solana_clock::Clock,
solana_kite::{
Expand Down Expand Up @@ -117,10 +121,16 @@ fn warp_to(svm: &mut LiteSVM, unix_timestamp: i64) {
// transaction failed for the rule under test, not for some unrelated reason.
fn assert_fails_with(result: Result<(), SolanaKiteError>, expected: BettingError) {
let code = 6000 + expected as u32;
assert_fails_with_text(result, &format!("Custom({code})"));
}

// Anchor 2 reports a failed account check as a plain `ProgramError` rather
// than a numbered code, so it is matched by its Debug text.
fn assert_fails_with_text(result: Result<(), SolanaKiteError>, expected: &str) {
let error = format!("{:?}", result.expect_err("transaction should have failed"));
assert!(
error.contains(&format!("Custom({code})")),
"expected error {code}, got: {error}"
error.contains(expected),
"expected error {expected}, got: {error}"
);
}

Expand Down Expand Up @@ -773,6 +783,60 @@ fn test_only_admin_can_initialize_event() {
assert_fails_with(unauthorized_open, BettingError::Unauthorized);
}

// Ending a market is the admin's alone: a stranger can neither settle it to
// an outcome nor cancel it, and the event stays open for the admin to settle.
#[test]
fn test_only_admin_can_settle_or_cancel_event() {
let mut market = setup();
let event_id: u64 = 14;
let (alice, alice_ata) = create_bettor(&mut market, 1_000);
let (carol, carol_ata) = create_bettor(&mut market, 1_000);

init_config(&mut market);
let admin = market.admin.pubkey();
let mint = market.mint;
let fee_recipient = market.fee_recipient.pubkey();
let fee_recipient_ata = market.fee_recipient_ata;
open_yes_no_market(&mut market, event_id, "Will it rain on Sunday?");
place_bet(&mut market, &alice, &alice_ata, event_id, 0, 100);
place_bet(&mut market, &carol, &carol_ata, event_id, 1, 200);

let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap();
let unauthorized_cancel = send_transaction_from_instructions(
&mut market.svm,
vec![cancel_event_ix(mallory.pubkey(), event_id)],
&[&mallory],
&mallory.pubkey(),
);
assert_fails_with(unauthorized_cancel, BettingError::Unauthorized);

// Betting has closed, so the only thing stopping Mallory is the signer.
warp_to(&mut market.svm, BETTING_CLOSES_AT);
let unauthorized_settle = send_transaction_from_instructions(
&mut market.svm,
vec![settle_event_ix(
mallory.pubkey(),
mint,
fee_recipient,
fee_recipient_ata,
event_id,
0,
)],
&[&mallory],
&mallory.pubkey(),
);
assert_fails_with(unauthorized_settle, BettingError::Unauthorized);
assert!(read_event(&market, event_id).status == EventStatus::Open);
assert_eq!(
get_token_account_balance(&market.svm, &fee_recipient_ata).unwrap(),
0
);

// The admin settles the same event, so the refusals were the signer's.
settle(&mut market, event_id, 0);
assert!(read_event(&market, event_id).status == EventStatus::Settled);
}

#[test]
fn test_cannot_bet_after_settle() {
let mut market = setup();
Expand Down Expand Up @@ -940,7 +1004,12 @@ fn test_double_claim_fails() {
&[&alice],
&alice.pubkey(),
);
assert!(second.is_err(), "claiming the same bet twice must fail");
// The first claim closed the Bet account (`close = bettor`), so the second
// transaction passes an address with no lamports and no data. Anchor
// refuses it while loading the accounts, before the handler runs: an
// address with no lamports that the system program owns is reported as
// the runtime's `UninitializedAccount` rather than a `BettingError`.
assert_fails_with_text(second, "UninitializedAccount");
}

#[test]
Expand Down
Loading
Loading