UNPKG

@silvana-one/nft

Version:
540 lines (504 loc) 18.1 kB
import { AccountUpdate, DeployArgs, method, Permissions, PublicKey, State, state, UInt64, SmartContract, Bool, UInt32, Field, Struct, assert, Provable, } from "o1js"; import { UInt64Option, TransferEvent, NFTCollectionContractConstructor, NFTApprovalBase, NFTCollectionBase, NFTTransactionContext, TransferExtendedParams, TransferBySignatureParams, TransferByProofParams, } from "../interfaces/index.js"; import { mulDiv } from "../util/index.js"; const MAX_SALE_FEE = 100000; const MIN_STEP = 10; // 1% to previous bid export class AuctionPacked extends Struct({ ownerX: Field, collectionX: Field, nftX: Field, auctioneerX: Field, bidderX: Field, data: Field, }) {} export class Auction extends Struct({ owner: PublicKey, collection: PublicKey, nft: PublicKey, auctioneer: PublicKey, bidder: PublicKey, minimumPrice: UInt64, transferFee: UInt64, /** The sale fee percentage (e.g., 1000 = 1%, 100 = 0.1%, 10000 = 10%, 100000 = 100%). */ saleFee: UInt32, auctionEndTime: UInt32, withdrawPeriod: UInt32, // in slots isOwnerPaid: Bool, isNFTtransferred: Bool, isNFTwithdrawn: Bool, }) { pack(): AuctionPacked { const data = Field.fromBits([ ...this.minimumPrice.value.toBits(64), ...this.transferFee.value.toBits(64), ...this.saleFee.value.toBits(32), ...this.auctionEndTime.value.toBits(32), ...this.withdrawPeriod.value.toBits(32), this.owner.isOdd, this.collection.isOdd, this.nft.isOdd, this.auctioneer.isOdd, this.bidder.isOdd, this.isOwnerPaid, this.isNFTtransferred, this.isNFTwithdrawn, ]); return new AuctionPacked({ ownerX: this.owner.x, collectionX: this.collection.x, nftX: this.nft.x, auctioneerX: this.auctioneer.x, bidderX: this.bidder.x, data, }); } static unpack(packed: AuctionPacked): Auction { const bits = packed.data.toBits(64 + 64 + 32 + 32 + 32 + 8); const ownerX = packed.ownerX; const collectionX = packed.collectionX; const nftX = packed.nftX; const auctioneerX = packed.auctioneerX; const bidderX = packed.bidderX; const ownerIsOdd = bits[64 + 64 + 32 + 32 + 32]; const collectionIsOdd = bits[64 + 64 + 32 + 32 + 32 + 1]; const nftIsOdd = bits[64 + 64 + 32 + 32 + 32 + 2]; const auctioneerIsOdd = bits[64 + 64 + 32 + 32 + 32 + 3]; const bidderIsOdd = bits[64 + 64 + 32 + 32 + 32 + 4]; const isOwnerPaid = bits[64 + 64 + 32 + 32 + 32 + 5]; const isNFTtransferred = bits[64 + 64 + 32 + 32 + 32 + 6]; const isNFTwithdrawn = bits[64 + 64 + 32 + 32 + 32 + 7]; const owner = PublicKey.from({ x: ownerX, isOdd: ownerIsOdd }); const collection = PublicKey.from({ x: collectionX, isOdd: collectionIsOdd, }); const nft = PublicKey.from({ x: nftX, isOdd: nftIsOdd }); const auctioneer = PublicKey.from({ x: auctioneerX, isOdd: auctioneerIsOdd, }); const bidder = PublicKey.from({ x: bidderX, isOdd: bidderIsOdd, }); const minimumPrice = UInt64.Unsafe.fromField( Field.fromBits(bits.slice(0, 64)) ); const transferFee = UInt64.Unsafe.fromField( Field.fromBits(bits.slice(64, 64 + 64)) ); const saleFee = UInt32.Unsafe.fromField( Field.fromBits(bits.slice(64 + 64, 64 + 64 + 32)) ); const auctionEndTime = UInt32.Unsafe.fromField( Field.fromBits(bits.slice(64 + 64 + 32, 64 + 64 + 32 + 32)) ); const withdrawPeriod = UInt32.Unsafe.fromField( Field.fromBits(bits.slice(64 + 64 + 32 + 32, 64 + 64 + 32 + 32 + 32)) ); return new Auction({ owner, collection, nft, auctioneer, bidder, minimumPrice, transferFee, saleFee, auctionEndTime, withdrawPeriod, isOwnerPaid, isNFTtransferred, isNFTwithdrawn, }); } } export class AuctionState extends Struct({ bidAmount: UInt64, auction: Auction, settled: Bool, }) {} export interface NonFungibleTokenAuctionContractDeployProps extends Exclude<DeployArgs, undefined> { /** The minimum price. */ minimumPrice: UInt64; /** The auction end time. */ auctionEndTime: UInt32; /** The collection of the NFT. */ collection: PublicKey; /** The address of the NFT. */ nft: PublicKey; /** The owner of the NFT. */ owner: PublicKey; /** The auctioneer of the NFT. */ auctioneer: PublicKey; /** The transfer fee. */ transferFee: UInt64; /** The sale fee. */ saleFee: UInt32; /** The withdraw period. */ withdrawPeriod: UInt32; } export class AuctionBidEvent extends Struct({ bidder: PublicKey, price: UInt64, }) {} /** * Creates a new NFT Collection Contract class. * * @param params - Constructor parameters including admin and upgrade contracts, and network ID. * @returns The Collection class extending TokenContract and implementing required interfaces. */ export function AuctionFactory(params: { collectionContract: () => NFTCollectionContractConstructor; }) { const { collectionContract } = params; class NonFungibleTokenAuctionContract extends SmartContract implements NFTApprovalBase { @state(AuctionPacked) auctionData = State<AuctionPacked>(); @state(UInt64) bidAmount = State<UInt64>(UInt64.zero); @state(Bool) settled = State<Bool>(Bool(false)); async deploy(args: NonFungibleTokenAuctionContractDeployProps) { await super.deploy(args); assert( args.saleFee.lessThanOrEqual(UInt32.from(MAX_SALE_FEE)), "Sale fee is too high" ); this.auctionData.set( new Auction({ owner: args.owner, collection: args.collection, nft: args.nft, auctioneer: args.auctioneer, minimumPrice: args.minimumPrice, transferFee: args.transferFee, saleFee: args.saleFee, auctionEndTime: args.auctionEndTime, withdrawPeriod: args.withdrawPeriod, bidder: PublicKey.empty(), isOwnerPaid: Bool(false), isNFTtransferred: Bool(false), isNFTwithdrawn: Bool(false), }).pack() ); this.settled.set(Bool(false)); this.bidAmount.set(UInt64.zero); this.account.permissions.set({ ...Permissions.default(), send: Permissions.proof(), setVerificationKey: Permissions.VerificationKey.impossibleDuringCurrentVersion(), setPermissions: Permissions.impossible(), }); } events = { bid: AuctionBidEvent, settleAuction: TransferByProofParams, canTransfer: TransferEvent, settlePayment: UInt64, settleAuctioneerPayment: UInt64, withdraw: UInt64, }; getCollectionContract(address: PublicKey): NFTCollectionBase { const CollectionContract = collectionContract(); return new CollectionContract(address); } calculateSaleFee(params: { price: UInt64; saleFee: UInt32; transferFee: UInt64; }): UInt64 { const { price, saleFee, transferFee } = params; saleFee.assertLessThanOrEqual( UInt32.from(MAX_SALE_FEE), "Sale fee is too high" ); return mulDiv({ value: price, multiplier: UInt64.from(saleFee), denominator: UInt64.from(MAX_SALE_FEE), }).result; } // anyone can call this method to bid, paying the bid amount for the bidder @method.returns(Auction) async bid(price: UInt64, bidder: PublicKey): Promise<Auction> { const settled = this.settled.getAndRequireEquals(); settled.assertFalse("Auction already finished"); const bidAmount = this.bidAmount.getAndRequireEquals(); this.account.balance.requireBetween(bidAmount, UInt64.MAXINT()); const auction = Auction.unpack(this.auctionData.getAndRequireEquals()); price.assertGreaterThanOrEqual( auction.minimumPrice, "Bid should be greater or equal than the minimum price" ); price.assertGreaterThan( bidAmount.add( mulDiv({ value: bidAmount, multiplier: UInt64.from(MIN_STEP), denominator: UInt64.from(1000), }).result ), "Bid should be greater than the existing bid plus the minimum step" ); this.network.globalSlotSinceGenesis.requireBetween( UInt32.from(0), auction.auctionEndTime ); const sender = this.sender.getUnconstrained(); const senderUpdate = AccountUpdate.createSigned(sender); // if there is no bidder, this AccountUpdate will be ignored, similar to AccountUpdate.createIf() const returnUpdate = AccountUpdate.create(auction.bidder); senderUpdate.body.useFullCommitment = Bool(true); returnUpdate.body.useFullCommitment = Bool(true); // return the previous bidder's bid this.balance.subInPlace(bidAmount); returnUpdate.balance.addInPlace(bidAmount); // get the new bid deposit senderUpdate.balance.subInPlace(price); this.balance.addInPlace(price); this.bidAmount.set(price); auction.bidder = bidder; this.auctionData.set(auction.pack()); this.emitEvent("bid", new AuctionBidEvent({ bidder, price })); return auction; } @method.returns(AuctionState) async getAuctionState(): Promise<AuctionState> { return new AuctionState({ auction: Auction.unpack(this.auctionData.getAndRequireEquals()), bidAmount: this.bidAmount.getAndRequireEquals(), settled: this.settled.getAndRequireEquals(), }); } // anyone can call this method to settle the auction // but it is intended to be called by the auctioneer // because the auctioneer is the one who will get the auction commission // and pay the royalty to NFT creator // This method is atomic, so it will settle the auction @method async settleAuction() { const settled = this.settled.getAndRequireEquals(); settled.assertFalse("Auction already settled"); this.settled.set(Bool(true)); const auction = Auction.unpack(this.auctionData.getAndRequireEquals()); this.network.globalSlotSinceGenesis.requireBetween( auction.auctionEndTime.add(1), UInt32.MAXINT() ); const nftAddress = auction.nft; const bidAmount = this.bidAmount.getAndRequireEquals(); auction.bidder.equals(PublicKey.empty()).assertFalse("No bidder"); bidAmount.assertGreaterThanOrEqual( auction.minimumPrice, "Bidder does not have enough balance" ); const collection = this.getCollectionContract(auction.collection); const transferParams = new TransferByProofParams({ address: nftAddress, from: this.address, to: auction.bidder, price: UInt64Option.fromValue(bidAmount), context: new NFTTransactionContext({ custom: [Field(1), Field(0), Field(0)], }), }); await collection.transferByProof(transferParams); this.emitEvent("settleAuction", transferParams); } // and pay the royalty to NFT creator // This method is atomic, so it will settle the auction @method async withdrawNFT() { const settled = this.settled.getAndRequireEquals(); settled.assertFalse("Auction already settled"); this.settled.set(Bool(true)); const auction = Auction.unpack(this.auctionData.getAndRequireEquals()); auction.isNFTwithdrawn.assertFalse("NFT already withdrawn"); auction.isNFTtransferred.assertFalse("NFT already transferred"); this.network.globalSlotSinceGenesis.requireBetween( auction.auctionEndTime.add(auction.withdrawPeriod), UInt32.MAXINT() ); const nftAddress = auction.nft; const collection = this.getCollectionContract(auction.collection); const transferParams = new TransferByProofParams({ address: nftAddress, from: this.address, to: auction.owner, price: UInt64Option.none(), context: new NFTTransactionContext({ custom: [Field(2), Field(0), Field(0)], }), }); await collection.transferByProof(transferParams); this.emitEvent("settleAuction", transferParams); } @method.returns(Bool) async canTransfer(params: TransferExtendedParams): Promise<Bool> { this.settled.requireEquals(Bool(true)); const isSale = params.context.custom[0].equals(Field(1)); const isWithdraw = params.context.custom[0].equals(Field(2)); isSale.or(isWithdraw).assertTrue("Invalid context"); const auction = Auction.unpack(this.auctionData.getAndRequireEquals()); auction.isNFTtransferred.assertFalse("NFT already transferred"); auction.isNFTwithdrawn.assertFalse("NFT already withdrawn"); const collectionAddress = auction.collection; const nftAddress = auction.nft; const owner = auction.owner; const bidder = auction.bidder; const bidAmount = this.bidAmount.getAndRequireEquals(); this.network.globalSlotSinceGenesis.requireBetween( auction.auctionEndTime.add( Provable.if(isSale, UInt32.from(1), auction.withdrawPeriod) ), UInt32.MAXINT() ); params.collection.assertEquals(collectionAddress); params.nft.assertEquals(nftAddress); params.from .equals(owner) .and(params.approved.equals(this.address)) .or( params.from .equals(this.address) .and(params.approved.equals(PublicKey.empty())) ) .assertTrue("Only owner or auction can transfer"); params.price.isSome.assertEquals(isSale); params.price .orElse(UInt64.zero) .assertEquals(Provable.if(isSale, bidAmount, UInt64.zero)); params.price .orElse(UInt64.MAXINT()) .assertGreaterThanOrEqual( auction.minimumPrice, "Bid should be greater or equal than the minimum price" ); params.to.assertEquals(Provable.if(isSale, bidder, auction.owner)); const fee = params.fee.orElse(UInt64.zero); fee.assertLessThanOrEqual( Provable.if(isSale, bidAmount, UInt64.MAXINT()), "Fee is higher than the bid" ); const saleFee = this.calculateSaleFee({ price: bidAmount, saleFee: auction.saleFee, transferFee: auction.transferFee, }); fee.assertLessThanOrEqual( Provable.if(isSale, saleFee, UInt64.MAXINT()), "Fee is higher than the sale fee" ); auction.isNFTtransferred = isSale; auction.isNFTwithdrawn = isWithdraw; this.auctionData.set(auction.pack()); this.emitEvent( "canTransfer", new TransferEvent({ ...params, }) ); return Bool(true); } @method async settlePayment(): Promise<void> { this.settled.getAndRequireEquals().assertTrue("Auction not settled"); const auction = Auction.unpack(this.auctionData.getAndRequireEquals()); auction.isOwnerPaid.assertFalse("Owner is not paid yet"); auction.isNFTtransferred.assertTrue("NFT not transferred"); const bidAmount = this.bidAmount.getAndRequireEquals(); this.network.globalSlotSinceGenesis.requireBetween( auction.auctionEndTime.add(1), UInt32.MAXINT() ); const payment = bidAmount.sub( this.calculateSaleFee({ price: bidAmount, saleFee: auction.saleFee, transferFee: auction.transferFee, }) ); this.account.balance.requireBetween(payment, UInt64.MAXINT()); const ownerUpdate = AccountUpdate.create(auction.owner); ownerUpdate.balance.addInPlace(payment); this.balance.subInPlace(payment); ownerUpdate.body.useFullCommitment = Bool(true); auction.isOwnerPaid = Bool(true); this.auctionData.set(auction.pack()); this.emitEvent("settlePayment", payment); } /* const balance = this.account.balance.getAndRequireEquals(); is not stable and sometimes gives 0 on devnet during proving, so we put the amount as a parameter This method can be called many times by anyone, allowing the auctioneer to use the hardware wallet and bots */ @method async settleAuctioneerPayment(amount: UInt64): Promise<void> { this.settled.getAndRequireEquals().assertTrue("Auction not settled"); const auction = Auction.unpack(this.auctionData.getAndRequireEquals()); auction.isOwnerPaid.assertTrue( "Owner is not paid yet, first call settlePayment" ); this.network.globalSlotSinceGenesis.requireBetween( auction.auctionEndTime.add(1), UInt32.MAXINT() ); this.account.balance.requireBetween(amount, UInt64.MAXINT()); const auctioneerUpdate = AccountUpdate.create(auction.auctioneer); auctioneerUpdate.balance.addInPlace(amount); this.balance.subInPlace(amount); auctioneerUpdate.body.useFullCommitment = Bool(true); this.emitEvent("settleAuctioneerPayment", amount); } /** * Withdraw the deposit from the auction * in case the auction is not settled during the WITHDRAW_PERIOD * for any reason * Anybody can call this method to allow the use of bots by the auctioneer or bidder */ @method async withdraw(): Promise<void> { const auction = Auction.unpack(this.auctionData.getAndRequireEquals()); auction.isNFTtransferred.assertFalse("NFT already transferred"); this.network.globalSlotSinceGenesis.requireBetween( auction.auctionEndTime.add(auction.withdrawPeriod), UInt32.MAXINT() ); const bidAmount = this.bidAmount.getAndRequireEquals(); const bidderUpdate = AccountUpdate.create(auction.bidder); bidderUpdate.balance.addInPlace(bidAmount); this.balance.subInPlace(bidAmount); bidderUpdate.body.useFullCommitment = Bool(true); this.settled.set(Bool(true)); this.emitEvent("withdraw", bidAmount); } } return NonFungibleTokenAuctionContract; }