How to Build a Real-Time Binary Prediction Market on Solana
Guides & Tutorials

How to Build a Real-Time Binary Prediction Market on Solana

MagicBlock
MagicBlock
@ magicblock

Prediction markets have found product-market fit.

The version builders are now racing toward (the "will SOL be higher eight seconds from now?" bet) is less a market and more a game, and games are latency products. If a round lasts eight seconds, you can't spend two of them waiting for a confirmation, and you can't ask for a wallet signature on every click.

On the Solana base layer that product fights physics: ~400 ms blocks, a fee per transaction, and a signature prompt for every bet. Ephemeral Rollups remove all three. They give you 10ms block times and gasless transactions on real Solana accounts (delegated, not bridged), so your program, your SPL tokens, and your oracle feed behave the same way they do on mainnet, only faster.

To show how Ephemeral Rollups enable this kind of real-time market, we built binary-prediction, an open-source program that runs the whole loop inside an ER: live price in, bet open, bet settled, tokens out.

Follow along
Clone the example repository and open the Anchor project:

git clone https://github.com/magicblock-labs/magicblock-engine-examples
cd magicblock-engine-examples/binary-prediction/anchor


The Flow

The demo is a three-step panel next to a live SOL/USD chart. The chart is a WebSocket subscription to the same oracle account the program reads onchain.

  1. Initialize creates the market on the base layer. One transaction creates the Pool PDA holding the config (price feed, round duration, minimum stake, payout multiplier), seeds it with 10,000 tokens of house liquidity, deposits that liquidity into an Ephemeral token vault, and delegates custody to the ER.
  2. Predict is where the ER earns its keep. Pick Up or Down, choose a stake, and place_bet executes on the ER: it reads the live oracle price, snapshots it as your opening price, pulls the stake into the pool, and starts an 8-second countdown.
  3. Settle fires after expiry. Anyone can crank it (the instruction is permissionless), and the demo auto-settles the moment the round ends. A correct call pays 1.9× the stake, a tie refunds it, a wrong call donates it to the pool.

Watch the activity log on the right. Every ticket and every settlement lands on the ER in under 100 ms:


A Price Feed That Lives Inside the ER

Oracles are usually the first casualty when you move off the base layer: your fast execution environment is useless if prices only update where you aren't. MagicBlock's Ephemeral Oracle streams Pyth Pro prices directly into the ER as standard PriceUpdateV2 accounts, with the same layout the Pyth receiver uses on mainnet. 

The program-side code reads like any other Pyth integration:

// programs/binary-prediction/src/utils.rs
/// Reads a fresh price from the Pyth receiver account.
/// This example rejects stale prices with a fixed max age.
pub(crate) fn read_price(
    price_update_account: &UncheckedAccount,
    feed_id: &[u8; 32],
) -> Result<i64> {
    let price_update_info = price_update_account.to_account_info();
    let data_ref = price_update_info.data.borrow();
    let price_update = PriceUpdateV2::try_deserialize_unchecked(&mut data_ref.as_ref())
        .map_err(Into::<Error>::into)?;
    let price = price_update
        .get_price_no_older_than(&Clock::get()?, MAX_PRICE_AGE_SECONDS, feed_id)?;
    Ok(price.price)
}


The program reads the price from an account rather than taking it as an instruction argument, so the client can't smuggle in a convenient number. “initialize” pins the feed's pubkey in the pool config, and both place_bet and settle verify price_update.key() == pool.price_feed before deserializing. Opening and closing prices come from the same feed, and the outcome is the sign of the difference.


Real Tokens, Ephemeral Speed

The stakes are SPL tokens. The Ephemeral SPL token program lets them move at ER speed: you deposit tokens into a per-mint vault on the base layer, delegate the resulting Ephemeral balance to the ER, and it serves that balance at your canonical associated token account through a byte-compatible token program, so a plain anchor_spl::token::transfer moves real value.

The pool's side of this happens once, inside initialize: the program CPIs the Ephemeral token program to create the vault, deposit the seed liquidity, and delegate custody.

The user's side is a single SDK call:

// app/src/lib/binaryPrediction.ts: move the user's tokens into ER custody
const delegateUserIxs = await delegateSpl(user.publicKey, mint, BigInt(amount), {
  validator: VALIDATOR,
  payer: admin.publicKey,
});
await sendSignedTransaction(
  base.connection,
  new Transaction().add(...delegateUserIxs),
  admin,
  [user],
);


The Pool PDA owns the pool token account, so payouts are program-signed transfers; no admin key can touch the money:

// programs/binary-prediction/src/utils.rs
/// Transfers tokens using the Pool PDA as SPL token authority.
pub(crate) fn pool_signed_transfer<'info>(
    from: AccountInfo<'info>,
    to: AccountInfo<'info>,
    pool: AccountInfo<'info>,
    token_program: AccountInfo<'info>,
    amount: u64,
    pool_mint: Pubkey,
    pool_bump: u8,
) -> Result<()> {
    let pool_bump_seed = [pool_bump];
    let signer_seeds: &[&[&[u8]]] = &[&[POOL_SEED, pool_mint.as_ref(), &pool_bump_seed]];
    let cpi_accounts = SplTransfer { from, to, authority: pool };
    let cpi_ctx = CpiContext::new_with_signer(token_program.key(), cpi_accounts, signer_seeds);
    token::transfer(cpi_ctx, amount)?;
    Ok(())
}


Betting Without Popups: Session Keys

An eight-second game with a wallet popup per bet is dead on arrival.

Session keys fix this: the user signs once to mint a scoped, expiring session token, and a throwaway keypair signs every bet after that.

Onchain, place_bet accepts either the user or a valid session signer:

