diff --git a/src/controllers/collection.ts b/src/controllers/collection.ts index 4d519f8..27b27ea 100644 --- a/src/controllers/collection.ts +++ b/src/controllers/collection.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express'; import httpStatus from 'http-status'; import { CollectionService } from '../services'; +import { assert } from '../utils'; class CollectionController { async searchCollections(req: Request, res: Response): Promise { @@ -33,6 +34,15 @@ class CollectionController { data: collectionWithTokens, }); } + + async verifyCollection(req: Request, res: Response): Promise { + const { id } = req.params; + const { instagram, profile } = req.query; + const collection = await CollectionService.getById(id as string); + + assert(collection != null, 'Collection id does not exists'); + assert(collection?.issuer == req.user?.address, 'Only issuer of collection can verify collection'); + } } export default new CollectionController(); diff --git a/src/controllers/index.ts b/src/controllers/index.ts index 23b6f92..0b8788b 100644 --- a/src/controllers/index.ts +++ b/src/controllers/index.ts @@ -1,3 +1,5 @@ export { default as CollectionController } from './collection'; export { default as ListingController } from './listing'; export { default as TokenController } from './token'; +export { default as UserController } from './user'; +export { default as OfferController } from './offer'; diff --git a/src/controllers/listing.ts b/src/controllers/listing.ts index 628f0c3..c1c0fb0 100644 --- a/src/controllers/listing.ts +++ b/src/controllers/listing.ts @@ -1,12 +1,10 @@ import { Listing } from '@prisma/client'; import { Request, Response } from 'express'; import httpStatus from 'http-status'; -import { NFTokenCancelOffer, NFTokenCreateOffer } from 'xrpl'; -import { configuration } from '../config'; import { ListingDBFilters } from '../interfaces'; -import { ListingService, TokenService } from '../services'; -import { XrplClient, assert, pick } from '../utils'; +import { ListingService } from '../services'; +import { XrplClient, assert, pick, NFTBroker } from '../utils'; class ListingController { async getListingById(req: Request, res: Response): Promise { @@ -29,44 +27,12 @@ class ListingController { } async createListing(req: Request, res: Response): Promise { - const { txHash } = req.body; - const tx = (await XrplClient.getTransaction(txHash)) as NFTokenCreateOffer; - - assert(tx.Account == req.user?.address, 'Not authorised'); - assert(tx.Flags == 1, 'Offer type in transaction is not of right type'); - assert(tx.TransactionType == 'NFTokenCreateOffer', "Transaction provided is of wrong type'"); - assert( - tx.Destination == XrplClient.getAddressFromPrivateKey(configuration.XRPL_ACCOUNT_SECRET), - 'Transaction destination address does not belong to Optimart', - ); - - const ongoingListings = await ListingService.all({ status: 'ONGOING' }); - assert( - ongoingListings.length == 0, - 'A listing for this token is still ongoing, cancel/close it before creating a new one', - ); + const { txHash, extraPayload } = req.body; + const { type } = extraPayload; - const nft = await TokenService.getOrCreateByTokenId(tx.NFTokenID); - const listing = await ListingService.create({ - creator: { - connectOrCreate: { - where: { - address: tx.Account, - }, - create: { - address: tx.Account, - }, - }, - }, - price: Number(tx.Amount), - nft: { - connect: { - tokenId: nft.tokenId, - }, - }, - createTxnHash: txHash, - }); + const listing = await NFTBroker.createListing(txHash, type, req.user?.address); res.status(httpStatus.OK).json({ data: listing }); + // todo: consider updating floor price of collection depending on listing price } @@ -79,12 +45,12 @@ class ListingController { assert(listing?.creatorAddr == req.user?.address, 'Listing creator mismatch with authenticated user'); assert(listing?.status == 'ONGOING', 'Listing must be currently ongoing'); - const txn = (await XrplClient.getTransaction(txHash)) as NFTokenCancelOffer; + const txn = (await XrplClient.getTransaction(txHash)).result; assert(txn.TransactionType == 'NFTokenCancelOffer', "Transaction provided is of wrong type'"); assert(txn.Account == req.user?.address, 'Not authorised'); - await ListingService.cancelListing(listingId, txHash); + await NFTBroker.cancelListing(listingId, txHash); res.status(httpStatus.NO_CONTENT).json(); } } diff --git a/src/controllers/offer.ts b/src/controllers/offer.ts index e69de29..610e36d 100644 --- a/src/controllers/offer.ts +++ b/src/controllers/offer.ts @@ -0,0 +1,168 @@ +import { ListingOffer } from '@prisma/client'; +import { Request, Response } from 'express'; +import httpStatus from 'http-status'; +import { TransactionMetadata, rippleTimeToUnixTime } from 'xrpl'; + +import { configuration } from '../config'; +import { NFTCreateOfferWithId, OfferDBFilters } from '../interfaces'; +import { ListingOfferService, ListingService, TokenService } from '../services'; +import { NFTBroker, XrplClient, assert, pick } from '../utils'; + +class OfferController { + async getOfferById(req: Request, res: Response): Promise { + const { id } = req.params; + const offer = await ListingOfferService.getById(id); + res.status(httpStatus.OK).json({ data: offer }); + } + + async getOffers(req: Request, res: Response): Promise { + const filters = pick(req.query, [ + 'status', + 'limit', + 'offset', + 'offeree', + 'offeror', + 'listing', + 'nftId', + 'offeredBefore', + 'offeredAfter', + ]) as OfferDBFilters; + + const offers: ListingOffer[] = await ListingOfferService.all(filters); + const offersCount = await ListingOfferService.count(filters); + res.status(httpStatus.OK).json({ + data: { + total: offersCount, + offers: offers, + }, + }); + } + + async createOffers(req: Request, res: Response): Promise { + const { txHash, extraPayload } = req.body; + const { listingId } = extraPayload; + const tx = (await XrplClient.getTransaction(txHash)).result; + const nftOffer = XrplClient.parseNFTCreateOfferFromTxnMetadata( + tx.meta as TransactionMetadata, + ) as NFTCreateOfferWithId; + const endAt = nftOffer.Expiration ? rippleTimeToUnixTime(nftOffer.Expiration) : undefined; + if (endAt) { + assert(endAt > Date.now(), 'Invalid value for ending timestamp'); + } + assert(tx.Account == req.user?.address, 'Not authorised'); + assert(nftOffer.Flags != 1, 'Offer type in transaction is not of right type'); + assert(tx.TransactionType == 'NFTokenCreateOffer', "Transaction provided is of wrong type'"); + assert( + nftOffer.Destination == XrplClient.getAddressFromPrivateKey(configuration.XRPL_ACCOUNT_SECRET), + 'Transaction destination address does not belong to Optimart', + ); + + const nft = await TokenService.getOrCreateByTokenId(nftOffer.NFTokenID); + let listing; + if (listingId) { + listing = await ListingService.getById(listingId); + assert(listing != null && listing.status == 'ONGOING', 'Invalid listing id provided'); + } + + const offer = await ListingOfferService.create({ + createTxnHash: txHash, + amount: Number(nftOffer.Amount), + nft: { + connect: { + tokenId: nft.tokenId, + }, + }, + offeree: { + connectOrCreate: { + where: { + address: nftOffer.Owner, + }, + create: { + address: nftOffer.Owner as string, + }, + }, + }, + endAt: endAt ? new Date(endAt) : undefined, + listing: { + connect: { id: listingId }, + }, + offeror: { + connectOrCreate: { + where: { + address: tx.Account, + }, + create: { + address: tx.Account as string, + }, + }, + }, + buyOfferId: nftOffer.id, + }); + + if (listing && listing.price.toNumber() <= offer.amount.toNumber()) { + await NFTBroker.acceptListingOffer(offer.id); + } + res.status(httpStatus.OK).json({ data: offer }); + } + + async cancelOffer(req: Request, res: Response): Promise { + const { txHash, extraPayload } = req.body; + const { offerId } = extraPayload; + const offer = await ListingOfferService.getById(offerId); + + assert(offer != null, 'Offer not found in database'); + assert(offer?.status == 'PENDING', 'Offer must be currently pending'); + assert(offer?.offerorAddr == req.user?.address, 'Offers can only be cancelled by creator'); + + const txn = (await XrplClient.getTransaction(txHash)).result; + + assert(txn.TransactionType == 'NFTokenCancelOffer', "Transaction provided is of wrong type'"); + assert(txn.Account == req.user?.address, 'Not authorised'); + await ListingOfferService.cancel(offerId, txHash); + res.status(httpStatus.NO_CONTENT).json(); + } + + async rejectOffer(req: Request, res: Response): Promise { + const { txHash, extraPayload } = req.body; + const { offerId } = extraPayload; + const offer = await ListingOfferService.getById(offerId); + + assert(offer != null, 'Offer not found in database'); + assert(offer?.status == 'PENDING', 'Offer must be currently pending'); + assert(offer?.offereeAddr == req.user?.address, 'Offers can only be rejected by receiver'); + + const txn = (await XrplClient.getTransaction(txHash)).result; + + assert(txn.TransactionType == 'NFTokenCancelOffer', "Transaction provided is of wrong type'"); + assert(txn.Account == req.user?.address, 'Not authorised'); + + await ListingOfferService.reject(offerId, txHash); + res.status(httpStatus.NO_CONTENT).json(); + } + + async acceptOffer(req: Request, res: Response): Promise { + const { txHash, extraPayload } = req.body; + const { offerId } = extraPayload; + const offer = await ListingOfferService.getById(offerId); + + assert(offer != null, 'Offer not found in database'); + assert(offer?.status == 'PENDING', 'Offer must be currently pending'); + assert(offer?.offereeAddr == req.user?.address, 'Offers can only be rejected by receiver'); + + if (!offer?.listingId) { + assert(txHash != undefined, ''); + const listing = await NFTBroker.createListing(txHash, 'REGULAR', req.user?.address); + await ListingOfferService.update(offerId, { + listing: { + connect: { + id: listing.id, + }, + }, + }); + } + await NFTBroker.acceptListingOffer(offerId); + res.status(httpStatus.NO_CONTENT).json(); + } +} + +export default new OfferController(); diff --git a/src/controllers/user.ts b/src/controllers/user.ts new file mode 100644 index 0000000..788f495 --- /dev/null +++ b/src/controllers/user.ts @@ -0,0 +1,23 @@ +import { Request, Response } from 'express'; +import httpStatus from 'http-status'; + +import { UserService } from '../services'; +import { XrplClient } from '../utils'; + +class UserController { + async getByAddr(req: Request, res: Response): Promise { + const { addr } = req.params; + const user = await UserService.getOrCreateByAddr(addr); + const balance = await XrplClient.getAccountInfo(addr); + const nfts = await XrplClient.getAccountNFTInfo(addr); + res.status(httpStatus.OK).json({ + data: { + ...user, + xrpBalance: balance, + nfts: nfts, + }, + }); + } +} + +export default new UserController(); diff --git a/src/@types/express/index.d.ts b/src/custom.d.ts similarity index 100% rename from src/@types/express/index.d.ts rename to src/custom.d.ts diff --git a/src/interfaces.ts b/src/interfaces.ts index 82cf9cb..acac40c 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,5 @@ import { Nft } from '@prisma/client'; -import { Transaction, TransactionMetadata } from 'xrpl'; +import { NFTokenCreateOffer, Transaction, TransactionMetadata } from 'xrpl'; import { LedgerIndex, NFTOffer } from 'xrpl/dist/npm/models/common'; import { BaseResponse } from 'xrpl/dist/npm/models/methods/baseMethod'; @@ -14,6 +14,9 @@ interface NFTMetadataAttribute { name: string; value: string; } +export interface NFTCreateOfferWithId extends NFTokenCreateOffer { + id: string; +} export interface NFTHistoryTxnsResponse extends BaseResponse { result: { @@ -41,7 +44,7 @@ export interface NFTInfoResponse extends BaseResponse { } export interface CollectionTokensData { - name: string; + name: string | null; nfts: Nft[]; } @@ -60,4 +63,18 @@ export interface ListingDBFilters { creator?: string; listedBefore?: Date; listedAfter?: Date; + nftId?: string; + type?: 'REGULAR' | 'AUCTION'; +} + +export interface OfferDBFilters { + limit?: number; + offset?: number; + offeree?: string; + offeror?: string; + listing?: string; + offeredBefore?: Date; + offeredAfter?: Date; + nftId?: string; + status?: 'PENDING' | 'CANCELLED' | 'ACCEPTED' | 'REJECTED'; } diff --git a/src/prisma/migrations/20230817140623_new_listing_and_offer/migration.sql b/src/prisma/migrations/20230817140623_new_listing_and_offer/migration.sql new file mode 100644 index 0000000..179067a --- /dev/null +++ b/src/prisma/migrations/20230817140623_new_listing_and_offer/migration.sql @@ -0,0 +1,29 @@ +/* + Warnings: + + - Added the required column `duration` to the `Listing` table without a default value. This is not possible if the table is not empty. + - Added the required column `nftId` to the `ListingOffer` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "ListingType" AS ENUM ('REGULAR', 'AUCTION'); + +-- DropForeignKey +ALTER TABLE "Offer" DROP CONSTRAINT "Offer_nftId_fkey"; + +-- DropIndex +DROP INDEX "Auction_nftId_key"; + +-- DropIndex +DROP INDEX "Listing_nftId_key"; + +-- AlterTable +ALTER TABLE "Listing" ADD COLUMN "duration" DECIMAL(65,30) NOT NULL; + +-- AlterTable +ALTER TABLE "ListingOffer" ADD COLUMN "nftId" TEXT NOT NULL, +ADD COLUMN "type" "ListingType" NOT NULL DEFAULT 'REGULAR', +ALTER COLUMN "listingId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "ListingOffer" ADD CONSTRAINT "ListingOffer_nftId_fkey" FOREIGN KEY ("nftId") REFERENCES "Nft"("tokenId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/prisma/migrations/20230817140950_optional_duration/migration.sql b/src/prisma/migrations/20230817140950_optional_duration/migration.sql new file mode 100644 index 0000000..73b474b --- /dev/null +++ b/src/prisma/migrations/20230817140950_optional_duration/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Listing" ALTER COLUMN "duration" DROP NOT NULL; diff --git a/src/prisma/migrations/20230817142718_add_type_to_listing/migration.sql b/src/prisma/migrations/20230817142718_add_type_to_listing/migration.sql new file mode 100644 index 0000000..6d986ac --- /dev/null +++ b/src/prisma/migrations/20230817142718_add_type_to_listing/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `type` on the `ListingOffer` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Listing" ADD COLUMN "type" "ListingType" NOT NULL DEFAULT 'REGULAR'; + +-- AlterTable +ALTER TABLE "ListingOffer" DROP COLUMN "type"; diff --git a/src/prisma/migrations/20230817144647_add_duration_to_offers/migration.sql b/src/prisma/migrations/20230817144647_add_duration_to_offers/migration.sql new file mode 100644 index 0000000..4869a18 --- /dev/null +++ b/src/prisma/migrations/20230817144647_add_duration_to_offers/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ListingOffer" ADD COLUMN "duration" DECIMAL(65,30); diff --git a/src/prisma/migrations/20230817160552_change_duration_to_end_at/migration.sql b/src/prisma/migrations/20230817160552_change_duration_to_end_at/migration.sql new file mode 100644 index 0000000..cba9b0f --- /dev/null +++ b/src/prisma/migrations/20230817160552_change_duration_to_end_at/migration.sql @@ -0,0 +1,21 @@ +/* + Warnings: + + - You are about to drop the column `duration` on the `Auction` table. All the data in the column will be lost. + - You are about to drop the column `duration` on the `Listing` table. All the data in the column will be lost. + - You are about to drop the column `duration` on the `ListingOffer` table. All the data in the column will be lost. + - Added the required column `endAt` to the `Auction` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Auction" DROP COLUMN "duration", +ADD COLUMN "endAt" TIMESTAMP(3) NOT NULL; + +-- AlterTable +ALTER TABLE "Listing" DROP COLUMN "duration", +ADD COLUMN "endAt" TIMESTAMP(3), +ALTER COLUMN "type" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "ListingOffer" DROP COLUMN "duration", +ADD COLUMN "endAt" TIMESTAMP(3); diff --git a/src/prisma/migrations/20230820091744_added_onchain_offer_id/migration.sql b/src/prisma/migrations/20230820091744_added_onchain_offer_id/migration.sql new file mode 100644 index 0000000..67b909a --- /dev/null +++ b/src/prisma/migrations/20230820091744_added_onchain_offer_id/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Added the required column `sellOfferId` to the `Listing` table without a default value. This is not possible if the table is not empty. + - Added the required column `buyOfferId` to the `ListingOffer` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "Listing" ADD COLUMN "sellOfferId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "ListingOffer" ADD COLUMN "buyOfferId" TEXT NOT NULL; diff --git a/src/prisma/migrations/20230820163441_modify_collection_schema/migration.sql b/src/prisma/migrations/20230820163441_modify_collection_schema/migration.sql new file mode 100644 index 0000000..b17d244 --- /dev/null +++ b/src/prisma/migrations/20230820163441_modify_collection_schema/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "Collection" ADD COLUMN "bannerUrl" TEXT, +ADD COLUMN "profileUrl" TEXT, +ALTER COLUMN "name" DROP NOT NULL, +ALTER COLUMN "description" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "profileUrl" TEXT; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 107649a..a8718a7 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -23,11 +23,16 @@ enum ListingStatus{ COMPLETED } +enum ListingType{ + REGULAR + AUCTION +} model User { id String @id @default(cuid()) email String? @unique address String @unique + profileUrl String? listings Listing[] auctions Auction[] @@ -45,13 +50,16 @@ model User { model Listing { id String @id @default(cuid()) creatorAddr String - nftId String @unique + nftId String price Decimal createdAt DateTime @default(now()) + endAt DateTime? updatedAt DateTime @updatedAt() status ListingStatus @default(ONGOING) + type ListingType createTxnHash String updateTxnHash String? + sellOfferId String offers ListingOffer[] @@ -63,15 +71,20 @@ model ListingOffer { id String @id @default(cuid()) offerorAddr String offereeAddr String - listingId String + listingId String? + nftId String amount Decimal + endAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt() status OfferStatus @default(PENDING) createTxnHash String @unique() updateTxnHash String? @unique() + buyOfferId String - listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) + + listing Listing? @relation(fields: [listingId], references: [id], onDelete: Cascade) + nft Nft @relation(fields: [nftId], references: [tokenId], onDelete: Cascade) offeree User @relation(name: "receivedListingOffers", fields: [offereeAddr], references: [address], onDelete: Cascade) offeror User @relation(name: "initiatedListingOffers", fields: [offerorAddr], references: [address], onDelete: Cascade) } @@ -79,8 +92,8 @@ model ListingOffer { model Auction { id String @id @default(cuid()) creatorAddr String - nftId String @unique - duration Decimal + nftId String + endAt DateTime minBid Decimal createdAt DateTime @default(now()) updatedAt DateTime @updatedAt() @@ -123,21 +136,25 @@ model Offer { createTxnHash String @unique() updateTxnHash String? @unique() - nft Nft @relation(fields: [nftId], references: [tokenId], onDelete: Cascade) + // nft Nft @relation(fields: [nftId], references: [tokenId], onDelete: Cascade) sender User @relation(name: "sentOffers", fields: [senderAddr], references: [address], onDelete: Cascade) receiver User @relation(name: "receivedOffers", fields: [receiverAddr], references: [address], onDelete: Cascade) } model Collection { id String @id @default(cuid()) - name String + name String? taxon Int issuer String - description String + description String? // gotten from a combination of `issuer-taxon` collectionId String @unique - floorPrice Decimal @default(0) - createdAt DateTime? + floorPrice Decimal @default(0) + createdAt DateTime? + profileUrl String? + bannerUrl String? + instagram String? + twitter String? nfts Nft[] @@ -158,7 +175,7 @@ model Nft { listing Listing[] auction Auction[] - offers Offer[] + offers ListingOffer[] collection Collection? @relation(fields: [collectionId], references: [collectionId], onDelete: Cascade) } diff --git a/src/services/bid.ts b/src/services/bid.ts new file mode 100644 index 0000000..411cd38 --- /dev/null +++ b/src/services/bid.ts @@ -0,0 +1,29 @@ +import { AuctionBid, Prisma } from '@prisma/client'; + +import prisma from '../prisma/index'; + +class AuctionBidService { + auctionBidModel = prisma.auctionBid; + + getById(id: string): Promise { + return this.auctionBidModel.findUnique({ where: { id: id } }); + } + + count(): Promise { + return this.auctionBidModel.count(); + } + + all(limit: number = 100, offset: number = 0): Promise { + return this.auctionBidModel.findMany({ take: limit, skip: offset }); + } + + create(data: Prisma.AuctionBidCreateInput): Promise { + return this.auctionBidModel.create({ data: data }); + } + + update(id: string, data: Prisma.AuctionBidUpdateInput): Promise { + return this.auctionBidModel.update({ where: { id: id }, data: data }); + } +} + +export default new AuctionBidService(); diff --git a/src/services/collection.ts b/src/services/collection.ts index 3bdc530..5902cb9 100644 --- a/src/services/collection.ts +++ b/src/services/collection.ts @@ -62,6 +62,17 @@ class CollectionService { }, }); } + + async getOrCreateCollection(id: string): Promise { + const [issuer, taxon] = id.split('-'); + return this.model.create({ + data: { + issuer: issuer, + taxon: Number(taxon), + collectionId: id, + }, + }); + } } export default new CollectionService(); diff --git a/src/services/listing-offer.ts b/src/services/listing-offer.ts index 0d1d71f..aace870 100644 --- a/src/services/listing-offer.ts +++ b/src/services/listing-offer.ts @@ -1,5 +1,6 @@ import { ListingOffer, Prisma } from '@prisma/client'; +import { OfferDBFilters } from '../interfaces'; import prisma from '../prisma/index'; class ListingOfferService { @@ -9,12 +10,46 @@ class ListingOfferService { return this.model.findUnique({ where: { id: id } }); } - async count(): Promise { - return this.model.count(); + async count(filters: OfferDBFilters): Promise { + return this.model.count({ + where: { + AND: [ + { offereeAddr: filters.offeree }, + { offerorAddr: filters.offeror }, + { createdAt: { lte: filters.offeredAfter } }, + { createdAt: { gte: filters.offeredBefore } }, + { listingId: filters.listing }, + { status: filters.status }, + ], + }, + }); } - async all(limit: number, offset: number): Promise { - return this.model.findMany({ take: limit, skip: offset }); + async all(filters: OfferDBFilters): Promise { + return this.model.findMany({ + take: filters.limit, + skip: filters.offset, + + where: { + AND: [ + { offereeAddr: filters.offeree }, + { offerorAddr: filters.offeror }, + { createdAt: { lte: filters.offeredAfter } }, + { createdAt: { gte: filters.offeredBefore } }, + { listingId: filters.listing }, + { status: filters.status }, + { nftId: filters.nftId }, + ], + }, + }); + } + + async getByListing(listingId: string): Promise { + return this.model.findMany({ + where: { + listingId: listingId, + }, + }); } async create(data: Prisma.ListingOfferCreateInput): Promise { @@ -24,6 +59,18 @@ class ListingOfferService { async update(id: string, data: Prisma.ListingOfferUpdateInput): Promise { return this.model.update({ where: { id: id }, data: data }); } + + async cancel(id: string, txHash: string): Promise { + return this.update(id, { status: 'CANCELLED', updateTxnHash: txHash }); + } + + async accept(id: string, txHash: string): Promise { + return this.update(id, { status: 'ACCEPTED', updateTxnHash: txHash }); + } + + async reject(id: string, txHash: string): Promise { + return this.update(id, { status: 'REJECTED', updateTxnHash: txHash }); + } } export default new ListingOfferService(); diff --git a/src/services/listing.ts b/src/services/listing.ts index 88d3103..c5fecce 100644 --- a/src/services/listing.ts +++ b/src/services/listing.ts @@ -20,12 +20,14 @@ class ListingService { { creatorAddr: filters.creator }, { createdAt: { lte: filters.listedAfter } }, { createdAt: { gte: filters.listedBefore } }, + { nftId: filters.nftId }, + { type: filters.type }, ], }, }); } - async getByTokenId(tokenId: string): Promise { + async getByTokenId(tokenId: string): Promise { return this.model.findMany({ where: { nftId: tokenId, @@ -33,6 +35,15 @@ class ListingService { }); } + async getPendingListings(): Promise { + return this.model.findMany({ + where: { + endAt: { lte: new Date() }, //filter based on timestamp + status: 'ONGOING', + }, + }); + } + async getById(id: string): Promise { return this.model.findUnique({ where: { @@ -52,6 +63,10 @@ class ListingService { async cancelListing(id: string, txHash: string): Promise { return this.update(id, { status: 'CANCELLED', updateTxnHash: txHash }); } + + async completeListing(id: string, txHash: string): Promise { + return this.update(id, { status: 'COMPLETED', updateTxnHash: txHash }); + } } export default new ListingService(); diff --git a/src/services/listingoffer.ts b/src/services/listingoffer.ts new file mode 100644 index 0000000..b42127e --- /dev/null +++ b/src/services/listingoffer.ts @@ -0,0 +1,32 @@ +import { ListingOffer, Prisma } from '@prisma/client'; + +import prisma from '../prisma/index'; + +class ListingOfferService { + listingOfferCollection; + constructor() { + this.listingOfferCollection = prisma.listingOffer; + } + + getById(id: string): Promise { + return this.listingOfferCollection.findUnique({ where: { id: id } }); + } + + count(): Promise { + return this.listingOfferCollection.count(); + } + + all(limit: number = 100, offset: number = 0): Promise { + return this.listingOfferCollection.findMany({ take: limit, skip: offset }); + } + + create(data: Prisma.ListingOfferCreateInput): Promise { + return this.listingOfferCollection.create({ data: data }); + } + + update(id: string, data: Prisma.ListingOfferUpdateInput): Promise { + return this.listingOfferCollection.update({ where: { id: id }, data: data }); + } +} + +export default new ListingOfferService(); diff --git a/src/services/token.ts b/src/services/token.ts index f646302..a2f7ba7 100644 --- a/src/services/token.ts +++ b/src/services/token.ts @@ -29,6 +29,7 @@ class NftService { async createByTokenId(id: string): Promise { const nftData = await XrplClient.getNFTInfo(id); + const collectionId = `${nftData.issuer}-${nftData.nft_taxon}`; const metadata = await NFTMetadataService.resolveNFTMetadata(id, nftData.uri, nftData.issuer); return this.create({ tokenId: id, @@ -37,6 +38,18 @@ class NftService { attributes: JSON.stringify(metadata.attributes), uri: nftData.uri, imageUrl: metadata.imageUrl, + collection: { + connectOrCreate: { + where: { + id: collectionId, + }, + create: { + collectionId: collectionId, + issuer: nftData.issuer, + taxon: nftData.nft_taxon, + }, + }, + }, }); } diff --git a/src/services/user.ts b/src/services/user.ts index a7c829b..3647f58 100644 --- a/src/services/user.ts +++ b/src/services/user.ts @@ -28,6 +28,14 @@ class UserService { } return user; } + async search(q: string, limit: number): Promise { + return this.model.findMany({ + take: limit, + where: { + OR: [{ address: { contains: q } }], + }, + }); + } } export default new UserService(); diff --git a/src/utils/broker.ts b/src/utils/broker.ts new file mode 100644 index 0000000..228245f --- /dev/null +++ b/src/utils/broker.ts @@ -0,0 +1,207 @@ +import { Listing, ListingOffer, ListingType } from '@prisma/client'; +import { TransactionMetadata, rippleTimeToUnixTime, xrpToDrops } from 'xrpl'; + +import { assert } from './misc'; +import { configuration } from '../config'; +import { NFTCreateOfferWithId } from '../interfaces'; +import { ListingOfferService, ListingService, TokenService } from '../services'; + +import { XrplClient } from '.'; + +class NFTBroker { + /** + * Brokers a nft sale between a buyer and sell with the specified offerId. + * + * @param offerId the offerId to accept. + */ + async acceptListingOffer(offerId: string): Promise { + const offer = (await ListingOfferService.getById(offerId)) as ListingOffer; + assert(offer != null, 'No offer of corresponding Id was found'); + const listing = (await ListingService.getById(offer.listingId as string)) as Listing; // make listing compulsory + + //select losing offers + const losingOffers = (await ListingOfferService.getByListing(listing.id)) + .filter((offerData) => offerData.id != offerId) //remove current winning offers + .map((offerData) => ({ + id: offerData.id, + buyOfferId: offerData.buyOfferId, + })); + + const fee = + (7.5 / 100) * Number(listing.price) > Number(xrpToDrops(25)) + ? Number(xrpToDrops(25)) + : (70 / 100) * Number(listing.price); + + //broker transaction + const acceptTxn = XrplClient.acceptOfferForNFTokens( + XrplClient.getAddressFromPrivateKey(configuration.XRPL_ACCOUNT_SECRET), + offer.buyOfferId, + listing.sellOfferId, + fee.toString(), + ); + + //cancel losing offers + const cancelTxn = XrplClient.cancelOfferForNFTokens( + XrplClient.getAddressFromPrivateKey(configuration.XRPL_ACCOUNT_SECRET), + losingOffers.map((losingOffer) => losingOffer.buyOfferId), + ); + + // submit transaction + const [acceptTxnResponse, rejectTxnResponse] = await XrplClient.signAndSubmitTransactions([ + acceptTxn, + cancelTxn, + ]); + + await ListingOfferService.accept(offerId, acceptTxnResponse.hash); //update winning offer in db + + //update losing offers in db + await Promise.all( + losingOffers + .map((losingOffer) => losingOffer.id) + .map((id) => { + ListingOfferService.reject(id, rejectTxnResponse.hash); + }), + ); + + await ListingService.completeListing(listing.id, acceptTxnResponse.hash); + } + + /** + * It selects the winning bid(if any) for the specified auction and brokers the sale. + * + * @param auctionId the id of the auction to complete + */ + async completeAuction(auctionId: string): Promise { + const auction = (await ListingService.getById(auctionId)) as Listing; // make listing compulsory + assert(auction != null, 'Auction id does not exist'); + const bids = await ListingOfferService.getByListing(auctionId); + ///select winning bid by price + const winningBid = bids.reduce((prevBid, currentBid) => + Number(prevBid.amount) > Number(currentBid.amount) ? prevBid : currentBid, + ); + + if (winningBid.amount.toNumber() > auction.price.toNumber()) { + await this.acceptListingOffer(winningBid.id); + } else { + await this.cancelListing(auctionId); + } + } + + /** + * Creates a new listing + * + * @param txHash the transaction hash of the NFTokenCreate Offer on the XRP blockchain + * @param type the type of the listing(REGULAR OR AUCTION) + * @param creator the account creating the listing + */ + async createListing(txHash: string, type: ListingType, creator: string | undefined): Promise { + const tx = (await XrplClient.getTransaction(txHash)).result; + const nftOffer = XrplClient.parseNFTCreateOfferFromTxnMetadata( + tx.meta as TransactionMetadata, + ) as NFTCreateOfferWithId; + const endAt = nftOffer.Expiration ? rippleTimeToUnixTime(nftOffer.Expiration) : undefined; + + assert(type == 'REGULAR' || type == 'AUCTION', 'Invalid listing type input'); + assert(tx.Account == creator, 'Not authorised'); + assert(nftOffer.Flags == 1, 'Offer type in transaction is not of right type'); + assert(nftOffer.TransactionType == 'NFTokenCreateOffer', "Transaction provided is of wrong type'"); + assert( + nftOffer.Destination == XrplClient.getAddressFromPrivateKey(configuration.XRPL_ACCOUNT_SECRET), + 'Transaction destination address does not belong to Optimart', + ); + if (type == 'AUCTION') { + assert(endAt != undefined, 'Auction types must have an expiration time'); + } + if (endAt) { + assert(endAt > Date.now(), 'Invalid value for ending timestamp'); + } + + const nft = await TokenService.getOrCreateByTokenId(nftOffer.NFTokenID); + const ongoingListings = await ListingService.all({ status: 'ONGOING', nftId: nft.tokenId }); + + assert( + ongoingListings.length == 0, + 'A listing for this token is still ongoing, cancel/close it before creating a new one', + ); + + const listing = await ListingService.create({ + creator: { + connectOrCreate: { + where: { + address: tx.Account, + }, + create: { + address: tx.Account, + }, + }, + }, + price: Number(nftOffer.Amount), + endAt: endAt ? new Date(endAt) : undefined, + type: type, + nft: { + connect: { + tokenId: nft.tokenId, + }, + }, + createTxnHash: txHash, + sellOfferId: nftOffer.id, + }); + return listing; + } + + /** + * It cancels a listing and all offers associated with it + * + * @param listingId the id of the auction to complete + * @param txHash the hash of the NFTokenOffer cancel transaction + */ + + async cancelListing(listingId: string, txHash?: string): Promise { + const offers = await ListingOfferService.getByListing(listingId); + const listing = (await ListingService.getById(listingId)) as Listing; + if (offers.length > 0) { + const cancelTxn = XrplClient.cancelOfferForNFTokens( + XrplClient.getAddressFromPrivateKey(configuration.XRPL_ACCOUNT_SECRET), + offers.map((offer) => offer.buyOfferId), + ); + + const [rejectTxnResponse] = await XrplClient.signAndSubmitTransactions([cancelTxn]); + + await Promise.all( + offers + .map((losingOffer) => losingOffer.id) + .map((id) => { + ListingOfferService.reject(id, rejectTxnResponse.hash); + }), + ); + } + if (!txHash) { + assert(listing != null, 'Listing id does not exist'); + const cancelTxn = XrplClient.cancelOfferForNFTokens( + XrplClient.getAddressFromPrivateKey(configuration.XRPL_ACCOUNT_SECRET), + [listing.sellOfferId], + ); + txHash = (await XrplClient.signAndSubmitTransactions([cancelTxn]))[0].hash; + } + if (listing.endAt && listing.endAt.getTime() > Date.now()) { + await ListingService.completeListing(listingId, txHash); + } else { + await ListingService.cancelListing(listingId, txHash); + } + } + + async completePendingListings(): Promise { + const pendingListings = await ListingService.getPendingListings(); + await Promise.all( + pendingListings.map(async (listing) => { + if (listing.type == 'AUCTION') { + await this.completeAuction(listing.id); + } else { + await this.cancelListing(listing.id); + } + }), + ); + } +} + +export default new NFTBroker(); diff --git a/src/utils/index.ts b/src/utils/index.ts index 4cc36f2..f9378a3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ export { default as XrplClient } from './xrpl-client'; export { default as NftService } from './nft-metadata'; +export { default as NFTBroker } from './broker'; export { default as ApiError } from './api-error'; export * from './catch-async'; export * from './pick'; diff --git a/src/utils/xrpl-client.ts b/src/utils/xrpl-client.ts index 24dcd25..ca75a49 100644 --- a/src/utils/xrpl-client.ts +++ b/src/utils/xrpl-client.ts @@ -6,10 +6,14 @@ import { NFTokenCancelOffer, NFTokenCreateOffer, Transaction, + TransactionMetadata, + TxResponse, + Wallet, deriveAddress, deriveKeypair, } from 'xrpl'; import { Amount, NFTOffer } from 'xrpl/dist/npm/models/common'; +import { CreatedNode } from 'xrpl/dist/npm/models/transactions/metadata'; import configuration from '../config/config'; import { NFTHistoryTxnsResponse, NFTInfoResponse, NFTOffersResponse } from '../interfaces'; @@ -67,7 +71,7 @@ class XrplClient { * @returns A Promise that resolves to the account information response. * @throws {Error} If there's an issue with the XRP Ledger client or the request. */ - async getTransaction(txHash: string): Promise { + async getTransaction(txHash: string): Promise { try { await this.connect(); const response = await this.client.request({ @@ -75,12 +79,26 @@ class XrplClient { transaction: txHash, ledger_index: 'validated', }); - return response.result; + return response; } finally { await this.disconnect(); } } + async signAndSubmitTransactions(txns: Transaction[]): Promise<{ hash: string }[]> { + const processedTxns = await Promise.all( + txns.map(async (txn) => { + return ( + await this.client.submitAndWait(txn, { + wallet: Wallet.fromSecret(configuration.XRPL_ACCOUNT_SECRET), + }) + ).result; + }), + ); + + return processedTxns; + } + /** * Retrieves tha NFT information of the address from the XRP Ledger. * @@ -304,6 +322,17 @@ class XrplClient { return []; } } + + parseNFTCreateOfferFromTxnMetadata(meta: TransactionMetadata): unknown { + const nftOfferMeta = meta.AffectedNodes.find((txnNode) => { + const coercedNode = txnNode as CreatedNode; + return ( + coercedNode['CreatedNode'] && coercedNode['CreatedNode']['LedgerEntryType'] == 'NFTokenOffer' + ); + }) as CreatedNode; + + return { id: nftOfferMeta.CreatedNode.LedgerIndex, ...nftOfferMeta.CreatedNode.NewFields } as unknown; + } } export default new XrplClient(); diff --git a/src/validators/offer.ts b/src/validators/offer.ts new file mode 100644 index 0000000..32d45a8 --- /dev/null +++ b/src/validators/offer.ts @@ -0,0 +1,59 @@ +import Joi from 'joi'; + +const getById = { + params: Joi.object().keys({ + id: Joi.string().required(), + }), +}; + +const getAll = { + query: Joi.object().keys({ + offeree: Joi.string(), + offeror: Joi.string(), + listing: Joi.string(), + nftId: Joi.string(), + offeredAfter: Joi.date(), + offeredBefore: Joi.date(), + offset: Joi.number().integer().default(0), + limit: Joi.number().integer().max(1000).default(1000), + status: Joi.string().valid('PENDING', 'CANCELLED', 'ACCEPTED', 'REJECTED'), + }), +}; + +const create = { + body: Joi.object().keys({ + txHash: Joi.string().required(), + extraPayload: Joi.object({ + listingId: Joi.string(), + }), + }), +}; + +const cancel = { + body: Joi.object().keys({ + txHash: Joi.string().required(), + extraPayload: Joi.object({ + offerId: Joi.string().required(), + }), + }), +}; + +const reject = { + body: Joi.object().keys({ + txHash: Joi.string().required(), + extraPayload: Joi.object({ + offerId: Joi.string().required(), + }), + }), +}; + +const accept = { + body: Joi.object().keys({ + txHash: Joi.string().required(), + extraPayload: Joi.object({ + offerId: Joi.string().required(), + }), + }), +}; + +export default { getById, getAll, create, cancel, reject, accept }; diff --git a/src/validators/user.ts b/src/validators/user.ts new file mode 100644 index 0000000..a6793bb --- /dev/null +++ b/src/validators/user.ts @@ -0,0 +1,14 @@ +import Joi from 'joi'; + +const searchUser = { + query: Joi.object().keys({ + q: Joi.string().required(), + limit: Joi.number().default(5).integer(), + }), +}; + +const getUser = { + params: Joi.object().keys({ + id: Joi.string().required(), + }), +}; diff --git a/tests/xrpl-client.spec.ts b/tests/xrpl-client.spec.ts index 9a2bf2c..4d3337e 100644 --- a/tests/xrpl-client.spec.ts +++ b/tests/xrpl-client.spec.ts @@ -1,3 +1,5 @@ +import { TransactionMetadata } from 'xrpl'; + import { XrplClient } from '../src/utils'; describe('XRPL client', () => { @@ -8,4 +10,12 @@ describe('XRPL client', () => { expect(result.account_data.Balance).toBe('999999990'); expect(result.account_data.Account).toBe('ra8cAACGKrwDSYdoUdbQUSH4AEh3GMQSmv'); }); + + it('should parse a transaction metadata', async () => { + const { result } = await XrplClient.getTransaction( + '7A5FA356493DD0912B2990A9D99706859199F6E1A48701B7D02CA7E891FC9413', + ); + const res = XrplClient.parseNFTCreateOfferFromTxnMetadata(result.meta as TransactionMetadata); + console.log(res); + }); });