This commit is contained in:
Ahmad Sghaier 2021-06-28 21:05:03 -04:00
commit 788d20e899
46 changed files with 1230 additions and 522 deletions

View File

@ -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<Buffer>,
) => ({
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<Buffer>,
@ -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;
}
}
@ -388,6 +431,10 @@ class CancelBidArgs {
}
}
class SetAuthorityArgs {
instruction: number = 5;
}
export const AUCTION_SCHEMA = new Map<any, any>([
[
CreateAuctionArgs,
@ -402,6 +449,8 @@ export const AUCTION_SCHEMA = new Map<any, any>([
['authority', 'pubkey'],
['resource', 'pubkey'],
['priceFloor', PriceFloor],
['tickSize', { kind: 'option', type: 'u64' }],
['gapTickSizePercentage', { kind: 'option', type: 'u8' }],
],
},
],
@ -446,6 +495,14 @@ export const AUCTION_SCHEMA = new Map<any, any>([
],
},
],
[
SetAuthorityArgs,
{
kind: 'struct',
fields: [['instruction', 'u8']],
},
],
[
AuctionData,
{
@ -541,39 +598,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 +629,10 @@ export async function createAuction(
isWritable: true,
},
{
pubkey: await getAuctionExtended({ auctionProgramId, resource }),
pubkey: await getAuctionExtended({
auctionProgramId,
resource: settings.resource,
}),
isSigner: false,
isWritable: true,
},
@ -668,6 +709,42 @@ export async function startAuction(
);
}
export async function setAuctionAuthority(
auction: PublicKey,
currentAuthority: PublicKey,
newAuthority: PublicKey,
instructions: TransactionInstruction[],
) {
const auctionProgramId = programIds().auction;
const data = Buffer.from(serialize(AUCTION_SCHEMA, new SetAuthorityArgs()));
const keys = [
{
pubkey: auction,
isSigner: false,
isWritable: true,
},
{
pubkey: currentAuthority,
isSigner: true,
isWritable: false,
},
{
pubkey: newAuthority,
isSigner: false,
isWritable: false,
},
];
instructions.push(
new TransactionInstruction({
keys,
programId: auctionProgramId,
data: data,
}),
);
}
export async function placeBid(
bidderPubkey: PublicKey,
bidderTokenPubkey: PublicKey,

View File

@ -51,7 +51,12 @@ export enum MetadataCategory {
VR = 'vr',
}
type FileOrString = File | string;
export type MetadataFile = {
uri: string;
type: string;
};
export type FileOrString = MetadataFile | string;
export interface IMetadataExtension {
name: string;
@ -61,6 +66,8 @@ export interface IMetadataExtension {
description: string;
// preview image absolute URI
image: string;
animation_url?: string;
// stores link to item on meta
external_url: string;
@ -72,7 +79,6 @@ export interface IMetadataExtension {
maxSupply?: number;
creators?: {
address: string;
verified: boolean;
shares: number;
}[];
};

View File

@ -275,6 +275,42 @@ export const decodeSafetyDeposit = (buffer: Buffer) => {
) as SafetyDepositBox;
};
export async function setVaultAuthority(
vault: PublicKey,
currentAuthority: PublicKey,
newAuthority: PublicKey,
instructions: TransactionInstruction[],
) {
const vaultProgramId = programIds().vault;
const data = Buffer.from([10]);
const keys = [
{
pubkey: vault,
isSigner: false,
isWritable: true,
},
{
pubkey: currentAuthority,
isSigner: true,
isWritable: false,
},
{
pubkey: newAuthority,
isSigner: false,
isWritable: false,
},
];
instructions.push(
new TransactionInstruction({
keys,
programId: vaultProgramId,
data: data,
}),
);
}
export async function initVault(
allowFurtherShareCreation: boolean,
fractionalMint: PublicKey,

View File

@ -24,7 +24,6 @@ export async function closeVault(
redeemTreasury: PublicKey,
priceMint: PublicKey,
externalPriceAccount: PublicKey,
setAuthorityToAuctionManager: boolean,
): Promise<{
instructions: TransactionInstruction[];
signers: Keypair[];
@ -48,13 +47,6 @@ export async function closeVault(
)
)[0];
const auctionManagerKey: PublicKey = (
await findProgramAddress(
[Buffer.from(METAPLEX_PREFIX), auctionKey.toBuffer()],
PROGRAM_IDS.metaplex,
)
)[0];
await activateVault(
new BN(0),
vault,
@ -117,7 +109,7 @@ export async function closeVault(
fractionMint,
fractionTreasury,
redeemTreasury,
setAuthorityToAuctionManager ? auctionManagerKey : wallet.publicKey,
wallet.publicKey,
wallet.publicKey,
transferAuthority.publicKey,
externalPriceAccount,

View File

@ -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';
@ -49,6 +48,7 @@ import { createExternalPriceAccount } from './createExternalPriceAccount';
import { validateParticipation } from '../models/metaplex/validateParticipation';
import { createReservationListForTokens } from './createReservationListsForTokens';
import { populatePrintingTokens } from './populatePrintingTokens';
import { setVaultAndAuctionAuthorities } from './setVaultAndAuctionAuthorities';
const { createTokenAccount } = actions;
interface normalPattern {
@ -73,6 +73,7 @@ interface byType {
makeAuction: normalPattern;
initAuctionManager: normalPattern;
startAuction: normalPattern;
setVaultAndAuctionAuthorities: normalPattern;
externalPriceAccount: normalPattern;
validateParticipation?: normalPattern;
buildAndPopulateOneTimeAuthorizationAccount?: normalPattern;
@ -100,13 +101,10 @@ export async function createAuctionManager(
ParsedAccount<WhitelistedCreator>
>,
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 +134,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(
@ -212,7 +202,6 @@ export async function createAuctionManager(
redeemTreasury,
priceMint,
externalPriceAccount,
true,
),
addTokens: { instructions: addTokenInstructions, signers: addTokenSigners },
createReservationList: {
@ -227,6 +216,12 @@ export async function createAuctionManager(
instructions: auctionManagerInstructions,
signers: auctionManagerSigners,
},
setVaultAndAuctionAuthorities: await setVaultAndAuctionAuthorities(
wallet,
vault,
auction,
auctionManager,
),
startAuction: await setupStartAuction(wallet, vault),
validateParticipation: participationSafetyDepositDraft
? await validateParticipationHelper(
@ -277,6 +272,7 @@ export async function createAuctionManager(
lookup.closeVault.signers,
lookup.makeAuction.signers,
lookup.initAuctionManager.signers,
lookup.setVaultAndAuctionAuthorities.signers,
lookup.validateParticipation?.signers || [],
...lookup.validateBoxes.signers,
lookup.startAuction.signers,
@ -292,6 +288,7 @@ export async function createAuctionManager(
lookup.closeVault.instructions,
lookup.makeAuction.instructions,
lookup.initAuctionManager.instructions,
lookup.setVaultAndAuctionAuthorities.instructions,
lookup.validateParticipation?.instructions || [],
...lookup.validateBoxes.instructions,
lookup.startAuction.instructions,

View File

@ -1,5 +1,10 @@
import { Keypair, Connection, TransactionInstruction } from '@solana/web3.js';
import { sendTransactionsWithManualRetry, TokenAccount } from '@oyster/common';
import {
sendTransactionsWithManualRetry,
setAuctionAuthority,
setVaultAuthority,
TokenAccount,
} from '@oyster/common';
import { AuctionView } from '../hooks';
import { AuctionManagerStatus } from '../models/metaplex';
@ -21,10 +26,28 @@ export async function decommAuctionManagerAndReturnPrizes(
) {
let decomSigners: Keypair[] = [];
let decomInstructions: TransactionInstruction[] = [];
if (auctionView.auction.info.authority.equals(wallet.publicKey)) {
await setAuctionAuthority(
auctionView.auction.pubkey,
wallet.publicKey,
auctionView.auctionManager.pubkey,
decomInstructions,
);
}
if (auctionView.vault.info.authority.equals(wallet.publicKey)) {
await setVaultAuthority(
auctionView.vault.pubkey,
wallet.publicKey,
auctionView.auctionManager.pubkey,
decomInstructions,
);
}
await decommissionAuctionManager(
auctionView.auctionManager.pubkey,
auctionView.auction.pubkey,
wallet.publicKey,
auctionView.vault.pubkey,
decomInstructions,
);
signers.push(decomSigners);

View File

@ -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[];
@ -40,24 +35,13 @@ export async function makeAuction(
)
)[0];
const auctionManagerKey: PublicKey = (
await findProgramAddress(
[Buffer.from(METAPLEX_PREFIX), auctionKey.toBuffer()],
PROGRAM_IDS.metaplex,
)
)[0];
const fullSettings = new CreateAuctionArgs({
...auctionSettings,
authority: wallet.publicKey,
resource: vault,
});
createAuction(
winnerLimit,
vault,
endAuctionAt,
auctionGap,
priceFloor,
paymentMint,
auctionManagerKey,
wallet.publicKey,
instructions,
);
createAuction(fullSettings, wallet.publicKey, instructions);
return { instructions, signers, auction: auctionKey };
}

View File

@ -11,6 +11,7 @@ import {
Data,
Creator,
findProgramAddress,
MetadataCategory,
} from '@oyster/common';
import React from 'react';
import { MintLayout, Token } from '@solana/spl-token';
@ -48,6 +49,7 @@ export const mintNFT = async (
symbol: string;
description: string;
image: string | undefined;
animation_url: string | undefined;
external_url: string;
properties: any;
creators: Creator[] | null;
@ -60,28 +62,31 @@ export const mintNFT = async (
if (!wallet?.publicKey) {
return;
}
const metadataContent = {
name: metadata.name,
symbol: metadata.symbol,
description: metadata.description,
seller_fee_basis_points: metadata.sellerFeeBasisPoints,
image: metadata.image,
animation_url: metadata.animation_url,
external_url: metadata.external_url,
properties: {
...metadata.properties,
creators: metadata.creators?.map(creator => {
return {
address: creator.address.toBase58(),
share: creator.share,
};
}),
},
};
const realFiles: File[] = [
...files,
new File(
[
JSON.stringify({
name: metadata.name,
symbol: metadata.symbol,
description: metadata.description,
seller_fee_basis_points: metadata.sellerFeeBasisPoints,
image: metadata.image,
external_url: metadata.external_url,
properties: {
...metadata.properties,
creators: metadata.creators?.map(creator => {
return {
address: creator.address.toBase58(),
verified: creator.verified,
share: creator.share,
};
}),
},
}),
JSON.stringify(metadataContent),
],
'metadata.json',
),
@ -139,22 +144,11 @@ export const mintNFT = async (
mintKey,
);
instructions.push(
Token.createMintToInstruction(
TOKEN_PROGRAM_ID,
mintKey,
recipientKey,
payerPublicKey,
[],
1,
),
);
const metadataAccount = await createMetadata(
new Data({
symbol: metadata.symbol,
name: metadata.name,
uri: `https://-------.---/rfX69WKd7Bin_RTbcnH4wM3BuWWsR_ZhWSSqZBLYdMY`,
uri: ' '.repeat(64), // size of url for arweave
sellerFeeBasisPoints: metadata.sellerFeeBasisPoints,
creators: metadata.creators,
}),
@ -212,8 +206,8 @@ export const mintNFT = async (
await fetch(
// TODO: add CNAME
env.startsWith('mainnet-beta')
? 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFileProd-1'
: 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile-1',
? 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFileProd2'
: 'https://us-central1-principal-lane-200702.cloudfunctions.net/uploadFile2',
{
method: 'POST',
body: data,
@ -246,7 +240,18 @@ export const mintNFT = async (
metadataAccount,
);
// // This mint, which allows limited editions to be made, stays with user's wallet.
updateInstructions.push(
Token.createMintToInstruction(
TOKEN_PROGRAM_ID,
mintKey,
recipientKey,
payerPublicKey,
[],
1,
),
);
// This mint, which allows limited editions to be made, stays with user's wallet.
const printingMint = createMint(
updateInstructions,
payerPublicKey,

View File

@ -0,0 +1,31 @@
import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js';
import { setAuctionAuthority, setVaultAuthority } from '@oyster/common';
// This command sets the authorities on the vault and auction to be the newly created auction manager.
export async function setVaultAndAuctionAuthorities(
wallet: any,
vault: PublicKey,
auction: PublicKey,
auctionManager: PublicKey,
): Promise<{
instructions: TransactionInstruction[];
signers: Keypair[];
}> {
let signers: Keypair[] = [];
let instructions: TransactionInstruction[] = [];
await setAuctionAuthority(
auction,
wallet.publicKey,
auctionManager,
instructions,
);
await setVaultAuthority(
vault,
wallet.publicKey,
auctionManager,
instructions,
);
return { instructions, signers };
}

View File

@ -53,7 +53,6 @@ export async function unwindVault(
vault.info.redeemTreasury,
decoded.priceMint,
vault.info.pricingLookupAddress,
false,
);
signers.push(cvSigners);

View File

@ -51,7 +51,7 @@
font-size: 20px;
font-weight: 600;
padding: 24px 24px 0 24px;
min-height: 120px;
min-height: 55px;
}
.ant-avatar.ant-avatar-circle {

View File

@ -12,9 +12,12 @@ const { Meta } = Card;
export interface ArtCardProps extends CardProps {
pubkey?: PublicKey;
image?: string;
file?: string;
animationURL?: string;
category?: MetadataCategory;
name?: string;
symbol?: string;
description?: string;
@ -22,7 +25,7 @@ export interface ArtCardProps extends CardProps {
preview?: boolean;
small?: boolean;
close?: () => void;
endAuctionAt?: number;
height?: number;
width?: number;
}
@ -33,7 +36,7 @@ export const ArtCard = (props: ArtCardProps) => {
small,
category,
image,
file,
animationURL,
name,
preview,
creators,
@ -79,8 +82,8 @@ export const ArtCard = (props: ArtCardProps) => {
<ArtContent
pubkey={pubkey}
extension={file || image}
uri={image}
animationURL={animationURL}
category={category}
preview={preview}

View File

@ -1,25 +1,27 @@
import React, { Ref, useCallback, useEffect, useRef, useState } from 'react';
import { Image } from 'antd';
import { MetadataCategory } from '@oyster/common';
import { MetadataCategory, MetadataFile } from '@oyster/common';
import { MeshViewer } from '../MeshViewer';
import { ThreeDots } from '../MyLoader';
import { useCachedImage, useExtendedArt } from '../../hooks';
import { Stream, StreamPlayerApi } from '@cloudflare/stream-react';
import { useInView } from 'react-intersection-observer';
import { PublicKey } from '@solana/web3.js';
import { getLast } from '../../utils/utils';
const MeshArtContent = ({
uri,
animationUrl,
className,
style,
files,
}: {
uri?: string;
animationUrl?: string;
className?: string;
style?: React.CSSProperties;
files?: string[];
files?: (MetadataFile | string)[];
}) => {
const renderURL = files && files.length > 0 ? files[0] : uri;
const renderURL = files && files.length > 0 && typeof files[0] === 'string' ? files[0] : animationUrl;
const { isLoading } = useCachedImage(renderURL || '', true);
if (isLoading) {
@ -62,16 +64,18 @@ const CachedImageContent = ({
}
const VideoArtContent = ({
extension,
className,
style,
files,
uri,
animationURL,
active,
}: {
extension?: string;
className?: string;
style?: React.CSSProperties;
files?: string[];
files?: (MetadataFile | string)[];
uri?: string;
animationURL?: string;
active?: boolean;
}) => {
const [playerApi, setPlayerApi] = useState<StreamPlayerApi>();
@ -90,9 +94,13 @@ const VideoArtContent = ({
}, [active, playerApi]);
const likelyVideo = (files || []).filter((f, index, arr) => {
if(typeof f !== 'string') {
return false;
}
// TODO: filter by fileType
return arr.length >= 2 ? index === 1 : index === 0;
})[0];
})?.[0] as string;
const content = (
likelyVideo && likelyVideo.startsWith('https://watch.videodelivery.net/') ? (
@ -116,15 +124,17 @@ const VideoArtContent = ({
<video
className={className}
playsInline={true}
autoPlay={false}
autoPlay={true}
muted={true}
controls={true}
controlsList="nodownload"
style={style}
loop={true}
poster={extension}
poster={uri}
>
<source src={likelyVideo} type="video/mp4" style={style} />
{likelyVideo && <source src={likelyVideo} type="video/mp4" style={style} />}
{animationURL && <source src={animationURL} type="video/mp4" style={style} />}
{files?.filter(f => typeof f !== 'string').map((f: any) => <source src={f.uri} type={f.type} style={style} />)}
</video>
)
);
@ -145,7 +155,7 @@ export const ArtContent = ({
pubkey,
uri,
extension,
animationURL,
files,
}: {
category?: MetadataCategory;
@ -158,24 +168,29 @@ export const ArtContent = ({
active?: boolean;
allowMeshRender?: boolean;
pubkey?: PublicKey | string,
extension?: string;
uri?: string;
files?: string[];
animationURL?: string;
files?: (MetadataFile | string)[];
}) => {
const id = typeof pubkey === 'string' ? pubkey : pubkey?.toBase58() || '';
const { ref, data } = useExtendedArt(id);
if(pubkey && data) {
files = data.properties.files?.filter(f => typeof f === 'string') as string[];
files = data.properties.files;
uri = data.image;
animationURL = data.animation_url;
category = data.properties.category;
}
if (allowMeshRender&& (extension?.endsWith('.glb') || category === 'vr')) {
animationURL = animationURL || '';
const animationUrlExt = new URLSearchParams(getLast(animationURL.split("?"))).get("ext");
if (allowMeshRender && (category === 'vr' || animationUrlExt === 'glb' || animationUrlExt === 'gltf')) {
return <MeshArtContent
uri={uri}
animationUrl={animationURL}
className={className}
style={style}
files={files}/>;
@ -183,10 +198,11 @@ export const ArtContent = ({
const content = category === 'video' ? (
<VideoArtContent
extension={extension}
className={className}
style={style}
files={files}
uri={uri}
animationURL={animationURL}
active={active}
/>
) : (

View File

@ -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<AuctionDataExtended> | undefined {
const [auctionExtended, setAuctionExtended] =
useState<ParsedAccount<AuctionDataExtended>>();
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 (
<div className="auction-container" style={style}>
@ -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 && (
<span>
Additionally, once the official auction end time has
passed, only bids {gapTick}% larger than an existing
bid will be accepted.
</span>
)}
</div>
)}
<br />
<AuctionNumbers auctionView={auctionView} />
<br />
{tickSizeInvalid && tickSize && (
<span style={{ color: 'red' }}>
Tick size is {tickSize.toNumber() / LAMPORTS_PER_SOL}.
</span>
)}
{gapBidInvalid && (
<span style={{ color: 'red' }}>
Your bid needs to be at least {gapTick}% larger than an
existing bid during gap periods to be eligible.
</span>
)}
<div
style={{
@ -333,6 +437,8 @@ export const AuctionCard = ({
className="action-btn"
onClick={placeBid}
disabled={
tickSizeInvalid ||
gapBidInvalid ||
!myPayingAccount ||
value === undefined ||
value * LAMPORTS_PER_SOL < priceFloor ||

View File

@ -92,7 +92,7 @@
font-weight: 600;
padding: 24px 24px 0px 24px;
white-space: normal !important;
min-height: 120px;
min-height: 55px;
}
.ant-avatar.ant-avatar-circle,

View File

@ -11,10 +11,7 @@ import {
useWallet,
VaultState,
} from '@oyster/common';
import {
Connection,
PublicKey,
} from '@solana/web3.js';
import { Connection, PublicKey } from '@solana/web3.js';
import { Badge, Popover, List } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
@ -26,10 +23,7 @@ import { settle } from '../../actions/settle';
import { QUOTE_MINT } from '../../constants';
import { useMeta } from '../../contexts';
import {
AuctionViewState,
useAuctions,
} from '../../hooks';
import { AuctionViewState, useAuctions } from '../../hooks';
import './index.less';
import { WalletAdapter } from '@solana/wallet-base';
interface NotificationCard {
@ -180,7 +174,7 @@ export function useSettlementAuctions({
notifications: NotificationCard[];
}) {
const { accountByMint } = useUserAccounts();
const walletPubkey = wallet?.publicKey?.toBase58() || '';
const walletPubkey = wallet?.publicKey;
const { bidderPotsByAuctionAndBidder } = useMeta();
const auctionsNeedingSettling = useAuctions(AuctionViewState.Ended);
@ -191,7 +185,8 @@ export function useSettlementAuctions({
const nextBatch = auctionsNeedingSettling
.filter(
a =>
a.auctionManager.info.authority.toBase58() === walletPubkey &&
walletPubkey &&
a.auctionManager.info.authority.equals(walletPubkey) &&
a.auction.info.ended(),
)
.sort(
@ -270,7 +265,6 @@ export function useSettlementAuctions({
myPayingAccount?.pubkey,
accountByMint,
);
const PROGRAM_IDS = programIds();
if (wallet?.publicKey) {
const ata = await getPersonalEscrowAta(wallet);
if (ata) await closePersonalEscrow(connection, wallet, ata);

View File

@ -6,16 +6,18 @@ import { AuctionView, useArt } from '../../hooks';
import { ArtContent } from '../ArtContent';
import { AuctionCard } from '../AuctionCard';
import { Link } from 'react-router-dom';
import { useMeta } from '../../contexts';
interface IPreSaleBanner {
auction?: AuctionView;
}
export const PreSaleBanner = ({ auction }: IPreSaleBanner) => {
const { isLoading } = useMeta();
const id = auction?.thumbnail.metadata.pubkey;
const art = useArt();
if (!auction) {
if (isLoading) {
return <Skeleton />;
}
@ -31,7 +33,7 @@ export const PreSaleBanner = ({ auction }: IPreSaleBanner) => {
<h2 className="art-title">
{art.title}
</h2>
<AuctionCard
{auction && <AuctionCard
auctionView={auction}
style={{
background: 'transparent',
@ -54,7 +56,7 @@ export const PreSaleBanner = ({ auction }: IPreSaleBanner) => {
</Link>
</>
}
/>
/>}
</Col>
</Row>
);

View File

@ -22,5 +22,12 @@
"image": "https://pbs.twimg.com/profile_images/1393353972371623938/ZMWvvptg_400x400.jpg",
"description": "",
"background": ""
},
"SoL351y4uKWtbH14AU1Rhiao96aBM4u57bMi5Vj2XJc": {
"name": "Solana",
"image": "https://pbs.twimg.com/profile_images/1299400345144049665/sPxnVXa7_400x400.jpg",
"description": "Account used by Solana to mint official NFTs.",
"background": ""
}
}

View File

@ -27,7 +27,9 @@ import {
Vault,
setProgramIds,
useConnectionConfig,
useWallet,
AuctionDataExtended,
MAX_AUCTION_DATA_EXTENDED_SIZE,
AuctionDataExtendedParser,
} from '@oyster/common';
import { MintInfo } from '@solana/spl-token';
import { Connection, PublicKey, PublicKeyAndAccount } from '@solana/web3.js';
@ -67,6 +69,7 @@ interface MetaState {
masterEditionsByOneTimeAuthMint: Record<string, ParsedAccount<MasterEdition>>;
auctionManagersByAuction: Record<string, ParsedAccount<AuctionManager>>;
auctions: Record<string, ParsedAccount<AuctionData>>;
auctionDataExtended: Record<string, ParsedAccount<AuctionDataExtended>>;
vaults: Record<string, ParsedAccount<Vault>>;
store: ParsedAccount<Store> | null;
bidderMetadataByAuctionAndBidder: Record<
@ -88,29 +91,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<Metadata> , store: ParsedAccount<Store> | null, whitelistedCreatorsByCreator: Record<
string,
ParsedAccount<WhitelistedCreator>
>) => {
if(!m?.info?.data?.creators) {
const isMetadataPartOfStore = (
m: ParsedAccount<Metadata>,
store: ParsedAccount<Store> | null,
whitelistedCreatorsByCreator: Record<
string,
ParsedAccount<WhitelistedCreator>
>,
) => {
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<MetaContextState>({
metadata: [],
@ -122,6 +134,7 @@ const MetaContext = React.createContext<MetaContextState>({
editions: {},
auctionManagersByAuction: {},
auctions: {},
auctionDataExtended: {},
vaults: {},
store: null,
isLoading: false,
@ -141,13 +154,20 @@ export function MetaProvider({ children = null as any }) {
metadata: [] as Array<ParsedAccount<Metadata>>,
metadataByMint: {} as Record<string, ParsedAccount<Metadata>>,
masterEditions: {} as Record<string, ParsedAccount<MasterEdition>>,
masterEditionsByPrintingMint: {} as Record<string, ParsedAccount<MasterEdition>>,
masterEditionsByOneTimeAuthMint: {} as Record<string, ParsedAccount<MasterEdition>>,
masterEditionsByPrintingMint: {} as Record<
string,
ParsedAccount<MasterEdition>
>,
masterEditionsByOneTimeAuthMint: {} as Record<
string,
ParsedAccount<MasterEdition>
>,
metadataByMasterEdition: {} as any,
editions: {},
auctionManagersByAuction: {},
bidRedemptions: {},
auctions: {},
auctionDataExtended: {},
vaults: {},
payoutTickets: {},
store: null as ParsedAccount<Store> | null,
@ -155,7 +175,7 @@ export function MetaProvider({ children = null as any }) {
bidderMetadataByAuctionAndBidder: {},
bidderPotsByAuctionAndBidder: {},
safetyDepositBoxesByVaultAndIndex: {},
})
});
const [isLoading, setIsLoading] = useState(true);
@ -163,11 +183,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 +223,7 @@ export function MetaProvider({ children = null as any }) {
auctionManagersByAuction: {},
bidRedemptions: {},
auctions: {},
auctionDataExtended: {},
vaults: {},
payoutTickets: {},
store: null,
@ -215,11 +236,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 +248,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<Metadata>[];
const values = Object.values(
tempCache.metadataByMint,
) as ParsedAccount<Metadata>[];
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 +276,7 @@ export function MetaProvider({ children = null as any }) {
tempCache.metadata = values;
setState({
...tempCache,
})
});
setIsLoading(false);
console.log('------->set finished');
@ -259,36 +287,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,16 +367,24 @@ 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);
setState((data) => ({
...data,
metadata: [...data.metadata.filter(m => m.pubkey.equals(pubkey)), result],
metadataByMasterEdition: {
...data.metadataByMasterEdition,
[result.info.masterEdition?.toBase58() || '']: result,
},
metadataByMint: {
...data.metadataByMint,
[result.info.mint.toBase58()]: result
}
}));
}
// TODO: BL
// setMetadataByMint(latest => {
// updateMints(latest);
// return latest;
// });
},
);
@ -366,7 +400,7 @@ export function MetaProvider({ children = null as any }) {
pubkey,
account: info.accountInfo,
},
updateStateValue
updateStateValue,
);
},
);
@ -380,6 +414,7 @@ export function MetaProvider({ children = null as any }) {
}, [
connection,
updateStateValue,
setState,
updateMints,
store,
whitelistedCreatorsByCreator,
@ -425,9 +460,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,
@ -466,7 +504,7 @@ const queryExtendedMetadata = async (
MintParser,
false,
) as ParsedAccount<MintInfo>;
if (mint.info.supply.gt(new BN(1)) || mint.info.decimals !== 0) {
if (!mint.info.supply.eqn(1) || mint.info.decimals !== 0) {
// naive not NFT check
delete mintToMetadata[key];
} else {
@ -520,6 +558,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<AuctionDataExtended>;
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 +584,10 @@ const processAuctions = (
setter(
'bidderMetadataByAuctionAndBidder',
account.info.auctionPubkey.toBase58() +
'-' +
account.info.bidderPubkey.toBase58(),
account);
'-' +
account.info.bidderPubkey.toBase58(),
account,
);
}
} catch {
// ignore errors
@ -550,9 +604,10 @@ const processAuctions = (
setter(
'bidderPotsByAuctionAndBidder',
account.info.auctionAct.toBase58() +
'-' +
account.info.bidderAct.toBase58(),
account);
'-' +
account.info.bidderAct.toBase58(),
account,
);
}
} catch {
// ignore errors
@ -586,7 +641,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 +699,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 +716,8 @@ const processMetaData = (
meta: PublicKeyAndAccount<Buffer>,
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 +751,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 +784,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<Vault> = {
@ -721,10 +794,7 @@ const processVaultData = (
info: vault,
};
setter(
'vaults',
a.pubkey.toBase58(),
account);
setter('vaults', a.pubkey.toBase58(), account);
}
} catch {
// ignore errors

View File

@ -12,6 +12,7 @@ export async function decommissionAuctionManager(
auctionManager: PublicKey,
auction: PublicKey,
authority: PublicKey,
vault: PublicKey,
instructions: TransactionInstruction[],
) {
const PROGRAM_IDS = programIds();
@ -41,6 +42,11 @@ export async function decommissionAuctionManager(
isWritable: false,
},
{
pubkey: vault,
isSigner: false,
isWritable: false,
},
{
pubkey: store,
isSigner: false,

View File

@ -1,3 +1,15 @@
export const cleanName = (name: string): string => {
export const cleanName = (name?: string): string | undefined => {
if (!name) {
return undefined;
}
return name.replaceAll(' ', '-');
};
export const getLast = <T>(arr: T[]) => {
if (arr.length <= 0) {
return undefined;
}
return arr[arr.length - 1];
};

View File

@ -5,7 +5,7 @@ import { useArt, useExtendedArt } from './../../hooks';
import './index.less';
import { ArtContent } from '../../components/ArtContent';
import { shortenAddress, useConnection, useWallet } from '@oyster/common';
import { shortenAddress, TokenAccount, useConnection, useUserAccounts, useWallet } from '@oyster/common';
import { MetaAvatar } from '../../components/MetaAvatar';
import { sendSignMetadata } from '../../actions/sendSignMetadata';
import { PublicKey } from '@solana/web3.js';
@ -15,10 +15,18 @@ const { Content } = Layout;
export const ArtView = () => {
const { id } = useParams<{ id: string }>();
const { wallet } = useWallet();
const connection = useConnection();
const art = useArt(id);
const { ref, data } = useExtendedArt(id);
// const { userAccounts } = useUserAccounts();
// const accountByMint = userAccounts.reduce((prev, acc) => {
// prev.set(acc.info.mint.toBase58(), acc);
// return prev;
// }, new Map<string, TokenAccount>());
const description = data?.description;
const pubkey = wallet?.publicKey?.toBase58() || '';
@ -55,6 +63,7 @@ export const ArtView = () => {
className="artwork-image"
pubkey={id}
active={true}
allowMeshRender={true}
/>
</Col>
{/* <Divider /> */}
@ -79,6 +88,31 @@ export const ArtView = () => {
<span className="creator-name">
{creator.name || shortenAddress(creator.address || '')}
</span>
{/* <Button
onClick={async () => {
if(!art.mint) {
return;
}
const mint = new PublicKey(art.mint);
const account = accountByMint.get(art.mint);
if(!account) {
return;
}
const owner = wallet?.publicKey;
if(!owner) {
return;
}
const instructions: any[] = [];
await updateMetadata(undefined, undefined, true, mint, owner, instructions)
sendTransaction(connection, wallet, instructions, [], true);
}}
>
Mark as Sold
</Button> */}
<div style={{ marginLeft: 10 }}>
{!creator.verified &&
(creator.address === pubkey ? (

View File

@ -30,12 +30,13 @@ import {
shortenAddress,
MetaplexModal,
MetaplexOverlay,
MetadataFile,
} from '@oyster/common';
import { getAssetCostToStore, LAMPORT_MULTIPLIER } from '../../utils/assets';
import { Connection, PublicKey } from '@solana/web3.js';
import { MintLayout } from '@solana/spl-token';
import { useHistory, useParams } from 'react-router-dom';
import { cleanName } from '../../utils/utils';
import { cleanName, getLast } from '../../utils/utils';
import { AmountLabel } from '../../components/AmountLabel';
import useWindowDimensions from '../../utils/layout';
@ -56,12 +57,14 @@ export const ArtCreateView = () => {
const [progress, setProgress] = useState<number>(0);
const [nft, setNft] =
useState<{ metadataAccount: PublicKey } | undefined>(undefined);
const [files, setFiles] = useState<File[]>([]);
const [attributes, setAttributes] = useState<IMetadataExtension>({
name: '',
symbol: '',
description: '',
external_url: '',
image: '',
animation_url: undefined,
seller_fee_basis_points: 0,
creators: [],
properties: {
@ -85,22 +88,17 @@ export const ArtCreateView = () => {
// store files
const mint = async () => {
const fileNames = (attributes?.properties?.files || []).map(f =>
typeof f === 'string' ? f : f.name,
);
const files = (attributes?.properties?.files || []).filter(
f => typeof f !== 'string',
) as File[];
const metadata = {
name: attributes.name,
symbol: attributes.symbol,
creators: attributes.creators,
description: attributes.description,
sellerFeeBasisPoints: attributes.seller_fee_basis_points,
image: fileNames && fileNames?.[0] && fileNames[0],
image: attributes.image,
animation_url: attributes.animation_url,
external_url: attributes.external_url,
properties: {
files: fileNames,
files: attributes.properties.files,
category: attributes.properties?.category,
},
};
@ -165,6 +163,8 @@ export const ArtCreateView = () => {
<UploadStep
attributes={attributes}
setAttributes={setAttributes}
files={files}
setFiles={setFiles}
confirm={() => gotoStep(2)}
/>
)}
@ -172,6 +172,7 @@ export const ArtCreateView = () => {
{step === 2 && (
<InfoStep
attributes={attributes}
files={files}
setAttributes={setAttributes}
confirm={() => gotoStep(3)}
/>
@ -186,6 +187,7 @@ export const ArtCreateView = () => {
{step === 4 && (
<LaunchStep
attributes={attributes}
files={files}
confirm={() => gotoStep(5)}
connection={connection}
/>
@ -283,14 +285,16 @@ const CategoryStep = (props: {
const UploadStep = (props: {
attributes: IMetadataExtension;
setAttributes: (attr: IMetadataExtension) => void;
files: File[],
setFiles: (files: File[]) => void,
confirm: () => void;
}) => {
const [mainFile, setMainFile] = useState<any>();
const [coverFile, setCoverFile] = useState<any>();
const [image, setImage] = useState<string>('');
const [imageURL, setImageURL] = useState<string>('');
const [imageURLErr, setImageURLErr] = useState<string>('');
const disableContinue = (!mainFile && !image) || !!imageURLErr;
const [coverFile, setCoverFile] = useState<File | undefined>(props.files?.[0]);
const [mainFile, setMainFile] = useState<File | undefined>(props.files?.[1]);
const [customURL, setCustomURL] = useState<string>('');
const [customURLErr, setCustomURLErr] = useState<string>('');
const disableContinue = (!coverFile) || !!customURLErr;
useEffect(() => {
props.setAttributes({
@ -344,46 +348,63 @@ const UploadStep = (props: {
very first time.
</p>
</Row>
<Row className="content-action" style={{ marginBottom: 5 }}>
<h3>{uploadMsg(props.attributes.properties?.category)}</h3>
<Dragger
accept={acceptableFiles(props.attributes.properties?.category)}
style={{ padding: 20, background: 'rgba(255, 255, 255, 0.08)' }}
multiple={false}
customRequest={info => {
// dont upload files here, handled outside of the control
info?.onSuccess?.({}, null as any);
}}
fileList={mainFile ? [mainFile] : []}
onChange={async info => {
const file = info.file.originFileObj;
<Row className="content-action">
<h3>
Upload a cover image (PNG, JPG, GIF)
</h3>
<Dragger
accept=".png,.jpg,.gif,.mp4"
style={{ padding: 20 }}
multiple={false}
customRequest={info => {
// dont upload files here, handled outside of the control
info?.onSuccess?.({}, null as any);
}}
fileList={coverFile ? [coverFile as any] : []}
onChange={async info => {
const file = info.file.originFileObj;
if (file) setCoverFile(file);
}}
>
<div className="ant-upload-drag-icon">
<h3 style={{ fontWeight: 700 }}>
Upload your cover image (PNG, JPG, GIF)
</h3>
</div>
<p className="ant-upload-text">Drag and drop, or click to browse</p>
</Dragger>
</Row>
{(props.attributes.properties?.category !== MetadataCategory.Image) && (
<Row className="content-action" style={{ marginBottom: 5, marginTop: 30 }}>
<h3>{uploadMsg(props.attributes.properties?.category)}</h3>
<Dragger
accept={acceptableFiles(props.attributes.properties?.category)}
style={{ padding: 20, background: 'rgba(255, 255, 255, 0.08)' }}
multiple={false}
customRequest={info => {
// dont upload files here, handled outside of the control
info?.onSuccess?.({}, null as any);
}}
fileList={mainFile ? [mainFile as any] : []}
onChange={async info => {
const file = info.file.originFileObj;
// Reset image URL
setImageURL('');
setImageURLErr('');
// Reset image URL
setCustomURL('');
setCustomURLErr('');
if (file) setMainFile(file);
if (
props.attributes.properties?.category !== MetadataCategory.Audio
) {
const reader = new FileReader();
reader.onload = function (event) {
setImage((event.target?.result as string) || '');
};
if (file) reader.readAsDataURL(file);
}
}}
onRemove={() => {
setMainFile(null);
setImage('');
}}
>
<div className="ant-upload-drag-icon">
<h3 style={{ fontWeight: 700 }}>Upload your creation</h3>
</div>
<p className="ant-upload-text">Drag and drop, or click to browse</p>
</Dragger>
</Row>
if (file) setMainFile(file);
}}
onRemove={() => {
setMainFile(undefined);
}}
>
<div className="ant-upload-drag-icon">
<h3 style={{ fontWeight: 700 }}>Upload your creation</h3>
</div>
<p className="ant-upload-text">Drag and drop, or click to browse</p>
</Dragger>
</Row>)}
<Form.Item
style={{
width: '100%',
@ -394,72 +415,33 @@ const UploadStep = (props: {
label={<h3>OR use absolute URL to content</h3>}
labelAlign="left"
colon={false}
validateStatus={imageURLErr ? 'error' : 'success'}
help={imageURLErr}
validateStatus={customURLErr ? 'error' : 'success'}
help={customURLErr}
>
<Input
disabled={!!mainFile}
placeholder="http://example.com/path/to/image"
value={imageURL}
onChange={ev => setImageURL(ev.target.value)}
onFocus={() => setImageURLErr('')}
value={customURL}
onChange={ev => setCustomURL(ev.target.value)}
onFocus={() => setCustomURLErr('')}
onBlur={() => {
if (!imageURL) {
setImageURLErr('');
if (!customURL) {
setCustomURLErr('');
return;
}
try {
// Validate URL and save
new URL(imageURL);
setImage(imageURL);
new URL(customURL);
setCustomURL(customURL);
setCustomURLErr('');
} catch (e) {
console.error(e);
setImageURLErr('Please enter a valid absolute URL');
setCustomURLErr('Please enter a valid absolute URL');
}
}}
/>
</Form.Item>
{props.attributes.properties?.category === MetadataCategory.Audio && (
<Row className="content-action">
<h3>
Optionally, you can upload a cover image or video (PNG, JPG, GIF,
MP4)
</h3>
<Dragger
accept=".png,.jpg,.gif,.mp4"
style={{ padding: 20 }}
multiple={false}
customRequest={info => {
// dont upload files here, handled outside of the control
info?.onSuccess?.({}, null as any);
}}
fileList={coverFile ? [coverFile] : []}
onChange={async info => {
const file = info.file.originFileObj;
if (file) setCoverFile(file);
if (
props.attributes.properties?.category === MetadataCategory.Audio
) {
const reader = new FileReader();
reader.onload = function (event) {
setImage((event.target?.result as string) || '');
};
if (file) reader.readAsDataURL(file);
}
}}
>
<div className="ant-upload-drag-icon">
<h3 style={{ fontWeight: 700 }}>
Upload your cover image or video (PNG, JPG, GIF, MP4)
</h3>
</div>
<p className="ant-upload-text">Drag and drop, or click to browse</p>
</Dragger>
<h3 style={{ marginTop: 30 }}>OR use absolute URL to content</h3>
<Input />
</Row>
)}
<Row>
<Button
type="primary"
@ -470,16 +452,24 @@ const UploadStep = (props: {
...props.attributes,
properties: {
...props.attributes.properties,
files: imageURL
? [imageURL]
: [mainFile, coverFile]
files: [coverFile, mainFile, customURL]
.filter(f => f)
.map(
f => new File([f], cleanName(f.name), { type: f.type }),
f => {
const uri = typeof f === 'string' ? f : (cleanName(f?.name) || '');
const type = typeof f === 'string' || !f ? 'unknown' : f.type || (getLast(f.name.split('.')) || 'unknown');
return ({
uri,
type
}) as MetadataFile;
},
),
},
image: imageURL || image,
image: cleanName(coverFile?.name) || '',
animation_url: cleanName(mainFile && mainFile.name),
});
props.setFiles([coverFile, mainFile].filter(f => f) as File[]);
props.confirm();
}}
style={{ marginTop: 24 }}
@ -497,17 +487,55 @@ interface Royalty {
amount: number;
}
const useArtworkFiles = (files: File[], attributes: IMetadataExtension) => {
const [data, setData] = useState<{ image: string, animation_url: string }>({ image: '', animation_url: '' });
useEffect(() => {
if(attributes.image) {
const file = files.find(f => f.name === attributes.image);
if(file) {
const reader = new FileReader();
reader.onload = function (event) {
setData((data: any) => {
return {
...(data || {}),
image: (event.target?.result as string) || '',
}
});
};
if (file) reader.readAsDataURL(file);
}
}
if(attributes.animation_url) {
const file = files.find(f => f.name === attributes.animation_url);
if(file) {
const reader = new FileReader();
reader.onload = function (event) {
setData((data: any) => {
return {
...(data || {}),
animation_url: (event.target?.result as string) || '',
}
});
};
if (file) reader.readAsDataURL(file);
}
}
}, [files, attributes]);
return data;
}
const InfoStep = (props: {
attributes: IMetadataExtension;
files: File[],
setAttributes: (attr: IMetadataExtension) => void;
confirm: () => void;
}) => {
const [creators, setCreators] = useState<Array<UserValue>>([]);
const [royalties, setRoyalties] = useState<Array<Royalty>>([]);
const { wallet } = useWallet();
const file = props.attributes.properties.files?.[0];
const fileName = typeof file === 'string' ? file : file?.name;
const { image, animation_url } = useArtworkFiles(props.files, props.attributes);
useEffect(() => {
setRoyalties(
@ -530,8 +558,8 @@ const InfoStep = (props: {
<Col>
{props.attributes.image && (
<ArtCard
image={props.attributes.image}
file={fileName || ''}
image={image}
animationURL={animation_url}
category={props.attributes.properties?.category}
name={props.attributes.name}
symbol={props.attributes.symbol}
@ -893,19 +921,13 @@ const RoyaltiesStep = (props: {
const LaunchStep = (props: {
confirm: () => void;
attributes: IMetadataExtension;
files: File[],
connection: Connection;
}) => {
const files = (props.attributes.properties?.files || []).filter(
f => typeof f !== 'string',
) as File[];
const fileNames = (props.attributes.properties?.files || []).map(f =>
typeof f === 'string' ? f : f?.name,
);
const metadata = {
...(props.attributes as any),
files: fileNames,
};
const [cost, setCost] = useState(0);
const { image, animation_url } = useArtworkFiles(props.files, props.attributes);
const files = props.files;
const metadata = props.attributes;
useEffect(() => {
const rentCall = Promise.all([
props.connection.getMinimumBalanceForRentExemption(MintLayout.span),
@ -932,7 +954,7 @@ const LaunchStep = (props: {
// TODO: add fees based on number of transactions and signers
setCost(sol + additionalSol);
});
}, [files, setCost]);
}, [files, metadata, setCost]);
return (
<>
@ -947,8 +969,8 @@ const LaunchStep = (props: {
<Col>
{props.attributes.image && (
<ArtCard
image={props.attributes.image}
file={fileNames?.[0] || ''}
image={image}
animationURL={animation_url}
category={props.attributes.properties?.category}
name={props.attributes.name}
symbol={props.attributes.symbol}

View File

@ -416,6 +416,7 @@ export const InnerBillingView = ({
<ArtContent
pubkey={id}
className="artwork-image"
allowMeshRender={true}
/>
</Col>
<Col span={12}>

View File

@ -64,6 +64,7 @@ export const AuctionItem = ({
className="artwork-image stack-item"
style={style}
active={active}
allowMeshRender={true}
/>
</div>
);

View File

@ -30,12 +30,12 @@ import {
Creator,
PriceFloor,
PriceFloorType,
IPartialCreateAuctionArgs,
} from '@oyster/common';
import {
Connection,
LAMPORTS_PER_SOL,
PublicKey,
SystemProgram,
} from '@solana/web3.js';
import { MintLayout } from '@solana/spl-token';
import { useHistory, useParams } from 'react-router-dom';
@ -306,14 +306,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 +338,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);
};

View File

@ -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 {

View File

@ -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<u64>,
gap_tick_size_percentage: Option<u8>,
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<u64>) -> 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<u64>,
gap_tick_size_percentage: Option<u8>,
) -> 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(()),

View File

@ -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<u64>,
/// Add a minimum percentage increase each bid must meet.
pub gap_tick_size_percentage: Option<u8>,
}
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,

View File

@ -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.

View File

@ -35,6 +35,12 @@ pub fn set_authority(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramRe
return Err(AuctionError::InvalidAuthority.into());
}
// Make sure new authority actually exists in some form.
if new_authority.data_is_empty() || new_authority.lamports() == 0 {
msg!("Disallowing new authority because it does not exist.");
return Err(AuctionError::InvalidAuthority.into());
}
auction.authority = *new_authority.key;
auction.serialize(&mut *auction_act.data.borrow_mut())?;
Ok(())

View File

@ -357,9 +357,10 @@ pub enum MetaplexInstruction {
/// 0. `[writable]` Auction Manager
/// 1. `[writable]` Auction
/// 2. `[Signer]` Authority of the Auction Manager
/// 3. `[]` Store
/// 4. `[]` Auction program
/// 5. `[]` Clock sysvar
/// 3. `[]` Vault
/// 4. `[]` Store
/// 5. `[]` Auction program
/// 6. `[]` Clock sysvar
DecommissionAuctionManager,
}

View File

@ -13,6 +13,8 @@ use {
entrypoint::ProgramResult,
pubkey::Pubkey,
},
spl_auction::processor::AuctionData,
spl_token_vault::state::Vault,
};
pub fn process_decommission_auction_manager<'a>(
@ -24,18 +26,29 @@ pub fn process_decommission_auction_manager<'a>(
let auction_manager_info = next_account_info(account_info_iter)?;
let auction_info = next_account_info(account_info_iter)?;
let authority_info = next_account_info(account_info_iter)?;
let vault_info = next_account_info(account_info_iter)?;
let store_info = next_account_info(account_info_iter)?;
let auction_program_info = next_account_info(account_info_iter)?;
let clock_info = next_account_info(account_info_iter)?;
assert_owned_by(auction_manager_info, program_id)?;
assert_owned_by(store_info, program_id)?;
assert_signer(authority_info)?;
let mut auction_manager = AuctionManager::from_account_info(auction_manager_info)?;
let vault = Vault::from_account_info(vault_info)?;
let auction = AuctionData::from_account_info(auction_info)?;
let store = Store::from_account_info(store_info)?;
assert_authority_correct(&auction_manager, authority_info)?;
if auction.authority != *auction_manager_info.key {
return Err(MetaplexError::AuctionAuthorityMismatch.into());
}
if vault.authority != *auction_manager_info.key {
return Err(MetaplexError::VaultAuthorityMismatch.into());
}
if auction_manager.state.status != AuctionManagerStatus::Initialized {
return Err(MetaplexError::InvalidStatus.into());
}

View File

@ -53,14 +53,6 @@ pub fn process_init_auction_manager(
return Err(MetaplexError::AuctionMustBeCreated.into());
}
if vault.authority != *auction_manager_info.key {
return Err(MetaplexError::VaultAuthorityMismatch.into());
}
if auction.authority != *auction_manager_info.key {
return Err(MetaplexError::AuctionAuthorityMismatch.into());
}
let bump_seed = assert_derivation(
program_id,
auction_manager_info,

View File

@ -28,16 +28,10 @@ fn set_reservation_list_wrapper<'a>(
auction_manager_info: &AccountInfo<'a>,
signer_seeds: &[&[u8]],
reservations: Vec<Reservation>,
first_push: bool,
total_reservation_spots: u64,
total_reservation_spots: Option<u64>,
offset: u64,
total_spot_offset: u64,
) -> ProgramResult {
let total_reservation_spot_opt: Option<u64>;
if first_push {
total_reservation_spot_opt = Some(total_reservation_spots)
} else {
total_reservation_spot_opt = None
}
invoke_signed(
&set_reservation_list(
*program_id,
@ -45,7 +39,9 @@ fn set_reservation_list_wrapper<'a>(
*reservation_list_info.key,
*auction_manager_info.key,
reservations,
total_reservation_spot_opt,
total_reservation_spots,
offset,
total_spot_offset,
),
&[
master_edition_info.clone(),
@ -58,12 +54,27 @@ fn set_reservation_list_wrapper<'a>(
Ok(())
}
pub fn calc_spots(
winning_config_item: &WinningConfigItem,
auction_manager: &AuctionManager,
n: usize,
) -> u64 {
auction_manager.settings.winning_configs[n]
.items
.iter()
.filter(|i| i.safety_deposit_box_index == winning_config_item.safety_deposit_box_index)
.map(|i| i.amount as u64)
.sum()
}
#[allow(clippy::too_many_arguments)]
pub fn reserve_list_if_needed<'a>(
program_id: &'a Pubkey,
auction_manager: &AuctionManager,
auction: &AuctionData,
winning_config_item: &WinningConfigItem,
winning_index: usize,
bidder_info: &AccountInfo<'a>,
master_edition_info: &AccountInfo<'a>,
reservation_list_info: &AccountInfo<'a>,
auction_manager_info: &AccountInfo<'a>,
@ -71,78 +82,56 @@ pub fn reserve_list_if_needed<'a>(
) -> ProgramResult {
let reservation_list = get_reservation_list(reservation_list_info)?;
if reservation_list.supply_snapshot().is_none() {
let mut reservations: Vec<Reservation> = vec![];
let total_reservation_spot_opt: Option<u64>;
// Auction specifically does not expose internal state workings as it may change someday,
// but it does expose a point get-winner-at-index method. Right now this is just array access
// but may be invocation someday. It's inefficient style but better for the interface maintenance
// in the long run if we move to better storage solutions (so that this action doesnt need to change if
// storage does.)
let mut total_reservation_spots: u64 = 0;
for n in 0..auction_manager.settings.winning_configs.len() {
match auction.winner_at(n) {
Some(address) => {
let spots: u64 = auction_manager.settings.winning_configs[n]
.items
.iter()
.filter(|i| {
i.safety_deposit_box_index
== winning_config_item.safety_deposit_box_index
})
.map(|i| i.amount as u64)
.sum();
total_reservation_spots = total_reservation_spots
// Auction specifically does not expose internal state workings as it may change someday,
// but it does expose a point get-winner-at-index method. Right now this is just array access
// but may be invocation someday. It's inefficient style but better for the interface maintenance
// in the long run if we move to better storage solutions (so that this action doesnt need to change if
// storage does.)
let mut total_reservation_spots: u64 = 0;
let mut total_spot_offset: u64 = 0;
for n in 0..auction_manager.settings.winning_configs.len() {
match auction.winner_at(n) {
Some(_) => {
let spots: u64 = calc_spots(winning_config_item, auction_manager, n);
total_reservation_spots = total_reservation_spots
.checked_add(spots)
.ok_or(MetaplexError::NumericalOverflowError)?;
if n < winning_index {
total_spot_offset = total_spot_offset
.checked_add(spots)
.ok_or(MetaplexError::NumericalOverflowError)?;
reservations.push(Reservation {
address,
// Select all items in a winning config matching the same safety deposit box
// as the one being redeemed here (likely only one)
// and then sum them to get the total spots to reserve for this winner
spots_remaining: spots,
total_spots: spots,
});
}
None => break,
}
}
let mut first_push = true;
let mut reservation_queue: Vec<Reservation> = vec![];
for reservation in reservations {
reservation_queue.push(reservation);
if reservation_queue.len().checked_rem(20) == Some(0) && reservation_queue.len() > 0 {
set_reservation_list_wrapper(
program_id,
master_edition_info,
reservation_list_info,
auction_manager_info,
signer_seeds,
reservation_queue,
first_push,
total_reservation_spots,
)?;
first_push = false;
reservation_queue = vec![]; // start over with new list.
}
}
if reservation_queue.len() > 0 {
set_reservation_list_wrapper(
program_id,
master_edition_info,
reservation_list_info,
auction_manager_info,
signer_seeds,
reservation_queue,
first_push,
total_reservation_spots,
)?;
None => break,
}
}
if reservation_list.supply_snapshot().is_none() {
total_reservation_spot_opt = Some(total_reservation_spots)
} else {
total_reservation_spot_opt = None
}
let my_spots: u64 = calc_spots(winning_config_item, auction_manager, winning_index);
set_reservation_list_wrapper(
program_id,
master_edition_info,
reservation_list_info,
auction_manager_info,
signer_seeds,
vec![Reservation {
address: *bidder_info.key,
spots_remaining: my_spots,
total_spots: my_spots,
}],
total_reservation_spot_opt,
winning_index as u64,
total_spot_offset,
)?;
Ok(())
}
pub fn process_redeem_bid<'a>(
@ -244,6 +233,8 @@ pub fn process_redeem_bid<'a>(
&auction_manager,
&auction,
&winning_config_item,
winning_index,
bidder_info,
master_edition_info,
reservation_list_info,
auction_manager_info,

View File

@ -11,7 +11,10 @@ use {
program::invoke_signed,
pubkey::Pubkey,
},
spl_auction::instruction::{start_auction_instruction, StartAuctionArgs},
spl_auction::{
instruction::{start_auction_instruction, StartAuctionArgs},
processor::AuctionData,
},
};
pub fn issue_start_auction<'a>(
@ -45,8 +48,13 @@ pub fn process_start_auction(program_id: &Pubkey, accounts: &[AccountInfo]) -> P
let clock_info = next_account_info(account_info_iter)?;
let mut auction_manager = AuctionManager::from_account_info(auction_manager_info)?;
let auction = AuctionData::from_account_info(auction_info)?;
let store = Store::from_account_info(store_info)?;
if auction.authority != *auction_manager_info.key {
return Err(MetaplexError::AuctionAuthorityMismatch.into());
}
assert_authority_correct(&auction_manager, authority_info)?;
assert_owned_by(auction_info, &store.auction_program)?;
assert_owned_by(auction_manager_info, program_id)?;

View File

@ -52,6 +52,10 @@ pub fn process_validate_participation(
let open_edition_metadata = Metadata::from_account_info(open_edition_metadata_info)?;
let master_edition = MasterEdition::from_account_info(open_master_edition_info)?;
if vault.authority != *auction_manager_info.key {
return Err(MetaplexError::VaultAuthorityMismatch.into());
}
// top level authority and ownership check
assert_authority_correct(&auction_manager, authority_info)?;
assert_owned_by(auction_manager_info, program_id)?;

View File

@ -104,10 +104,14 @@ pub fn process_validate_safety_deposit_box(
let metadata = Metadata::from_account_info(metadata_info)?;
let store = Store::from_account_info(auction_manager_store_info)?;
// Is it a real vault?
let _vault = Vault::from_account_info(vault_info)?;
let vault = Vault::from_account_info(vault_info)?;
// Is it a real mint?
let _mint: Mint = assert_initialized(mint_info)?;
if vault.authority != *auction_manager_info.key {
return Err(MetaplexError::VaultAuthorityMismatch.into());
}
assert_owned_by(auction_manager_info, program_id)?;
assert_owned_by(metadata_info, &store.token_metadata_program)?;
if !original_authority_lookup_info.data_is_empty() {

View File

@ -274,6 +274,10 @@ pub enum MetadataError {
/// The reservation has only been partially alotted
#[error("The reservation has only been partially alotted")]
ReservationNotComplete,
/// You cannot splice over an existing reservation!
#[error("You cannot splice over an existing reservation!")]
TriedToReplaceAnExistingReservation,
}
impl PrintProgramError for MetadataError {

View File

@ -47,6 +47,13 @@ pub struct SetReservationListArgs {
pub reservations: Vec<Reservation>,
/// should only be present on the very first call to set reservation list.
pub total_reservation_spots: Option<u64>,
/// Where in the reservation list you want to insert this slice of reservations
pub offset: u64,
/// What the total spot offset is in the reservation list from the beginning to your slice of reservations.
/// So if is going to be 4 total editions eventually reserved between your slice and the beginning of the array,
/// split between 2 reservation entries, the offset variable above would be "2" since you start at entry 2 in 0 indexed array
/// (first 2 taking 0 and 1) and because they each have 2 spots taken, this variable would be 4.
pub total_spot_offset: u64,
}
/// Instructions supported by the Metadata program.
@ -126,13 +133,13 @@ pub enum MetadataInstruction {
/// with the pda that was created by that first bidder - the token metadata can then cross reference
/// these people with the list and see that bidder A gets edition #2, so on and so forth.
///
/// NOTE: If you have more than 30 addresses in a reservation list, this may be called multiple times to build up the list,
/// NOTE: If you have more than 20 addresses in a reservation list, this may be called multiple times to build up the list,
/// otherwise, it simply wont fit in one transaction. Only provide a total_reservation argument on the first call, which will
/// allocate the edition space, and in follow up calls this will specifically be unnecessary (and indeed will error.)
///
/// 0. `[writable]` Master Edition key (pda of ['metadata', program id, mint id, 'edition'])
/// 1. `[writable]` PDA for ReservationList of ['metadata', program id, master edition key, 'reservation', resource-key]
/// 3. `[signer]` The resource you tied the reservation list too
/// 2. `[signer]` The resource you tied the reservation list too
SetReservationList(SetReservationListArgs),
/// Create an empty reservation list for a resource who can come back later as a signer and fill the reservation list
@ -370,6 +377,8 @@ pub fn set_reservation_list(
resource: Pubkey,
reservations: Vec<Reservation>,
total_reservation_spots: Option<u64>,
offset: u64,
total_spot_offset: u64,
) -> Instruction {
Instruction {
program_id,
@ -381,6 +390,8 @@ pub fn set_reservation_list(
data: MetadataInstruction::SetReservationList(SetReservationListArgs {
reservations,
total_reservation_spots,
offset,
total_spot_offset,
})
.try_to_vec()
.unwrap(),

View File

@ -75,6 +75,8 @@ pub fn process_instruction(
accounts,
args.reservations,
args.total_reservation_spots,
args.offset,
args.total_spot_offset,
)
}
MetadataInstruction::CreateReservationList => {
@ -547,6 +549,8 @@ pub fn process_set_reservation_list(
accounts: &[AccountInfo],
reservations: Vec<Reservation>,
total_reservation_spots: Option<u64>,
offset: u64,
total_spot_offset: u64,
) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
@ -600,7 +604,7 @@ pub fn process_set_reservation_list(
}
reservation_list.set_current_reservation_spots(total_len);
reservation_list.add_reservations(reservations);
reservation_list.add_reservations(reservations, offset, total_spot_offset)?;
if let Some(total) = total_reservation_spots {
reservation_list.set_supply_snapshot(Some(master_edition.supply));

View File

@ -174,8 +174,13 @@ pub trait ReservationList {
fn current_reservation_spots(&self) -> u64;
fn set_master_edition(&mut self, key: Pubkey);
fn set_supply_snapshot(&mut self, supply: Option<u64>);
fn set_reservations(&mut self, reservations: Vec<Reservation>);
fn add_reservations(&mut self, reservations: Vec<Reservation>);
fn set_reservations(&mut self, reservations: Vec<Reservation>) -> ProgramResult;
fn add_reservations(
&mut self,
reservations: Vec<Reservation>,
offset: u64,
total_spot_offset: u64,
) -> ProgramResult;
fn set_total_reservation_spots(&mut self, total_reservation_spots: u64);
fn set_current_reservation_spots(&mut self, current_reservation_spots: u64);
fn save(&self, account: &AccountInfo) -> ProgramResult;
@ -231,12 +236,52 @@ impl ReservationList for ReservationListV2 {
self.supply_snapshot = supply;
}
fn add_reservations(&mut self, mut reservations: Vec<Reservation>) {
self.reservations.append(&mut reservations)
fn add_reservations(
&mut self,
mut reservations: Vec<Reservation>,
offset: u64,
total_spot_offset: u64,
) -> ProgramResult {
let usize_offset = offset as usize;
while self.reservations.len() < usize_offset {
self.reservations.push(Reservation {
address: solana_program::system_program::id(),
spots_remaining: 0,
total_spots: 0,
})
}
if self.reservations.len() > usize_offset {
let removed_elements: Vec<Reservation> = self
.reservations
.splice(
usize_offset..usize_offset + reservations.len(),
reservations,
)
.collect();
let existing_res = removed_elements
.iter()
.find(|r| r.address != solana_program::system_program::id());
if existing_res.is_some() {
return Err(MetadataError::TriedToReplaceAnExistingReservation.into());
}
} else {
self.reservations.append(&mut reservations)
}
if usize_offset != 0
&& self.reservations[usize_offset - 1].address == solana_program::system_program::id()
{
// This becomes an anchor then for calculations... put total spot offset in here.
self.reservations[usize_offset - 1].spots_remaining = total_spot_offset;
self.reservations[usize_offset - 1].total_spots = total_spot_offset;
}
Ok(())
}
fn set_reservations(&mut self, reservations: Vec<Reservation>) {
self.reservations = reservations
fn set_reservations(&mut self, reservations: Vec<Reservation>) -> ProgramResult {
self.reservations = reservations;
Ok(())
}
fn save(&self, account: &AccountInfo) -> ProgramResult {
@ -322,7 +367,12 @@ impl ReservationList for ReservationListV1 {
self.supply_snapshot = supply;
}
fn add_reservations(&mut self, reservations: Vec<Reservation>) {
fn add_reservations(
&mut self,
reservations: Vec<Reservation>,
_: u64,
_: u64,
) -> ProgramResult {
self.reservations = reservations
.iter()
.map(|r| ReservationV1 {
@ -331,10 +381,13 @@ impl ReservationList for ReservationListV1 {
total_spots: r.total_spots as u8,
})
.collect();
Ok(())
}
fn set_reservations(&mut self, reservations: Vec<Reservation>) {
self.add_reservations(reservations);
fn set_reservations(&mut self, reservations: Vec<Reservation>) -> ProgramResult {
self.add_reservations(reservations, 0, 0)?;
Ok(())
}
fn save(&self, account: &AccountInfo) -> ProgramResult {

View File

@ -424,6 +424,7 @@ pub fn mint_limited_edition<'a>(
let mut reservations = reservation_list.reservations();
for i in 0..reservations.len() {
let mut reservation = &mut reservations[i];
if reservation.address == *mint_authority_info.key {
offset = Some(
prev_total_offsets
@ -436,13 +437,21 @@ pub fn mint_limited_edition<'a>(
.checked_sub(1)
.ok_or(MetadataError::NumericalOverflowError)?;
reservation_list.set_reservations(reservations);
reservation_list.set_reservations(reservations)?;
reservation_list.save(account)?;
break;
}
prev_total_offsets = prev_total_offsets
.checked_add(reservation.total_spots)
.ok_or(MetadataError::NumericalOverflowError)?;
if reservation.address == solana_program::system_program::id() {
// This is an anchor point in the array...it means we reset our math to
// this offset because we may be missing information in between this point and
// the points before it.
prev_total_offsets = reservation.total_spots;
} else {
prev_total_offsets = prev_total_offsets
.checked_add(reservation.total_spots)
.ok_or(MetadataError::NumericalOverflowError)?;
}
}
match offset {

View File

@ -104,6 +104,8 @@ fn show_reservation_list(app_matches: &ArgMatches, _payer: Keypair, client: RpcC
"current res spots: {:?}",
res_list.current_reservation_spots()
);
println!("total res spots: {:?}", res_list.total_reservation_spots());
println!("supply snapshot: {:?}", res_list.supply_snapshot());
}
fn show(app_matches: &ArgMatches, _payer: Keypair, client: RpcClient) {

View File

@ -140,6 +140,13 @@ pub enum VaultInstruction {
/// Useful for testing purposes, and the CLI makes use of it as well so that you can verify logic.
/// 0. `[writable]` External price account
UpdateExternalPriceAccount(ExternalPriceAccount),
/// Sets the authority of the vault to a new authority.
///
/// 0. `[writable]` Vault
/// 1. `[signer]` Vault authority
/// 2. `[]` New authority
SetAuthority,
}
/// Creates an InitVault instruction
@ -432,3 +439,20 @@ pub fn create_add_shares_instruction(
.unwrap(),
}
}
pub fn create_set_authority_instruction(
program_id: Pubkey,
vault: Pubkey,
current_authority: Pubkey,
new_authority: Pubkey,
) -> Instruction {
Instruction {
program_id,
accounts: vec![
AccountMeta::new(vault, false),
AccountMeta::new_readonly(current_authority, true),
AccountMeta::new_readonly(new_authority, false),
],
data: VaultInstruction::SetAuthority.try_to_vec().unwrap(),
}
}

View File

@ -84,6 +84,10 @@ pub fn process_instruction(
args.allowed_to_combine,
)
}
VaultInstruction::SetAuthority => {
msg!("Instruction: Set Authority");
process_set_authority(program_id, accounts)
}
}
}
@ -112,6 +116,35 @@ pub fn process_update_external_price_account(
Ok(())
}
pub fn process_set_authority(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult {
let account_info_iter = &mut accounts.iter();
let vault_info = next_account_info(account_info_iter)?;
let current_authority_info = next_account_info(account_info_iter)?;
let new_authority_info = next_account_info(account_info_iter)?;
let mut vault = Vault::from_account_info(vault_info)?;
assert_owned_by(vault_info, program_id)?;
if vault.authority != *current_authority_info.key {
return Err(VaultError::InvalidAuthority.into());
}
if !current_authority_info.is_signer {
return Err(VaultError::InvalidAuthority.into());
}
// Make sure new authority actually exists in some form.
if new_authority_info.data_is_empty() || new_authority_info.lamports() == 0 {
msg!("Disallowing new authority because it does not exist.");
return Err(VaultError::InvalidAuthority.into());
}
vault.authority = *new_authority_info.key;
vault.serialize(&mut *vault_info.data.borrow_mut())?;
Ok(())
}
pub fn process_add_fractional_shares_to_treasury(
program_id: &Pubkey,
accounts: &[AccountInfo],