// programs/binary-prediction/src/lib.rs
#[session_auth_or(
    ctx.accounts.user.key() == ctx.accounts.payer.key(),
    SessionError::InvalidToken
)]
pub fn place_bet(ctx: Context<PlaceBet>, direction: Direction, stake: u64) -> Result<()> {
    // ...
    if ctx.accounts.payer.key() != ctx.accounts.user.key() {
        require_token_delegate(&ctx.accounts.user_token_account, ctx.accounts.payer.key(), stake)?;
    }

    let open_price = read_price(&ctx.accounts.price_update, &ctx.accounts.pool.price_feed_id)?;

    // Solvency is checked against the balance *after* the stake lands, since
    // the stake is transferred into the pool below and backs the payout.
    let required_payout = checked_payout(stake, ctx.accounts.pool.payout_bps)?;
    let available_after_stake = ctx.accounts.pool_token_account.amount
        .checked_add(stake)
        .ok_or(ErrorCode::MathOverflow)?;
    require!(available_after_stake >= required_payout, ErrorCode::InsufficientLiquidity);

    signer_transfer(/* user ATA -> pool ATA, signed by the payer */)?;

    let bet = &mut ctx.accounts.bet;
    bet.open_price = open_price;
    bet.expiry_ts = Clock::get()?.unix_timestamp + ctx.accounts.pool.bet_duration_seconds;
    bet.direction = direction;
    bet.stake = stake;
    bet.is_open = true;
    Ok(())
}


The SPL Token program doesn't understand session tokens. A session token authorizes your program's instruction, but it can't authorize the token transfer inside it. The fix is a one-time SPL approve that makes the session key a token delegate over the user's ER balance, which the program verifies with require_token_delegate.

After that, the whole loop is popup-free:

// tests/binary-prediction.ts
await sessionManager.program.methods
  .createSessionV2(true, new BN(nowTs + 3600), new BN(0.005 * LAMPORTS_PER_SOL))
  .accounts({
    targetProgram: program.programId,
    sessionSigner: sessionKeypair.publicKey,
    feePayer: admin.publicKey,
    authority: user.publicKey,
  })
  .rpc();

// One-time SPL approve so the session key can move the stake.
createApproveInstruction(userAta, sessionKeypair.publicKey, user.publicKey, STAKE);

// Every bet after this is signed by the session key alone.
await erProgram.methods
  .placeBet({ down: {} }, STAKE)
  .accountsPartial({
    payer: sessionKeypair.publicKey,
    user: user.publicKey,
    sessionToken: sessionTokenPda,
    // ...
  })
  .signers([sessionKeypair])
  .rpc();


Settlement

place_bet already enforced solvency, so settle is short: read the feed again and pay out by the sign of the move.

// programs/binary-prediction/src/lib.rs
pub fn settle(ctx: Context<Settle>) -> Result<()> {
    require!(ctx.accounts.bet.is_open, ErrorCode::BetNotOpen);
    let now = Clock::get()?.unix_timestamp;
    require!(now >= ctx.accounts.bet.expiry_ts, ErrorCode::BetNotExpired);

    let settle_price = read_price(&ctx.accounts.price_update, &ctx.accounts.pool.price_feed_id)?;
    let win_payout = checked_payout(ctx.accounts.bet.stake, ctx.accounts.pool.payout_bps)?;
    let payout = if settle_price == ctx.accounts.bet.open_price {
        ctx.accounts.bet.stake
    } else if ctx.accounts.bet.direction == outcome(settle_price, ctx.accounts.bet.open_price)? {
        win_payout
    } else {
        0
    };

    if payout > 0 {
        pool_signed_transfer(/* pool ATA -> user ATA, signed by the Pool PDA */)?;
    }

    // Reset the Bet so the same PDA can be used again.
    // ...
    Ok(())
}


There is no authority check in that function. Settlement is permissionless: anyone can crank an expired bet, and the demo client does it automatically once the ER clock passes expiry. The house edge is the payout_bps setting: at 19,000 bps a winning coin-flip pays 1.9× instead of a fair 2×, so the pool earns its spread in expectation without touching the odds.


Cashing Out

When the user is done, one instruction commits their ER token balance back to the base layer through the delegation program, and withdrawSpl pulls it out of the vault into their normal ATA:

// tests/binary-prediction.ts: exit the ER
await erConnection.sendTransaction(
  new Transaction().add(undelegateIx(user.publicKey, mint)),
);
const withdrawIxs = await withdrawSpl(user.publicKey, mint, erUserBalance);
await baseConnection.sendTransaction(new Transaction().add(...withdrawIxs));


The lifecycle test asserts the exact base-layer balances after withdrawal, so every token that entered the Ephemeral Rollup is accounted for on the way out.


Do It Yourself

The example is in the magicblock-engine-examples repo:

git clone https://github.com/magicblock-labs/magicblock-engine-examples
cd magicblock-engine-examples/binary-prediction/anchor
yarn && yarn build

# terminal 1: local base validator + ER + query filtering service
yarn setup

# terminal 2: full lifecycle test (initialize -> bet -> settle -> session bet -> withdraw)
yarn test:local


And the demo UI runs against MagicBlock devnet out of the box, live Pyth Pro SOL/USD feed included:

cd app && yarn && yarn dev


Why It Matters

The value of the example is composition. A real oracle, real SPL tokens, session-key UX, and delegated program state add up to the stack a real-time consumer finance app needs, in about 500 lines of Anchor.

The same skeleton stretches past coin-flips: live trading competitions, in-game economies with real token sinks, and anything else where the product depends on reacting faster than a block time. The example keeps risk simple (the pool is an unhedged counterparty with a fixed multiplier), and a production risk engine would slot in behind the same custody and settlement plumbing.

Resources