use crate::error::RoundError;
use anchor_lang::{prelude::*, solana_program::clock::UnixTimestamp};
use spl_math::precise_number::PreciseNumber;
#[account]
#[derive(InitSpace, Default)]
pub struct Round {
pub status: RoundStatus,
pub bid_mint: Pubkey,
pub offer_mint: Pubkey,
pub heir: Pubkey,
pub recipient: Pubkey,
pub payer: Pubkey,
pub vouchers_count: u64,
pub created_at: i64,
pub bidding_start: i64,
pub bidding_end: i64,
pub total_bid: Option<u64>,
pub total_offer: Option<u64>,
pub target_bid: u64,
pub return_wallet: Pubkey,
pub reserved1: [u8; 24],
pub reserved2: Pubkey,
pub reserved3: Pubkey,
}
const _: [(); Round::INIT_SPACE] = [(); 339];
#[derive(Debug, Clone, PartialEq, AnchorSerialize, AnchorDeserialize, InitSpace)]
pub enum RoundStatus {
Pending,
Accepted,
Rejected,
}
impl Default for RoundStatus {
fn default() -> Self {
Self::Pending
}
}
const WITHDRAWAL_ENABLED_AFTER_INACTIVITY_TIMEOUT: i64 = 7 * 24 * 60 * 60; impl Round {
pub fn assert_can_accept_or_reject(&self, now: UnixTimestamp) -> Result<()> {
if self.status != RoundStatus::Pending {
return err!(RoundError::OfferIsNotPending);
}
if self.can_withdraw_due_heir_inactivity(now)? {
return err!(RoundError::OfferTimedOut);
}
if now < self.bidding_end {
return err!(RoundError::BiddingStillGoing);
}
Ok(())
}
pub fn assert_can_contribute(&self, now: UnixTimestamp) -> Result<()> {
if now < self.bidding_start {
return err!(RoundError::BiddingNotStarted);
}
if self.bidding_end < now {
return err!(RoundError::BiddingEnded);
}
Ok(())
}
pub fn assert_can_withdraw(&self, now: UnixTimestamp, user_is_signer: bool) -> Result<()> {
if now < self.bidding_start {
return err!(RoundError::BiddingNotStarted);
}
if self.status == RoundStatus::Accepted {
return err!(RoundError::CantWithdrawAcceptedOffer);
}
if self.status == RoundStatus::Pending
&& now > self.bidding_end
&& !self.can_withdraw_due_heir_inactivity(now)?
{
return err!(RoundError::InactivityTimeoutHasNotPassed);
}
if !user_is_signer && now < self.bidding_end {
return err!(RoundError::CantWithdrawWithoutUserSignature);
}
Ok(())
}
fn heir_timeout_date(&self) -> Result<UnixTimestamp> {
self.bidding_end
.checked_add(WITHDRAWAL_ENABLED_AFTER_INACTIVITY_TIMEOUT)
.ok_or_else(|| error!(RoundError::Overflow))
}
fn can_withdraw_due_heir_inactivity(&self, now: UnixTimestamp) -> Result<bool> {
Ok(now > self.heir_timeout_date()?)
}
pub fn assert_can_redeem(&self) -> Result<()> {
if self.status != RoundStatus::Accepted {
return err!(RoundError::CantRedeemNotAcceptedRound);
}
Ok(())
}
pub fn assert_can_cancel(&self, now: UnixTimestamp) -> Result<()> {
if now > self.bidding_start {
return err!(RoundError::CantCancelStartedRound);
}
Ok(())
}
pub fn assert_can_close(&self, now: UnixTimestamp) -> Result<()> {
if self.vouchers_count > 0 {
return err!(RoundError::VouchersNotWithdrawn);
}
match self.status {
RoundStatus::Accepted | RoundStatus::Rejected => Ok(()),
RoundStatus::Pending if now > self.heir_timeout_date()? => Ok(()),
RoundStatus::Pending => err!(RoundError::CantCloseBeforeHeirTimeout),
}
}
}
pub fn calculate_redeem_amount(
target_bid: u64,
bid_balance: u64,
user_bid: u64,
total_offer: u64,
) -> Option<u64> {
let bid = target_bid.max(bid_balance);
let target_bid = PreciseNumber::new(bid as u128)?;
let user_bid = PreciseNumber::new(user_bid as u128)?;
let total = PreciseNumber::new(total_offer as u128)?;
user_bid
.checked_div(&target_bid)?
.checked_mul(&total)?
.to_imprecise()?
.try_into()
.ok()
}
#[account]
pub struct Voucher {
pub user: Pubkey,
pub round: Pubkey,
pub payer: Pubkey,
pub amount_contributed: u64,
}
impl Voucher {
pub fn calculate_space() -> usize {
8 + 32 + 32 + 32 + 8
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn test_calculate_redeem_amount() {
struct TestCase {
target_bid: u64,
bid_balance: u64,
user_bid: u64,
total_offer: u64,
expected: u64,
}
let cases = vec![
TestCase {
bid_balance: 100,
target_bid: 100,
user_bid: 10,
total_offer: 100,
expected: 10,
},
TestCase {
bid_balance: 10000,
target_bid: 1000,
user_bid: 10000,
total_offer: 100,
expected: 100,
},
TestCase {
bid_balance: 10000,
target_bid: 10000,
user_bid: 9000,
total_offer: 1000,
expected: 900,
},
TestCase {
bid_balance: 10000,
target_bid: 26_250,
user_bid: 9000,
total_offer: 2500,
expected: 857,
},
TestCase {
bid_balance: 25_000,
target_bid: 5_500,
user_bid: 899,
total_offer: 500,
expected: 18,
},
];
for case in cases {
let result = calculate_redeem_amount(
case.target_bid,
case.bid_balance,
case.user_bid,
case.total_offer,
);
assert_eq!(result, Some(case.expected));
}
}
#[test]
fn test_can_contribute() {
let round = Round {
status: RoundStatus::Pending,
bidding_start: 1000,
bidding_end: 2000,
created_at: 500,
..Default::default()
};
assert_eq!(
round.assert_can_contribute(500),
err!(RoundError::BiddingNotStarted)
);
assert_eq!(round.assert_can_contribute(1500), Ok(()));
assert_eq!(
round.assert_can_contribute(2500),
err!(RoundError::BiddingEnded)
);
}
#[test]
fn test_can_accept_reject() {
let mut round = Round {
status: RoundStatus::Pending,
bidding_start: 1000,
bidding_end: 2000,
..Default::default()
};
assert_eq!(
round.assert_can_accept_or_reject(500),
err!(RoundError::BiddingStillGoing)
);
assert_eq!(
round.assert_can_accept_or_reject(1500),
err!(RoundError::BiddingStillGoing)
);
assert_eq!(
round.assert_can_accept_or_reject(2001 + WITHDRAWAL_ENABLED_AFTER_INACTIVITY_TIMEOUT),
err!(RoundError::OfferTimedOut)
);
round.status = RoundStatus::Accepted;
assert_eq!(
round.assert_can_accept_or_reject(1500),
err!(RoundError::OfferIsNotPending),
);
round.status = RoundStatus::Rejected;
assert_eq!(
round.assert_can_accept_or_reject(1500),
err!(RoundError::OfferIsNotPending),
);
}
#[test]
fn test_can_withdraw_pending() {
let round = Round {
status: RoundStatus::Pending,
bidding_start: 1000,
bidding_end: 2000,
created_at: 500,
..Default::default()
};
assert_eq!(
round.assert_can_withdraw(700, true),
err!(RoundError::BiddingNotStarted)
);
assert_eq!(round.assert_can_withdraw(1500, true), Ok(()));
assert_eq!(
round.assert_can_withdraw(1500, false),
err!(RoundError::CantWithdrawWithoutUserSignature)
);
assert_eq!(
round.assert_can_withdraw(2100, true),
err!(RoundError::InactivityTimeoutHasNotPassed)
);
assert_eq!(
round.assert_can_withdraw(2100 + WITHDRAWAL_ENABLED_AFTER_INACTIVITY_TIMEOUT, false),
Ok(())
);
}
proptest! {
#[test]
fn proptest_withdraw_accepted(now in 2001i64..10000000000000, sig: bool) {
let round = Round {
status: RoundStatus::Accepted,
bidding_start: 1000,
bidding_end: 2000,
created_at: 500,
..Default::default()
};
assert_eq!(
round.assert_can_withdraw(now, sig),
err!(RoundError::CantWithdrawAcceptedOffer)
);
}
#[test]
fn proptest_can_withdraw_rejected(now in 2001i64..10000000000000, sig: bool) {
let round = Round {
status: RoundStatus::Rejected,
bidding_start: 1000,
bidding_end: 2000,
created_at: 500,
..Default::default()
};
assert_eq!(round.assert_can_withdraw(now, sig), Ok(()));
}
}
}