//! pairo: one-hour long/short duels on a pump.fun coin, one person against another. //! //! The opener picks a coin, a side and a stake; the stake sits in the duel account. A taker matches it with the //! same stake on the other side, and that moment's price (read from the coin's own bonding curve or PumpSwap pool) //! becomes the entry. In the last three minutes anyone can take up to three samples of the price, at least //! 60 seconds apart, and sampling closes when the hour ends. The duel settles on the median of the samples //! (one sample: that one; two: their average): a move under 1% (or no sample at all, or a curve //! coin that graduated mid-duel) is a draw and both stakes go back; otherwise the winner gets both stakes minus 2%. //! There is no house liquidity: every payout is already in the duel. use anchor_lang::prelude::*; use anchor_lang::system_program; declare_id!("EfY5FBbaMSqSm198nBWhnnWf2paj9uLiAZp2GjaKJCWL"); pub const PUMP: Pubkey = pubkey!("6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P"); pub const AMM: Pubkey = pubkey!("pAMMBay6oceH9fJKBRHGP5D4bD4sWpmSwMn52FMfXEA"); pub const WSOL: Pubkey = pubkey!("So11111111111111111111111111111111111111112"); /// only the deploy key can open the house, so nobody can front-run init and make themselves admin pub const DEPLOYER: Pubkey = pubkey!("CTNkYr8FmCqiPsyx3Xt59mL4sLryJmFDM36wvg5e6kPP"); pub const DURATION: i64 = 3_600; pub const SAMPLE_FROM: i64 = 180; // sampling opens 3 minutes before the end pub const SAMPLE_GAP: i64 = 60; pub const MIN_STAKE: u64 = 50_000_000; // 0.05 SOL pub const MAX_STAKE_BPS: u128 = 500; // a stake may be at most 5% of the pool's SOL reserves pub const FEE_BPS: u64 = 200; pub const DRAW_BPS: u128 = 100; // a move under 1% is a draw pub const LONG: u8 = 1; pub const SHORT: u8 = 2; pub const CURVE: u8 = 0; pub const POOL: u8 = 1; pub const WAITING: u8 = 0; pub const LIVE: u8 = 1; #[program] pub mod pairo { use super::*; pub fn init_house(ctx: Context) -> Result<()> { let h = &mut ctx.accounts.house; h.admin = ctx.accounts.admin.key(); h.bump = ctx.bumps.house; Ok(()) } /// `source` is CURVE (remaining: bonding curve) or POOL (remaining: pool, base vault, quote vault). pub fn open_duel<'info>(ctx: Context<'_, '_, 'info, 'info, OpenDuel<'info>>, nonce: u32, side: u8, stake: u64, source: u8) -> Result<()> { require!(side == LONG || side == SHORT, Err::BadSide); require!(stake >= MIN_STAKE, Err::StakeTooSmall); let mint = ctx.accounts.mint.key(); let src = read_price(source, &mint, ctx.remaining_accounts)?; require!(!src.graduated, Err::Graduated); require!((stake as u128) * 10_000 <= src.quote * MAX_STAKE_BPS, Err::StakeTooBig); system_program::transfer( CpiContext::new(ctx.accounts.system_program.to_account_info(), system_program::Transfer { from: ctx.accounts.opener.to_account_info(), to: ctx.accounts.duel.to_account_info(), }), stake, )?; let d = &mut ctx.accounts.duel; d.mint = mint; d.opener = ctx.accounts.opener.key(); d.side = side; d.stake = stake; d.source = source; d.price_acc = ctx.remaining_accounts[0].key(); if source == POOL { d.base_vault = ctx.remaining_accounts[1].key(); d.quote_vault = ctx.remaining_accounts[2].key(); } d.nonce = nonce; d.state = WAITING; d.bump = ctx.bumps.duel; emit!(DuelOpened { duel: d.key(), mint, opener: d.opener, side, stake }); Ok(()) } /// The opener takes the stake back while nobody has taken the other side. pub fn cancel(ctx: Context) -> Result<()> { require!(ctx.accounts.duel.state == WAITING, Err::NotWaiting); emit!(DuelClosed { duel: ctx.accounts.duel.key(), winner: Pubkey::default(), payout: 0, fee: 0, draw: true }); Ok(()) // `close = opener` returns the stake and the rent } pub fn take<'info>(ctx: Context<'_, '_, 'info, 'info, Take<'info>>) -> Result<()> { let d = &ctx.accounts.duel; require!(d.state == WAITING, Err::NotWaiting); require_keys_neq!(ctx.accounts.taker.key(), d.opener, Err::SelfDuel); let src = read_source(d, ctx.remaining_accounts)?; require!(!src.graduated, Err::Graduated); system_program::transfer( CpiContext::new(ctx.accounts.system_program.to_account_info(), system_program::Transfer { from: ctx.accounts.taker.to_account_info(), to: ctx.accounts.duel.to_account_info(), }), d.stake, )?; let now = Clock::get()?.unix_timestamp; let d = &mut ctx.accounts.duel; d.taker = ctx.accounts.taker.key(); d.entry = src.price; d.start = now; d.end = now + DURATION; d.state = LIVE; emit!(DuelTaken { duel: d.key(), taker: d.taker, entry: d.entry, end: d.end }); Ok(()) } /// Anyone can take a price sample in the last 3 minutes, at least 60 s after the previous one, up to three. pub fn sample<'info>(ctx: Context<'_, '_, 'info, 'info, Sample<'info>>) -> Result<()> { let now = Clock::get()?.unix_timestamp; let d = &ctx.accounts.duel; require!(d.state == LIVE, Err::NotLive); // sampling closes at the end: nobody can wait for a better price after the hour require!(now >= d.end - SAMPLE_FROM && now <= d.end, Err::NotSamplingTime); let n = d.n as usize; require!(n < 3, Err::EnoughSamples); if n > 0 { require!(now >= d.sample_at[n - 1] + SAMPLE_GAP, Err::TooSoon); } let src = read_source(d, ctx.remaining_accounts)?; let d = &mut ctx.accounts.duel; if src.graduated { d.graduated = true; // a curve duel whose coin left the curve settles as a draw } else { d.samples[n] = src.price; d.sample_at[n] = now; d.n += 1; } emit!(Sampled { duel: d.key(), price: src.price, n: d.n }); Ok(()) } pub fn settle(ctx: Context) -> Result<()> { let now = Clock::get()?.unix_timestamp; let d = &ctx.accounts.duel; require!(d.state == LIVE, Err::NotLive); require!(now >= d.end, Err::NotOver); let exit = match d.n { 0 => 0u128, 1 => d.samples[0], 2 => (d.samples[0] + d.samples[1]) / 2, _ => median3(d.samples[0], d.samples[1], d.samples[2]), }; let pot = d.stake.checked_mul(2).ok_or(Err::Overflow)?; let diff = if exit > d.entry { exit - d.entry } else { d.entry - exit }; let draw = d.n == 0 || d.graduated || d.entry == 0 || diff * 10_000 < d.entry * DRAW_BPS; let duel = ctx.accounts.duel.to_account_info(); let (winner, payout, fee) = if draw { **duel.try_borrow_mut_lamports()? -= pot; **ctx.accounts.opener.try_borrow_mut_lamports()? += d.stake; **ctx.accounts.taker.try_borrow_mut_lamports()? += d.stake; (Pubkey::default(), 0u64, 0u64) } else { let long_wins = exit > d.entry; let opener_wins = (d.side == LONG) == long_wins; let fee = pot * FEE_BPS / 10_000; let payout = pot - fee; **duel.try_borrow_mut_lamports()? -= pot; **ctx.accounts.house.to_account_info().try_borrow_mut_lamports()? += fee; if opener_wins { **ctx.accounts.opener.try_borrow_mut_lamports()? += payout; (d.opener, payout, fee) } else { **ctx.accounts.taker.try_borrow_mut_lamports()? += payout; (d.taker, payout, fee) } }; emit!(DuelClosed { duel: d.key(), winner, payout, fee, draw }); Ok(()) // `close = opener`: the rent the opener paid goes back to them } pub fn withdraw(ctx: Context, amount: u64) -> Result<()> { let house = ctx.accounts.house.to_account_info(); let keep = Rent::get()?.minimum_balance(house.data_len()); require!(house.lamports().saturating_sub(keep) >= amount, Err::Insolvent); **house.try_borrow_mut_lamports()? -= amount; **ctx.accounts.to.try_borrow_mut_lamports()? += amount; Ok(()) } pub fn set_admin(ctx: Context, admin: Pubkey) -> Result<()> { ctx.accounts.house.admin = admin; Ok(()) } } pub struct Src { /// SOL per token, scaled by 1e12 pub price: u128, /// SOL reserves the price comes from pub quote: u128, pub graduated: bool, } fn u64_at(data: &[u8], at: usize) -> Result { require!(data.len() >= at + 8, Err::BadSource); Ok(u64::from_le_bytes(data[at..at + 8].try_into().unwrap())) } fn ratio(quote: u64, base: u64) -> Result { require!(base > 0 && quote > 0, Err::BadSource); Ok((quote as u128) * 1_000_000_000_000 / (base as u128)) } /// Reads the price from the coin's bonding curve (CURVE) or its PumpSwap pool and vaults (POOL), checking every account. fn read_price(source: u8, mint: &Pubkey, accs: &[AccountInfo]) -> Result { if source == CURVE { require!(!accs.is_empty(), Err::BadSource); let c = &accs[0]; let (expect, _) = Pubkey::find_program_address(&[b"bonding-curve", mint.as_ref()], &PUMP); require_keys_eq!(c.key(), expect, Err::BadSource); require_keys_eq!(*c.owner, PUMP, Err::BadSource); let data = c.try_borrow_data()?; require!(data.len() > 48, Err::BadSource); let graduated = data[48] != 0; let (vt, vq) = (u64_at(&data, 8)?, u64_at(&data, 16)?); if graduated { return Ok(Src { price: 0, quote: 0, graduated }); } Ok(Src { price: ratio(vq, vt)?, quote: vq as u128, graduated }) } else if source == POOL { require!(accs.len() >= 3, Err::BadSource); let (pool, bv, qv) = (&accs[0], &accs[1], &accs[2]); require_keys_eq!(*pool.owner, AMM, Err::BadSource); let p = pool.try_borrow_data()?; require!(p.len() >= 203, Err::BadSource); require!(p[43..75] == mint.to_bytes(), Err::BadSource); require!(p[75..107] == WSOL.to_bytes(), Err::BadSource); require!(p[139..171] == bv.key().to_bytes() && p[171..203] == qv.key().to_bytes(), Err::BadSource); let base = u64_at(&bv.try_borrow_data()?, 64)?; let quote = u64_at(&qv.try_borrow_data()?, 64)?; Ok(Src { price: ratio(quote, base)?, quote: quote as u128, graduated: false }) } else { err!(Err::BadSource) } } /// Same source as when the duel was opened: the same accounts, in the same order. fn read_source(d: &Duel, accs: &[AccountInfo]) -> Result { require!(!accs.is_empty() && accs[0].key() == d.price_acc, Err::BadSource); if d.source == POOL { require!(accs.len() >= 3 && accs[1].key() == d.base_vault && accs[2].key() == d.quote_vault, Err::BadSource); } read_price(d.source, &d.mint, accs) } fn median3(a: u128, b: u128, c: u128) -> u128 { a.max(b).min(a.min(b).max(c)) } #[account] #[derive(InitSpace)] pub struct House { pub admin: Pubkey, pub bump: u8, } #[account] #[derive(InitSpace)] pub struct Duel { pub mint: Pubkey, pub opener: Pubkey, pub taker: Pubkey, pub price_acc: Pubkey, pub base_vault: Pubkey, pub quote_vault: Pubkey, pub stake: u64, pub entry: u128, pub samples: [u128; 3], pub sample_at: [i64; 3], pub start: i64, pub end: i64, pub nonce: u32, pub side: u8, pub source: u8, pub state: u8, pub n: u8, pub graduated: bool, pub bump: u8, } #[derive(Accounts)] pub struct InitHouse<'info> { #[account(init, payer = admin, space = 8 + House::INIT_SPACE, seeds = [b"house"], bump)] pub house: Account<'info, House>, #[account(mut, address = DEPLOYER)] pub admin: Signer<'info>, pub system_program: Program<'info, System>, } #[derive(Accounts)] #[instruction(nonce: u32)] pub struct OpenDuel<'info> { #[account(init, payer = opener, space = 8 + Duel::INIT_SPACE, seeds = [b"duel", opener.key().as_ref(), &nonce.to_le_bytes()], bump)] pub duel: Account<'info, Duel>, /// CHECK: the coin; read_price ties it to its curve or pool pub mint: UncheckedAccount<'info>, #[account(mut)] pub opener: Signer<'info>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct Cancel<'info> { #[account(mut, close = opener, has_one = opener)] pub duel: Account<'info, Duel>, #[account(mut)] pub opener: Signer<'info>, } #[derive(Accounts)] pub struct Take<'info> { #[account(mut)] pub duel: Account<'info, Duel>, #[account(mut)] pub taker: Signer<'info>, pub system_program: Program<'info, System>, } #[derive(Accounts)] pub struct Sample<'info> { #[account(mut)] pub duel: Account<'info, Duel>, } #[derive(Accounts)] pub struct Settle<'info> { #[account(mut, close = opener, has_one = opener, has_one = taker)] pub duel: Account<'info, Duel>, /// CHECK: must be duel.opener (has_one); receives a stake or the pot, and the rent #[account(mut)] pub opener: UncheckedAccount<'info>, /// CHECK: must be duel.taker (has_one) #[account(mut)] pub taker: UncheckedAccount<'info>, #[account(mut, seeds = [b"house"], bump = house.bump)] pub house: Account<'info, House>, } #[derive(Accounts)] pub struct Withdraw<'info> { #[account(mut, seeds = [b"house"], bump = house.bump, has_one = admin)] pub house: Account<'info, House>, pub admin: Signer<'info>, /// CHECK: any destination the admin picks #[account(mut)] pub to: UncheckedAccount<'info>, } #[derive(Accounts)] pub struct SetAdmin<'info> { #[account(mut, seeds = [b"house"], bump = house.bump, has_one = admin)] pub house: Account<'info, House>, pub admin: Signer<'info>, } #[event] pub struct DuelOpened { pub duel: Pubkey, pub mint: Pubkey, pub opener: Pubkey, pub side: u8, pub stake: u64 } #[event] pub struct DuelTaken { pub duel: Pubkey, pub taker: Pubkey, pub entry: u128, pub end: i64 } #[event] pub struct Sampled { pub duel: Pubkey, pub price: u128, pub n: u8 } #[event] pub struct DuelClosed { pub duel: Pubkey, pub winner: Pubkey, pub payout: u64, pub fee: u64, pub draw: bool } #[error_code] pub enum Err { #[msg("side must be 1 (long) or 2 (short)")] BadSide, #[msg("minimum stake is 0.05 SOL")] StakeTooSmall, #[msg("stake is over 5% of the pool's SOL reserves")] StakeTooBig, #[msg("not this coin's curve or pool")] BadSource, #[msg("the coin has left its bonding curve")] Graduated, #[msg("the duel is not waiting for a taker")] NotWaiting, #[msg("the duel is not live")] NotLive, #[msg("you cannot take your own duel")] SelfDuel, #[msg("samples are taken in the last 3 minutes only")] NotSamplingTime, #[msg("three samples are already in")] EnoughSamples, #[msg("samples must be at least 60 seconds apart")] TooSoon, #[msg("the duel has not ended")] NotOver, #[msg("not enough lamports")] Insolvent, #[msg("overflow")] Overflow, }