How it works
Scoring
One fill of stake S (scaled by USDC decimals (10e6) in the code), decimal odds quoted to the user o (scaled by 10,000 in the code), closing probability pc of the side you laid (in millionths in the code):
clv = S * (1 - pc * o)
You score positively when the closing probability is higher than the odds you offered bets at. A quote of 2.0 that ends up closing at 40% is a positive CLV for you (negative for your counterparty). Just like real life, there is no way to know how the market will close, so you need to develop a strategy that tries to always fill at +EV odds. The close is the hidden probability when the event ends. The result of the events only matter for collateral management, not your score - the objective is to quote lots of +EV volume, not to pick a winner. Your overall score is the sum of CLV across the sims.
The simulation
Each simulation is roughly structured like a weekend of sports. One market closes early like a Friday night game, a block of markets closes together later like a Saturday afternoon block, and the rest close scattered after that like Sunday games. Each market opens a random stretch before its own close, and those times move a little from seed to seed. A market takes no bets until it opens. Ingest includes every market's open tick and close tick from the first tick, so you can see what is still listed and when it starts.
The hidden probability starts between 15% and 85% and usually moves only a few percent while the market is open. About one market in five also gets a single jump of roughly 7 to 12 points to represent breaking team news (e.g. QB out injured). That jump is not telegraphed in the feed. It lands in the public price a few ticks later. The news bettors who know about it bet immediately.
Everyone else is betting the print they can see. Line shoppers only take a price close to it. The others will pay a few percent worse. Each bettor type uses several wallets, so blocking one address does not remove that bettor type. Counts and stake ranges are in ruleset.
You are not the only market maker in the simulation. Baseline smooths the public prints and shades for noise, staleness, and inventory. Eager quotes the last print with a thin margin, in small size. Patient quotes it with a wide margin, in large size. Best odds are filled first, up to 5 quotes per bet. Equal odds are picked at random, per bet, so being first in the list is not an advantage. A fill that fails for lack of funds does not use up the stake. The rest is offered to the next book.
Start from the sample
programs/starter is a basic market maker that stores the latest public price and quotes it back with a flat 3% margin, capped at 100 USDC. No bankroll management, no sizing, no view of who is betting.
You need Rust 1.97 and cargo build-sbf from the Agave CLI.
cargo build-sbf --arch v3 --manifest-path programs/starter/Cargo.toml
cargo run -p spamm-challenge --release -- validate --so target/deploy/spamm_challenge_starter.so
cargo run -p spamm-challenge --release -- run --so target/deploy/spamm_challenge_starter.so --sims 5 --steps 800
The harness creates every account before the sim starts. Do not write an init instruction. ruleset prints the live event count, bankroll, bettor sizes, and the other books. A local run can use seeded randomness so you can improve your strategy without changes between runs. The leaderboard is scored using the same secret seeds for everyone.
What you have to implement
The harness loads your .so at GEeVgwhMcPc45CUvE1qbFnC2GurrevgboyDuV5TG5MGC. Each event is one two-outcome market. There are no parlays. Three instructions:
| Disc | Name | Purpose | You return |
|---|---|---|---|
| 120 | get_quote | Read the global, event, and market pda data and return a quote for the user request, or a null quote of (0, 0). Do not write to data accounts as you are not guarunteed to be filled. | 12 bytes: amount u64, odds u32. Never an error. |
| 121 | fill_bet_quote | Use the previously calculated quote to fill the bet, optionally updating the market, event, and global data pdas. | Move exactly amount_to_send and mark the quote used. |
| 200 | ingest | Called once per tick and used to represent the offchain backend in a real SPAMM. Read the odds feed data and update any global or market-specific data you need. | Account data updates. |
Odds are decimal odds times 10,000, little endian and always from the perspective of the user being filled. A price of 3.00 is 30000 and means the user will get 3x their stake returned if they win, and you will lose 2x their stake. Amount 0, or odds at or below 10000 (1.0), is a decline. The router will not hand you a stake under the min amount of 0.1 USDC.
get_quote accounts, in order: user (r), clock sysvar (r), market (r), event (r), config (r), quote buffer (w). fill_bet_quote accounts: user (r), aggregator config (s, r), market (w), event (w), config (w), quote buffer (w), your vault (w), the liability token account (w), the mint (r), the token program (r). ingest accounts: admin signer (s, r), config (w), then every event (w), then every market (w). The admin is the admin field on the config account.
The simulation is only for prematch betting, so event sequence and game state are not used. If ingest fails, you quote nothing for the rest of that sim.
Default compute budgets are 1,400,000 CU for a quote or a fill (the Solana maximum), and 40,000,000 CU for one ingest (since this is a proxy for offchain work). The .so has to be sBPF v3 (e_flags 3, machine 247) and at most 1 MiB. validate checks the ELF and runs a few short sims.
The odds you receive
Ingest data, after the 200 byte, version 1, little endian. crates/wire parses it and is no_std, so the program can depend on it.
version u8 = 1
tick u32
n_events u8
vault u64 free collateral, token base units
events n_events * 21
event_id 11 bytes
status u8 0 listed, 1 open, 2 closed
outcome u8 0 unresolved, 1 side 0 won, 2 side 1 won
open_step u32 tick this market opens
close_step u32 tick this market closes
n_messages u16
messages n_messages * 13
market_index u8
origin_step u32 when the print was true
arrival_step u32 when it reaches you
prob_micros u32 P(side 0), in millionths
An older origin_step must not overwrite a newer one. Prints are the hidden probability plus noise, and they are often late. What you store on the market account after its 2-byte header is up to you. The sample keeps side-0 odds, side-1 odds, and the last origin at bytes 2, 6, and 10, and the open and close ticks at bytes 14 and 18.
Collateral
You start with 10,000 USDC. amount_to_send is not the stake. It is the increase in that market's peak, the worse of the two sides: profit owed if side 0 wins, or if side 1 wins. A bet on the other side of a market you are already on can cost nothing. If the vault cannot cover it, the fill fails. Collateral locked on a market stays locked until that market settles, so a book that quotes its whole bankroll cannot quote anywhere else until then.