Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TAS-958] ✨ Support auto deliver in collection #621

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/util/ValidationHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ export function filterNFTCollectionTypePayload(type, payload, isOwner = false) {
notificationEmails,
moderatorWallets,
connectedWallets,
isAutoDeliver,
} = payload;
if (!isOwner) {
return {
Expand All @@ -601,6 +602,7 @@ export function filterNFTCollectionTypePayload(type, payload, isOwner = false) {
notificationEmails,
moderatorWallets,
connectedWallets,
isAutoDeliver,
};
}
return {
Expand Down
57 changes: 46 additions & 11 deletions src/util/api/likernft/book/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,32 +367,67 @@ export async function validateStocks(
};
}

export async function validateAutoDeliverNFTsTxHash(
txHash: string,
classId: string,
sender: string,
expectedNFTCount: number,
) {
async function parseNFTIdsMapFromTxHash(txHash: string, sender: string) {
if (!txHash) throw new ValidationError('TX_HASH_IS_EMPTY');
const client = await getClient();
const tx = await client.getTx(txHash);
if (!tx) throw new ValidationError('TX_NOT_FOUND');
const { code, tx: rawTx } = tx;
if (code) throw new ValidationError('TX_FAILED');
const { body } = decodeTxRaw(rawTx);
const nftIds = body.messages
const sendMessages = body.messages
.filter((m) => m.typeUrl === '/cosmos.nft.v1beta1.MsgSend')
.map(((m) => MsgSend.decode(m.value)))
.filter((m) => m.classId === classId
&& m.sender === sender
&& m.receiver === LIKER_NFT_TARGET_ADDRESS)
.map((m) => m.id);
.filter((m) => m.sender === sender
&& m.receiver === LIKER_NFT_TARGET_ADDRESS);
const nftIdsMap = {};
sendMessages.forEach((m) => {
nftIdsMap[m.classId] ??= [];
nftIdsMap[m.classId].push(m.id);
});
return nftIdsMap;
}

export async function validateAutoDeliverNFTsTxHash(
txHash: string,
classId: string,
sender: string,
expectedNFTCount: number,
) {
const nftIdsMap = await parseNFTIdsMapFromTxHash(txHash, sender);
const nftIds = nftIdsMap[classId];
if (!nftIds) {
throw new ValidationError(`TX_SEND_NFT_CLASS_ID_NOT_FOUND: ${classId}`);
}
if (nftIds.length < expectedNFTCount) {
throw new ValidationError(`TX_SEND_NFT_COUNT_NOT_ENOUGH: EXPECTED: ${expectedNFTCount}, ACTUAL: ${nftIds.length}`);
}
return nftIds;
}

// TODO: replace validateAutoDeliverNFTsTxHash with this
export async function validateAutoDeliverNFTsTxHashV2({
txHash,
sender,
expectedNFTCountMap,
}: {
txHash: string;
sender: string;
expectedNFTCountMap: Record<string, number>;
}) {
const nftIdsMap = await parseNFTIdsMapFromTxHash(txHash, sender);
Object.entries(expectedNFTCountMap).forEach(([classId, expectedNFTCount]) => {
const nftIds = nftIdsMap[classId];
if (!nftIds) {
throw new ValidationError(`TX_SEND_NFT_CLASS_ID_NOT_FOUND: ${classId}`);
}
if (nftIds.length < expectedNFTCount) {
throw new ValidationError(`TX_SEND_NFT_COUNT_NOT_ENOUGH: EXPECTED: ${expectedNFTCount}, ACTUAL: ${nftIds.length}`);
}
});
return nftIdsMap;
}

export function getNFTBookStoreSendPageURL(classId: string, paymentId: string) {
return `https://${NFT_BOOKSTORE_HOSTNAME}/nft-book-store/send/${classId}/?payment_id=${paymentId}`;
}
Expand Down
136 changes: 128 additions & 8 deletions src/util/api/likernft/collection/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { v4 as uuidv4 } from 'uuid';
import { FieldValue, likeNFTCollectionCollection } from '../../../firebase';
import { FieldValue, db, likeNFTCollectionCollection } from '../../../firebase';
import { filterNFTCollection } from '../../../ValidationHelper';
import { ValidationError } from '../../../ValidationError';
import { validatePrice } from '../book';
import { validateAutoDeliverNFTsTxHashV2, validatePrice } from '../book';
import { getISCNFromNFTClassId, getNFTsByClassId } from '../../../cosmos/nft';
import { sleep } from '../../../misc';
import { FIRESTORE_BATCH_SIZE } from '../../../../constant';

export type CollectionType = 'book' | 'reader' | 'creator';
export const COLLECTION_TYPES: CollectionType[] = ['book', 'reader', 'creator'];
Expand Down Expand Up @@ -80,6 +82,7 @@ async function validateCollectionTypeData(
notificationEmails,
moderatorWallets,
connectedWallets,
isAutoDeliver,
} = data;
validatePrice({
priceInDecimal,
Expand All @@ -96,9 +99,11 @@ async function validateCollectionTypeData(
// if (ownerWallet !== wallet) {
// throw new ValidationError(`NOT_OWNER_OF_NFT_CLASS: ${classId}`, 403);
// }
const { nfts } = await getNFTsByClassId(classId, wallet);
if (nfts.length < stock) {
throw new ValidationError(`NOT_ENOUGH_NFT_COUNT: ${classId}`, 403);
if (!isAutoDeliver) {
const { nfts } = await getNFTsByClassId(classId, wallet);
if (nfts.length < stock) {
throw new ValidationError(`NOT_ENOUGH_NFT_COUNT: ${classId}`, 403);
}
}
}),
);
Expand All @@ -113,6 +118,7 @@ async function validateCollectionTypeData(
notificationEmails,
moderatorWallets,
connectedWallets,
isAutoDeliver,
}),
);
} else if (type === 'reader') {
Expand All @@ -125,6 +131,24 @@ async function validateCollectionTypeData(
return typePayload;
}

function calculateExpectedNFTCountMap(
oldClassIds: string[],
oldStock: number,
newClassIds: string[],
newStock: number,
): { [classId: string]: number } {
const map = {};
const stockDiff = newStock - oldStock;
const addedClassIds = newClassIds.filter((classId) => !oldClassIds.includes(classId));
oldClassIds.forEach((classId) => {
map[classId] = stockDiff;
});
addedClassIds.forEach((classId) => {
map[classId] = newStock;
});
return map;
}

export async function createNFTCollectionByType(
wallet: string,
type: CollectionType,
Expand All @@ -133,9 +157,17 @@ export async function createNFTCollectionByType(
const collectionId = `col_${type}_${uuidv4()}`;
const typePayload = await validateCollectionTypeData(wallet, type, payload);
const {
classIds = [], name, description, image,
classIds = [],
name,
description,
image,
isAutoDeliver,
autoDeliverNFTsTxHash,
stock,
} = payload;
await likeNFTCollectionCollection.doc(collectionId).create({

let batch = db.batch();
batch.create(likeNFTCollectionCollection.doc(collectionId), {
ownerWallet: wallet,
classIds,
name,
Expand All @@ -149,6 +181,54 @@ export async function createNFTCollectionByType(
timestamp: FieldValue.serverTimestamp(),
lastUpdatedTimestamp: FieldValue.serverTimestamp(),
});

if (isAutoDeliver && stock > 0) {
const expectedNFTCountMap = calculateExpectedNFTCountMap(
[],
0,
classIds,
stock,
);
const classIdToNFTIdsMap = await validateAutoDeliverNFTsTxHashV2({
txHash: autoDeliverNFTsTxHash,
sender: wallet,
expectedNFTCountMap,
});

const nftIdMapArray: Record<string, number>[] = Array(stock).fill(null).map(() => ({}));
nftIdMapArray.forEach((nftIdMap, i) => {
classIds.forEach((classId) => {
const nftId = classIdToNFTIdsMap[classId][i];
// eslint-disable-next-line no-param-reassign
nftIdMap[classId] = nftId;
});
});

for (let i = 0; i < stock; i += 1) {
if ((i + 1) % FIRESTORE_BATCH_SIZE === 0) {
// eslint-disable-next-line no-await-in-loop
await batch.commit();
// TODO: remove this after solving API CPU hang error
await sleep(10);
batch = db.batch();
}
const docNum = i + 1;
batch.create(
likeNFTCollectionCollection
.doc(collectionId)
.collection('nft')
.doc(docNum),
{
nftIdMap: nftIdMapArray[i],
isSold: false,
isProcessing: false,
timestamp: FieldValue.serverTimestamp(),
},
);
}
}

await batch.commit();
return {
id: collectionId,
ownerWallet: wallet,
Expand Down Expand Up @@ -178,15 +258,34 @@ export async function patchNFTCollectionById(
name: docName,
description: docDescription,
classIds: docClassIds,
isAutoDeliver,
} = docData;
if (ownerWallet !== wallet) { throw new ValidationError('NOT_OWNER_OF_COLLECTION', 403); }
const {
classIds: newClassIds,
name: newName,
description: newDescription,
image,
stock: newStock,
isAutoDeliver: newIsAutoDeliver,
autoDeliverNFTsTxHash,
} = payload;

if (isAutoDeliver) {
if (!newIsAutoDeliver) {
throw new ValidationError('CANNOT_CHANGE_DELIVERY_METHOD_OF_AUTO_DELIVER_COLLECTION', 403);
}

if (newStock < typePayload.stock) {
throw new ValidationError('CANNOT_DECREASE_STOCK_OF_AUTO_DELIVERY_COLLECTION', 403);
}

const someDocClassIdNotIncluded = docClassIds.some((classId) => !newClassIds.includes(classId));
if (someDocClassIdNotIncluded) {
throw new ValidationError('CANNOT_REMOVE_CLASS_ID_OF_AUTO_DELIVERY_COLLECTION', 403);
}
}

const newTypePayload = await validateCollectionTypeData(wallet, type, {
name: newName || docName,
description: newDescription || docDescription,
Expand All @@ -206,7 +305,28 @@ export async function patchNFTCollectionById(
if (newName !== undefined) updatePayload.name = newName;
if (newDescription !== undefined) updatePayload.description = newDescription;
if (image !== undefined) updatePayload.image = image;
await likeNFTCollectionCollection.doc(collectionId).update(updatePayload);

const batch = db.batch();
batch.update(likeNFTCollectionCollection.doc(collectionId), updatePayload);

const expectedNFTCountMap = calculateExpectedNFTCountMap(
docClassIds,
typePayload.stock,
newClassIds,
newStock,
);
const shouldUpdateNFTId = Object.values(expectedNFTCountMap).some((count) => count > 0);
if (shouldUpdateNFTId) {
const classIdToNFTIdsMap = await validateAutoDeliverNFTsTxHashV2({
txHash: autoDeliverNFTsTxHash,
sender: wallet,
expectedNFTCountMap,
});

// TODO: update existing NFT doc
}

await batch.commit();
}

export async function removeNFTCollectionById(
Expand Down