diff --git a/js/packages/common/src/actions/auction.ts b/js/packages/common/src/actions/auction.ts index 59b11b3..b0f6576 100644 --- a/js/packages/common/src/actions/auction.ts +++ b/js/packages/common/src/actions/auction.ts @@ -15,6 +15,7 @@ import { findProgramAddress } from '../utils'; export const AUCTION_PREFIX = 'auction'; export const METADATA = 'metadata'; export const EXTENDED = 'extended'; +export const MAX_AUCTION_DATA_EXTENDED_SIZE = 8 + 9 + 2 + 200; export enum AuctionState { Created = 0, @@ -91,6 +92,23 @@ export const decodeBidderPot = (buffer: Buffer) => { return deserializeUnchecked(AUCTION_SCHEMA, BidderPot, buffer) as BidderPot; }; +export const AuctionDataExtendedParser: AccountParser = ( + pubkey: PublicKey, + account: AccountInfo, +) => ({ + pubkey, + account, + info: decodeAuctionDataExtended(account.data), +}); + +export const decodeAuctionDataExtended = (buffer: Buffer) => { + return deserializeUnchecked( + AUCTION_SCHEMA, + AuctionDataExtended, + buffer, + ) as AuctionDataExtended; +}; + export const BidderMetadataParser: AccountParser = ( pubkey: PublicKey, account: AccountInfo, @@ -323,7 +341,24 @@ export class WinnerLimit { } } -class CreateAuctionArgs { +export interface IPartialCreateAuctionArgs { + /// How many winners are allowed for this auction. See AuctionData. + winners: WinnerLimit; + /// End time is the cut-off point that the auction is forced to end by. See AuctionData. + endAuctionAt: BN | null; + /// Gap time is how much time after the previous bid where the auction ends. See AuctionData. + auctionGap: BN | null; + /// Token mint for the SPL token used for bidding. + tokenMint: PublicKey; + + priceFloor: PriceFloor; + + tickSize: BN | null; + + gapTickSizePercentage: number | null; +} + +export class CreateAuctionArgs implements IPartialCreateAuctionArgs { instruction: number = 1; /// How many winners are allowed for this auction. See AuctionData. winners: WinnerLimit; @@ -340,6 +375,10 @@ class CreateAuctionArgs { priceFloor: PriceFloor; + tickSize: BN | null; + + gapTickSizePercentage: number | null; + constructor(args: { winners: WinnerLimit; endAuctionAt: BN | null; @@ -348,6 +387,8 @@ class CreateAuctionArgs { authority: PublicKey; resource: PublicKey; priceFloor: PriceFloor; + tickSize: BN | null; + gapTickSizePercentage: number | null; }) { this.winners = args.winners; this.endAuctionAt = args.endAuctionAt; @@ -356,6 +397,8 @@ class CreateAuctionArgs { this.authority = args.authority; this.resource = args.resource; this.priceFloor = args.priceFloor; + this.tickSize = args.tickSize; + this.gapTickSizePercentage = args.gapTickSizePercentage; } } @@ -402,6 +445,8 @@ export const AUCTION_SCHEMA = new Map([ ['authority', 'pubkey'], ['resource', 'pubkey'], ['priceFloor', PriceFloor], + ['tickSize', { kind: 'option', type: 'u64' }], + ['gapTickSizePercentage', { kind: 'option', type: 'u8' }], ], }, ], @@ -541,39 +586,20 @@ export const decodeAuctionData = (buffer: Buffer) => { }; export async function createAuction( - winners: WinnerLimit, - resource: PublicKey, - endAuctionAt: BN | null, - auctionGap: BN | null, - priceFloor: PriceFloor, - tokenMint: PublicKey, - authority: PublicKey, + settings: CreateAuctionArgs, creator: PublicKey, instructions: TransactionInstruction[], ) { const auctionProgramId = programIds().auction; - const data = Buffer.from( - serialize( - AUCTION_SCHEMA, - new CreateAuctionArgs({ - winners, - resource, - endAuctionAt, - auctionGap, - tokenMint, - authority, - priceFloor, - }), - ), - ); + const data = Buffer.from(serialize(AUCTION_SCHEMA, settings)); const auctionKey: PublicKey = ( await findProgramAddress( [ Buffer.from(AUCTION_PREFIX), auctionProgramId.toBuffer(), - resource.toBuffer(), + settings.resource.toBuffer(), ], auctionProgramId, ) @@ -591,7 +617,10 @@ export async function createAuction( isWritable: true, }, { - pubkey: await getAuctionExtended({ auctionProgramId, resource }), + pubkey: await getAuctionExtended({ + auctionProgramId, + resource: settings.resource, + }), isSigner: false, isWritable: true, }, diff --git a/js/packages/web/src/actions/createAuctionManager.ts b/js/packages/web/src/actions/createAuctionManager.ts index 2eb1a61..57d7f07 100644 --- a/js/packages/web/src/actions/createAuctionManager.ts +++ b/js/packages/web/src/actions/createAuctionManager.ts @@ -8,7 +8,6 @@ import { actions, Metadata, ParsedAccount, - WinnerLimit, MasterEdition, SequenceType, sendTransactions, @@ -20,8 +19,8 @@ import { getSafetyDepositBoxAddress, createAssociatedTokenAccountInstruction, sendTransactionWithRetry, - PriceFloor, findProgramAddress, + IPartialCreateAuctionArgs, } from '@oyster/common'; import { AccountLayout, Token } from '@solana/spl-token'; @@ -100,13 +99,10 @@ export async function createAuctionManager( ParsedAccount >, settings: AuctionManagerSettings, - winnerLimit: WinnerLimit, - endAuctionAt: BN, - auctionGap: BN, + auctionSettings: IPartialCreateAuctionArgs, safetyDepositDrafts: SafetyDepositDraft[], participationSafetyDepositDraft: SafetyDepositDraft | undefined, paymentMint: PublicKey, - priceFloor: PriceFloor, ): Promise<{ vault: PublicKey; auction: PublicKey; @@ -136,15 +132,7 @@ export async function createAuctionManager( instructions: makeAuctionInstructions, signers: makeAuctionSigners, auction, - } = await makeAuction( - wallet, - winnerLimit, - vault, - endAuctionAt, - auctionGap, - paymentMint, - priceFloor, - ); + } = await makeAuction(wallet, vault, auctionSettings); let safetyDepositConfigsWithPotentiallyUnsetTokens = await buildSafetyDepositArray( diff --git a/js/packages/web/src/actions/makeAuction.ts b/js/packages/web/src/actions/makeAuction.ts index f38e261..0bcf968 100644 --- a/js/packages/web/src/actions/makeAuction.ts +++ b/js/packages/web/src/actions/makeAuction.ts @@ -2,24 +2,19 @@ import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; import { utils, actions, - WinnerLimit, - PriceFloor, findProgramAddress, + IPartialCreateAuctionArgs, + CreateAuctionArgs, } from '@oyster/common'; -import BN from 'bn.js'; import { METAPLEX_PREFIX } from '../models/metaplex'; const { AUCTION_PREFIX, createAuction } = actions; // This command makes an auction export async function makeAuction( wallet: any, - winnerLimit: WinnerLimit, vault: PublicKey, - endAuctionAt: BN, - auctionGap: BN, - paymentMint: PublicKey, - priceFloor: PriceFloor, + auctionSettings: IPartialCreateAuctionArgs, ): Promise<{ auction: PublicKey; instructions: TransactionInstruction[]; @@ -47,17 +42,13 @@ export async function makeAuction( ) )[0]; - createAuction( - winnerLimit, - vault, - endAuctionAt, - auctionGap, - priceFloor, - paymentMint, - auctionManagerKey, - wallet.publicKey, - instructions, - ); + const fullSettings = new CreateAuctionArgs({ + ...auctionSettings, + authority: auctionManagerKey, + resource: vault, + }); + + createAuction(fullSettings, wallet.publicKey, instructions); return { instructions, signers, auction: auctionKey }; } diff --git a/js/packages/web/src/components/AuctionCard/index.tsx b/js/packages/web/src/components/AuctionCard/index.tsx index 28291ba..7b96f5e 100644 --- a/js/packages/web/src/components/AuctionCard/index.tsx +++ b/js/packages/web/src/components/AuctionCard/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Col, Button, InputNumber, Spin } from 'antd'; import { MemoryRouter, Route, Redirect, Link } from 'react-router-dom'; @@ -13,6 +13,10 @@ import { formatTokenAmount, useMint, PriceFloorType, + AuctionDataExtended, + ParsedAccount, + getAuctionExtended, + programIds, } from '@oyster/common'; import { AuctionView, useUserBalance } from '../../hooks'; import { sendPlaceBid } from '../../actions/sendPlaceBid'; @@ -26,9 +30,78 @@ import BN from 'bn.js'; import { Confetti } from '../Confetti'; import { QUOTE_MINT } from '../../constants'; import { LAMPORTS_PER_SOL } from '@solana/web3.js'; +import { useMeta } from '../../contexts'; +import moment from 'moment'; const { useWallet } = contexts.Wallet; +function useGapTickCheck( + value: number | undefined, + gapTick: number | null, + gapTime: number, + auctionView: AuctionView, +): boolean { + return !!useMemo(() => { + if (gapTick && value && gapTime && !auctionView.auction.info.ended()) { + // so we have a gap tick percentage, and a gap tick time, and a value, and we're not ended - are we within gap time? + const now = moment().unix(); + const endedAt = auctionView.auction.info.endedAt; + if (endedAt) { + const ended = endedAt.toNumber(); + if (now > ended) { + const toLamportVal = value * LAMPORTS_PER_SOL; + // Ok, we are in gap time, since now is greater than ended and we're not actually an ended auction yt. + // Check that the bid is at least gapTick % bigger than the next biggest one in the stack. + for ( + let i = auctionView.auction.info.bidState.bids.length - 1; + i > -1; + i-- + ) { + const bid = auctionView.auction.info.bidState.bids[i]; + const expected = bid.amount.toNumber(); + if (expected < toLamportVal) { + const higherExpectedAmount = expected * ((100 + gapTick) / 100); + + return higherExpectedAmount > toLamportVal; + } else if (expected == toLamportVal) { + // If gap tick is set, no way you can bid in this case - you must bid higher. + return true; + } + } + return false; + } else { + return false; + } + } + return false; + } + }, [value, gapTick, gapTime, auctionView]); +} + +function useAuctionExtended( + auctionView: AuctionView, +): ParsedAccount | undefined { + const [auctionExtended, setAuctionExtended] = + useState>(); + const { auctionDataExtended } = useMeta(); + + useMemo(() => { + const fn = async () => { + if (!auctionExtended) { + const PROGRAM_IDS = programIds(); + const extendedKey = await getAuctionExtended({ + auctionProgramId: PROGRAM_IDS.auction, + resource: auctionView.vault.pubkey, + }); + const extendedValue = auctionDataExtended[extendedKey.toBase58()]; + if (extendedValue) setAuctionExtended(extendedValue); + } + }; + fn(); + }, [auctionDataExtended, auctionExtended, setAuctionExtended]); + + return auctionExtended; +} export const AuctionCard = ({ auctionView, style, @@ -74,9 +147,21 @@ export const AuctionCard = ({ winnerIndex, auctionView, ); + const auctionExtended = useAuctionExtended(auctionView); const eligibleForAnything = winnerIndex !== null || eligibleForOpenEdition; const gapTime = (auctionView.auction.info.auctionGap?.toNumber() || 0) / 60; + const gapTick = auctionExtended + ? auctionExtended.info.gapTickSizePercentage + : 0; + const tickSize = auctionExtended ? auctionExtended.info.tickSize : 0; + const tickSizeInvalid = !!( + tickSize && + value && + (value * LAMPORTS_PER_SOL) % tickSize.toNumber() != 0 + ); + + const gapBidInvalid = useGapTickCheck(value, gapTick, gapTime, auctionView); return (
@@ -270,13 +355,32 @@ export const AuctionCard = ({ }} > Bids placed in the last {gapTime} minutes will extend - bidding for another {gapTime} minutes. + bidding for another {gapTime} minutes beyond the point in + time that bid was made.{' '} + {gapTick && ( + + Additionally, once the official auction end time has + passed, only bids {gapTick}% larger than an existing + bid will be accepted. + + )}
)}

+ {tickSizeInvalid && tickSize && ( + + Tick size is ◎{tickSize.toNumber() / LAMPORTS_PER_SOL}. + + )} + {gapBidInvalid && ( + + Your bid needs to be at least {gapTick}% larger than an + existing bid during gap periods to be eligible. + + )}
>; auctionManagersByAuction: Record>; auctions: Record>; + auctionDataExtended: Record>; vaults: Record>; store: ParsedAccount | null; bidderMetadataByAuctionAndBidder: Record< @@ -88,29 +92,38 @@ interface MetaState { const { MetadataKey } = actions; -type UpdateStateValueFunc = (prop: keyof MetaState, key: string, value: any) => void; +type UpdateStateValueFunc = ( + prop: keyof MetaState, + key: string, + value: any, +) => void; export interface MetaContextState extends MetaState { isLoading: boolean; } -const isMetadataPartOfStore = (m: ParsedAccount , store: ParsedAccount | null, whitelistedCreatorsByCreator: Record< - string, - ParsedAccount ->) => { - if(!m?.info?.data?.creators) { +const isMetadataPartOfStore = ( + m: ParsedAccount, + store: ParsedAccount | null, + whitelistedCreatorsByCreator: Record< + string, + ParsedAccount + >, +) => { + if (!m?.info?.data?.creators) { return false; } - return m.info.data.creators.findIndex( + return ( + m.info.data.creators.findIndex( c => c.verified && store && store.info && (store.info.public || - whitelistedCreatorsByCreator[c.address.toBase58()]?.info - ?.activated), - ) >= 0; -} + whitelistedCreatorsByCreator[c.address.toBase58()]?.info?.activated), + ) >= 0 + ); +}; const MetaContext = React.createContext({ metadata: [], @@ -122,6 +135,7 @@ const MetaContext = React.createContext({ editions: {}, auctionManagersByAuction: {}, auctions: {}, + auctionDataExtended: {}, vaults: {}, store: null, isLoading: false, @@ -141,13 +155,20 @@ export function MetaProvider({ children = null as any }) { metadata: [] as Array>, metadataByMint: {} as Record>, masterEditions: {} as Record>, - masterEditionsByPrintingMint: {} as Record>, - masterEditionsByOneTimeAuthMint: {} as Record>, + masterEditionsByPrintingMint: {} as Record< + string, + ParsedAccount + >, + masterEditionsByOneTimeAuthMint: {} as Record< + string, + ParsedAccount + >, metadataByMasterEdition: {} as any, editions: {}, auctionManagersByAuction: {}, bidRedemptions: {}, auctions: {}, + auctionDataExtended: {}, vaults: {}, payoutTickets: {}, store: null as ParsedAccount | null, @@ -155,7 +176,7 @@ export function MetaProvider({ children = null as any }) { bidderMetadataByAuctionAndBidder: {}, bidderPotsByAuctionAndBidder: {}, safetyDepositBoxesByVaultAndIndex: {}, - }) + }); const [isLoading, setIsLoading] = useState(true); @@ -163,11 +184,11 @@ export function MetaProvider({ children = null as any }) { async metadataByMint => { try { const m = await queryExtendedMetadata(connection, metadataByMint); - setState((current) => ({ + setState(current => ({ ...current, metadata: m.metadata, metadataByMint: m.mintToMetadata, - })) + })); } catch (er) { console.error(er); } @@ -203,6 +224,7 @@ export function MetaProvider({ children = null as any }) { auctionManagersByAuction: {}, bidRedemptions: {}, auctions: {}, + auctionDataExtended: {}, vaults: {}, payoutTickets: {}, store: null, @@ -215,11 +237,11 @@ export function MetaProvider({ children = null as any }) { const updateTemp = (prop: keyof MetaState, key: string, value: any) => { if (prop === 'store') { tempCache[prop] = value; - } else if(tempCache[prop]) { + } else if (tempCache[prop]) { const bucket = tempCache[prop] as any; bucket[key] = value as any; } - } + }; for (let i = 0; i < accounts.length; i++) { let account = accounts[i]; @@ -227,18 +249,25 @@ export function MetaProvider({ children = null as any }) { processAuctions(account, updateTemp); processMetaData(account, updateTemp); - await processMetaplexAccounts( - account, - updateTemp, - ); + await processMetaplexAccounts(account, updateTemp); } - const values = Object.values(tempCache.metadataByMint) as ParsedAccount[]; + const values = Object.values( + tempCache.metadataByMint, + ) as ParsedAccount[]; for (let i = 0; i < values.length; i++) { const metadata = values[i]; - if(isMetadataPartOfStore(metadata, tempCache.store, tempCache.whitelistedCreatorsByCreator)) { + if ( + isMetadataPartOfStore( + metadata, + tempCache.store, + tempCache.whitelistedCreatorsByCreator, + ) + ) { await metadata.info.init(); - tempCache.metadataByMasterEdition[metadata.info?.masterEdition?.toBase58() || ''] = metadata; + tempCache.metadataByMasterEdition[ + metadata.info?.masterEdition?.toBase58() || '' + ] = metadata; } else { delete tempCache.metadataByMint[metadata.info.mint.toBase58() || '']; } @@ -248,7 +277,7 @@ export function MetaProvider({ children = null as any }) { tempCache.metadata = values; setState({ ...tempCache, - }) + }); setIsLoading(false); console.log('------->set finished'); @@ -259,36 +288,34 @@ export function MetaProvider({ children = null as any }) { return () => { dispose(); }; - }, [ - connection, - setState, - updateMints, - env, - ]); + }, [connection, setState, updateMints, env]); - const updateStateValue = useMemo(() => (prop: keyof MetaState, key: string, value: any) => { - setState((current) => { - if (prop === 'store') { - return { - ...current, - [prop]: value, + const updateStateValue = useMemo( + () => (prop: keyof MetaState, key: string, value: any) => { + setState(current => { + if (prop === 'store') { + return { + ...current, + [prop]: value, + }; + } else { + return { + ...current, + [prop]: { + ...current[prop], + [key]: value, + }, + }; } - } else { - return ({ - ...current, - [prop]: { - ...current[prop], - [key]: value - } - }); - } - }); - }, [setState]); + }); + }, + [setState], + ); const store = state.store; const whitelistedCreatorsByCreator = state.whitelistedCreatorsByCreator; useEffect(() => { - if(isLoading) { + if (isLoading) { return; } @@ -341,9 +368,16 @@ export function MetaProvider({ children = null as any }) { updateStateValue, ); - if(result && isMetadataPartOfStore(result, store, whitelistedCreatorsByCreator)) { + if ( + result && + isMetadataPartOfStore(result, store, whitelistedCreatorsByCreator) + ) { await result.info.init(); - updateStateValue('metadataByMasterEdition', result.info.masterEdition?.toBase58() || '', result); + updateStateValue( + 'metadataByMasterEdition', + result.info.masterEdition?.toBase58() || '', + result, + ); } // TODO: BL @@ -366,7 +400,7 @@ export function MetaProvider({ children = null as any }) { pubkey, account: info.accountInfo, }, - updateStateValue + updateStateValue, ); }, ); @@ -425,9 +459,12 @@ export function MetaProvider({ children = null as any }) { masterEditions: state.masterEditions, auctionManagersByAuction: state.auctionManagersByAuction, auctions: state.auctions, + auctionDataExtended: state.auctionDataExtended, metadataByMint: state.metadataByMint, - safetyDepositBoxesByVaultAndIndex: state.safetyDepositBoxesByVaultAndIndex, - bidderMetadataByAuctionAndBidder: state.bidderMetadataByAuctionAndBidder, + safetyDepositBoxesByVaultAndIndex: + state.safetyDepositBoxesByVaultAndIndex, + bidderMetadataByAuctionAndBidder: + state.bidderMetadataByAuctionAndBidder, bidderPotsByAuctionAndBidder: state.bidderPotsByAuctionAndBidder, vaults: state.vaults, bidRedemptions: state.bidRedemptions, @@ -520,6 +557,21 @@ const processAuctions = ( // ignore errors // add type as first byte for easier deserialization } + + try { + if (a.account.data.length === MAX_AUCTION_DATA_EXTENDED_SIZE) { + const account = cache.add( + a.pubkey, + a.account, + AuctionDataExtendedParser, + false, + ) as ParsedAccount; + setter('auctionDataExtended', a.pubkey.toBase58(), account); + } + } catch { + // ignore errors + // add type as first byte for easier deserialization + } try { if (a.account.data.length === BIDDER_METADATA_LEN) { const account = cache.add( @@ -531,9 +583,10 @@ const processAuctions = ( setter( 'bidderMetadataByAuctionAndBidder', account.info.auctionPubkey.toBase58() + - '-' + - account.info.bidderPubkey.toBase58(), - account); + '-' + + account.info.bidderPubkey.toBase58(), + account, + ); } } catch { // ignore errors @@ -550,9 +603,10 @@ const processAuctions = ( setter( 'bidderPotsByAuctionAndBidder', account.info.auctionAct.toBase58() + - '-' + - account.info.bidderAct.toBase58(), - account); + '-' + + account.info.bidderAct.toBase58(), + account, + ); } } catch { // ignore errors @@ -586,7 +640,11 @@ const processMetaplexAccounts = async ( account: a.account, info: auctionManager, }; - setter('auctionManagersByAuction', auctionManager.auction.toBase58(), account); + setter( + 'auctionManagersByAuction', + auctionManager.auction.toBase58(), + account, + ); } } } else if (a.account.data[0] === MetaplexKey.BidRedemptionTicketV1) { @@ -640,7 +698,11 @@ const processMetaplexAccounts = async ( account.info.image = nameInfo.image; account.info.twitter = nameInfo.twitter; } - setter('whitelistedCreatorsByCreator', whitelistedCreator.address.toBase58(), account); + setter( + 'whitelistedCreatorsByCreator', + whitelistedCreator.address.toBase58(), + account, + ); } } } catch { @@ -653,7 +715,8 @@ const processMetaData = ( meta: PublicKeyAndAccount, setter: UpdateStateValueFunc, ) => { - if (meta.account.owner.toBase58() !== programIds().metadata.toBase58()) return; + if (meta.account.owner.toBase58() !== programIds().metadata.toBase58()) + return; try { if (meta.account.data[0] === MetadataKey.MetadataV1) { @@ -687,8 +750,16 @@ const processMetaData = ( info: masterEdition, }; setter('masterEditions', meta.pubkey.toBase58(), account); - setter('masterEditionsByPrintingMint', masterEdition.printingMint.toBase58(), account); - setter('masterEditionsByOneTimeAuthMint', masterEdition.oneTimePrintingAuthorizationMint.toBase58(), account); + setter( + 'masterEditionsByPrintingMint', + masterEdition.printingMint.toBase58(), + account, + ); + setter( + 'masterEditionsByOneTimeAuthMint', + masterEdition.oneTimePrintingAuthorizationMint.toBase58(), + account, + ); } } catch { // ignore errors @@ -712,7 +783,8 @@ const processVaultData = ( setter( 'safetyDepositBoxesByVaultAndIndex', safetyDeposit.vault.toBase58() + '-' + safetyDeposit.order, - account); + account, + ); } else if (a.account.data[0] === VaultKey.VaultV1) { const vault = decodeVault(a.account.data); const account: ParsedAccount = { @@ -721,10 +793,7 @@ const processVaultData = ( info: vault, }; - setter( - 'vaults', - a.pubkey.toBase58(), - account); + setter('vaults', a.pubkey.toBase58(), account); } } catch { // ignore errors diff --git a/js/packages/web/src/views/auctionCreate/index.tsx b/js/packages/web/src/views/auctionCreate/index.tsx index b88e9ac..bf0fcee 100644 --- a/js/packages/web/src/views/auctionCreate/index.tsx +++ b/js/packages/web/src/views/auctionCreate/index.tsx @@ -30,13 +30,9 @@ import { Creator, PriceFloor, PriceFloorType, + IPartialCreateAuctionArgs, } from '@oyster/common'; -import { - Connection, - LAMPORTS_PER_SOL, - PublicKey, - SystemProgram, -} from '@solana/web3.js'; +import { Connection, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; import { MintLayout } from '@solana/spl-token'; import { useHistory, useParams } from 'react-router-dom'; import { capitalize } from 'lodash'; @@ -306,14 +302,29 @@ export const AuctionCreateView = () => { console.log('Tiered settings', settings); } + const auctionSettings: IPartialCreateAuctionArgs = { + winners: winnerLimit, + endAuctionAt: new BN((attributes.auctionDuration || 0) * 60), // endAuctionAt is actually auction duration, poorly named, in seconds + auctionGap: new BN((attributes.gapTime || 0) * 60), + priceFloor: new PriceFloor({ + type: attributes.priceFloor + ? PriceFloorType.Minimum + : PriceFloorType.None, + minPrice: new BN((attributes.priceFloor || 0) * LAMPORTS_PER_SOL), + }), + tokenMint: QUOTE_MINT, + gapTickSizePercentage: attributes.tickSizeEndingPhase || null, + tickSize: attributes.priceTick + ? new BN(attributes.priceTick * LAMPORTS_PER_SOL) + : null, + }; + const _auctionObj = await createAuctionManager( connection, wallet, whitelistedCreatorsByCreator, settings, - winnerLimit, - new BN((attributes.auctionDuration || 0) * 60), // endAuctionAt is actually auction duration, poorly named, in seconds - new BN((attributes.gapTime || 0) * 60), + auctionSettings, attributes.category === AuctionCategory.Open ? [] : attributes.category !== AuctionCategory.Tiered @@ -323,12 +334,6 @@ export const AuctionCreateView = () => { ? attributes.items[0] : attributes.participationNFT, QUOTE_MINT, - new PriceFloor({ - type: attributes.priceFloor - ? PriceFloorType.Minimum - : PriceFloorType.None, - minPrice: new BN((attributes.priceFloor || 0) * LAMPORTS_PER_SOL), - }), ); setAuctionObj(_auctionObj); }; diff --git a/rust/auction/program/src/errors.rs b/rust/auction/program/src/errors.rs index c4cb3ad..9152ccf 100644 --- a/rust/auction/program/src/errors.rs +++ b/rust/auction/program/src/errors.rs @@ -122,6 +122,18 @@ pub enum AuctionError { /// Data type mismatch #[error("Data type mismatch")] DataTypeMismatch, + + /// Bid must be multiple of tick size + #[error("Bid must be multiple of tick size")] + BidMustBeMultipleOfTickSize, + + /// During the gap window, gap between next lowest bid must be of a certain percentage + #[error("During the gap window, gap between next lowest bid must be of a certain percentage")] + GapBetweenBidsTooSmall, + + /// Gap tick size percentage must be between 0 and 100 + #[error("Gap tick size percentage must be between 0 and 100")] + InvalidGapTickSizePercentage, } impl PrintProgramError for AuctionError { diff --git a/rust/auction/program/src/processor.rs b/rust/auction/program/src/processor.rs index c756b36..f873762 100644 --- a/rust/auction/program/src/processor.rs +++ b/rust/auction/program/src/processor.rs @@ -134,10 +134,10 @@ impl AuctionData { (Some(end), Some(gap)) => { // Check if the bid is within the gap between the last bidder. if let Some(last) = self.last_bid { - let next_bid_time = match last.checked_add(gap) { - Some(val) => val, - None => return Err(AuctionError::NumericalOverflowError.into()), - }; + let next_bid_time = last + .checked_add(gap) + .ok_or(AuctionError::NumericalOverflowError)?; + Ok(now > end && now > next_bid_time) } else { Ok(now > end) @@ -179,6 +179,28 @@ impl AuctionData { }; self.bid_state.winner_at(idx, minimum) } + + pub fn place_bid( + &mut self, + bid: Bid, + tick_size: Option, + gap_tick_size_percentage: Option, + now: UnixTimestamp, + ) -> Result<(), ProgramError> { + let gap_val = match self.ended_at { + Some(end) => { + // We use the actual gap tick size perc if we're in gap window, + // otherwise we pass in none so the logic isnt used + if now > end { + gap_tick_size_percentage + } else { + None + } + } + None => None, + }; + self.bid_state.place_bid(bid, tick_size, gap_val) + } } /// Define valid auction state transitions. @@ -259,59 +281,120 @@ impl BidState { real_max } + fn assert_valid_tick_size_bid(bid: &Bid, tick_size: Option) -> ProgramResult { + if let Some(tick) = tick_size { + if bid.1.checked_rem(tick) != Some(0) { + msg!( + "This bid {:?} is not a multiple of tick size {:?}, throw it out.", + bid.1, + tick_size + ); + return Err(AuctionError::BidMustBeMultipleOfTickSize.into()); + } + } else { + msg!("No tick size on this auction") + } + + Ok(()) + } + + fn assert_valid_gap_insertion( + gap_tick: u8, + beaten_bid: &Bid, + beating_bid: &Bid, + ) -> ProgramResult { + // Use u128 to avoid potential overflow due to temporary mult of 100x since + // we haven't divided yet. + let mut minimum_bid_amount: u128 = (beaten_bid.1 as u128) + .checked_mul((100 + gap_tick) as u128) + .ok_or(AuctionError::NumericalOverflowError)?; + minimum_bid_amount = minimum_bid_amount + .checked_div(100u128) + .ok_or(AuctionError::NumericalOverflowError)?; + + if minimum_bid_amount > beating_bid.1 as u128 { + msg!("Rejecting inserting this bid due to gap tick size of {:?} which causes min bid of {:?} from {:?} which is the bid it is trying to beat", gap_tick, minimum_bid_amount.to_string(), beaten_bid.1); + return Err(AuctionError::GapBetweenBidsTooSmall.into()); + } + + Ok(()) + } + /// Push a new bid into the state, this succeeds only if the bid is larger than the current top /// winner stored. Crappy list information to start with. - pub fn place_bid(&mut self, bid: Bid) -> Result<(), ProgramError> { + pub fn place_bid( + &mut self, + bid: Bid, + tick_size: Option, + gap_tick_size_percentage: Option, + ) -> Result<(), ProgramError> { + msg!("Placing bid {:?}", &bid.1.to_string()); + BidState::assert_valid_tick_size_bid(&bid, tick_size)?; + match self { // In a capped auction, track the limited number of winners. - BidState::EnglishAuction { ref mut bids, max } => match bids.last() { - Some(top) => { - msg!("Looking to go over the loop"); - for i in (0..bids.len()).rev() { - msg!("Comparison of {:?} and {:?} for {:?}", bids[i].1, bid.1, i); - if bids[i].1 < bid.1 { - msg!("Ok we can do an insert"); - if i + 1 < bids.len() { - msg!("Doing a normal insert"); - bids.insert(i + 1, bid); - } else { - msg!("Doing an on the end insert"); - bids.push(bid) - } - break; - } else if bids[i].1 == bid.1 { - msg!("Ok we can do an equivalent insert"); - if i == 0 { - msg!("Doing a normal insert"); + BidState::EnglishAuction { ref mut bids, max } => { + match bids.last() { + Some(top) => { + msg!("Looking to go over the loop, but check tick size first"); + + for i in (0..bids.len()).rev() { + msg!("Comparison of {:?} and {:?} for {:?}", bids[i].1, bid.1, i); + if bids[i].1 < bid.1 { + if let Some(gap_tick) = gap_tick_size_percentage { + BidState::assert_valid_gap_insertion(gap_tick, &bids[i], &bid)? + } + + msg!("Ok we can do an insert"); + if i + 1 < bids.len() { + msg!("Doing a normal insert"); + bids.insert(i + 1, bid); + } else { + msg!("Doing an on the end insert"); + bids.push(bid) + } + break; + } else if bids[i].1 == bid.1 { + if let Some(gap_tick) = gap_tick_size_percentage { + if gap_tick > 0 { + msg!("Rejecting same-bid insert due to gap tick size of {:?}", gap_tick); + return Err(AuctionError::GapBetweenBidsTooSmall.into()); + } + } + + msg!("Ok we can do an equivalent insert"); + if i == 0 { + msg!("Doing a normal insert"); + bids.insert(0, bid); + break; + } else { + if bids[i - 1].1 != bids[i].1 { + msg!("Doing an insert just before"); + bids.insert(i, bid); + break; + } + msg!("More duplicates ahead...") + } + } else if i == 0 { + msg!("Inserting at 0"); bids.insert(0, bid); break; - } else { - if bids[i - 1].1 != bids[i].1 { - msg!("Doing an insert just before"); - bids.insert(i, bid); - break; - } - msg!("More duplicates ahead...") } - } else if i == 0 { - msg!("Inserting at 0"); - bids.insert(0, bid); - break; } - } - let max_size = BidState::max_array_size_for(*max); + let max_size = BidState::max_array_size_for(*max); - if bids.len() > max_size { - bids.remove(0); + if bids.len() > max_size { + bids.remove(0); + } + Ok(()) + } + _ => { + msg!("Pushing bid onto stack"); + bids.push(bid); + Ok(()) } - Ok(()) } - _ => { - msg!("Pushing bid onto stack"); - bids.push(bid); - Ok(()) - } - }, + } // In an open auction, bidding simply succeeds. BidState::OpenEdition { bids, max } => Ok(()), diff --git a/rust/auction/program/src/processor/create_auction.rs b/rust/auction/program/src/processor/create_auction.rs index 60ac4ac..7841e7a 100644 --- a/rust/auction/program/src/processor/create_auction.rs +++ b/rust/auction/program/src/processor/create_auction.rs @@ -40,6 +40,10 @@ pub struct CreateAuctionArgs { pub resource: Pubkey, /// Set a price floor. pub price_floor: PriceFloor, + /// Add a tick size increment + pub tick_size: Option, + /// Add a minimum percentage increase each bid must meet. + pub gap_tick_size_percentage: Option, } struct Accounts<'a, 'b: 'a> { @@ -98,6 +102,12 @@ pub fn create_auction( WinnerLimit::Unlimited(_) => BidState::new_open_edition(), }; + if let Some(gap_tick) = args.gap_tick_size_percentage { + if gap_tick > 100 { + return Err(AuctionError::InvalidGapTickSizePercentage.into()); + } + } + // Create auction account with enough space for a winner tracking. create_or_allocate_account_raw( *program_id, @@ -141,6 +151,14 @@ pub fn create_auction( ], )?; + // Configure extended + AuctionDataExtended { + total_uncancelled_bids: 0, + tick_size: args.tick_size, + gap_tick_size_percentage: args.gap_tick_size_percentage, + } + .serialize(&mut *accounts.auction_extended.data.borrow_mut())?; + // Configure Auction. AuctionData { authority: args.authority, diff --git a/rust/auction/program/src/processor/place_bid.rs b/rust/auction/program/src/processor/place_bid.rs index cdff99f..f184f3c 100644 --- a/rust/auction/program/src/processor/place_bid.rs +++ b/rust/auction/program/src/processor/place_bid.rs @@ -316,9 +316,12 @@ pub fn place_bid<'r, 'b: 'r>( // Serialize new Auction State auction.last_bid = Some(clock.unix_timestamp); - auction - .bid_state - .place_bid(Bid(*accounts.bidder.key, args.amount))?; + auction.place_bid( + Bid(*accounts.bidder.key, args.amount), + auction_extended.tick_size, + auction_extended.gap_tick_size_percentage, + clock.unix_timestamp, + )?; auction.serialize(&mut *accounts.auction.data.borrow_mut())?; // Update latest metadata with results from the bid.