diff --git a/package.json b/package.json index a59540cd7..fc106605a 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "graphql-request": "^5", "html-to-draftjs": "^1.5.0", "immutable": "^4.0.0", + "keccak256": "^1.0.6", "kubernetes-models": "^4.3.1", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", diff --git a/packages/hooks/launchpad/useCreateCollection.ts b/packages/hooks/launchpad/useCreateCollection.ts new file mode 100644 index 000000000..5c9b39bd3 --- /dev/null +++ b/packages/hooks/launchpad/useCreateCollection.ts @@ -0,0 +1,245 @@ +import keccak256 from "keccak256"; // Tested and this lib is compatible with merkle tree libs from Rust and Go +import { MerkleTree } from "merkletreejs"; +import { useCallback } from "react"; +import { useSelector } from "react-redux"; + +import { useFeedbacks } from "@/context/FeedbacksProvider"; +import { + Coin, + MintPeriod, + NftLaunchpadClient, + WhitelistInfo, +} from "@/contracts-clients/nft-launchpad"; +import { PinataFileProps, useIpfs } from "@/hooks/useIpfs"; +import { useSelectedNetworkId } from "@/hooks/useSelectedNetwork"; +import useSelectedWallet from "@/hooks/useSelectedWallet"; +import { getNetworkFeature, NetworkFeature } from "@/networks"; +import { getKeplrSigningCosmWasmClient } from "@/networks/signer"; +import { selectNFTStorageAPI } from "@/store/slices/settings"; +import { generateIpfsKey } from "@/utils/ipfs"; +import { LocalFileData } from "@/utils/types/files"; +import { + CollectionAssetsMetadatasFormValues, + CollectionFormValues, + CollectionMintPeriodFormValues, + CollectionToSubmit, +} from "@/utils/types/launchpad"; + +export const useCreateCollection = () => { + // Since the Collection network is the selected network, we use useSelectedNetworkId (See LaunchpadBasic.tsx) + const selectedNetworkId = useSelectedNetworkId(); + const selectedWallet = useSelectedWallet(); + const { setToast } = useFeedbacks(); + const userIPFSKey = useSelector(selectNFTStorageAPI); + const { pinataPinFileToIPFS, uploadFilesToPinata } = useIpfs(); + + // TODO: Uncomment when the NFT Launchpad MyCollections frontend and useCompleteCollection are merged + // const { completeCollection } = useCompleteCollection(); + + const createCollection = useCallback( + async (collectionFormValues: CollectionFormValues) => { + if (!selectedWallet) return false; + const userId = selectedWallet.userId; + const walletAddress = selectedWallet.address; + + const signingComswasmClient = + await getKeplrSigningCosmWasmClient(selectedNetworkId); + const cosmwasmNftLaunchpadFeature = getNetworkFeature( + selectedNetworkId, + NetworkFeature.CosmWasmNFTLaunchpad, + ); + if (!cosmwasmNftLaunchpadFeature) return false; + + const nftLaunchpadContractClient = new NftLaunchpadClient( + signingComswasmClient, + walletAddress, + cosmwasmNftLaunchpadFeature.launchpadContractAddress, + ); + const pinataJWTKey = + collectionFormValues.assetsMetadatas?.nftApiKey || + userIPFSKey || + (await generateIpfsKey(selectedNetworkId, userId)); + if (!pinataJWTKey) { + console.error("Project creation error: No Pinata JWT"); + setToast({ + mode: "normal", + type: "error", + title: "Project creation error: No Pinata JWT", + }); + return false; + } + + try { + // ========== Cover image + const fileIpfsHash = await pinataPinFileToIPFS({ + pinataJWTKey, + file: collectionFormValues.coverImage, + } as PinataFileProps); + if (!fileIpfsHash) { + console.error("Project creation error: Pin to Pinata failed"); + setToast({ + mode: "normal", + type: "error", + title: "Project creation error: Pin to Pinata failed", + }); + return false; + } + + // ========== Whitelists + const whitelistAddressesFilesToUpload: LocalFileData[] = []; + collectionFormValues.mintPeriods.forEach((mintPeriod) => { + if (mintPeriod.whitelistAddressesFile) + whitelistAddressesFilesToUpload.push( + mintPeriod.whitelistAddressesFile, + ); + }); + const remoteWhitelistAddressesFiles = await uploadFilesToPinata({ + pinataJWTKey, + files: whitelistAddressesFilesToUpload, + }); + const mint_periods: MintPeriod[] = collectionFormValues.mintPeriods.map( + (mintPeriod: CollectionMintPeriodFormValues, index) => { + let whitelist_info: WhitelistInfo | null = null; + if ( + mintPeriod.whitelistAddresses?.length && + remoteWhitelistAddressesFiles[index].url + ) { + const addresses: string[] = mintPeriod.whitelistAddresses; + const leaves = addresses.map(keccak256); + const tree = new MerkleTree(leaves, keccak256); + const merkleRoot = tree.getRoot().toString("hex"); + whitelist_info = { + addresses_count: addresses.length, + addresses_ipfs: remoteWhitelistAddressesFiles[index].url, + addresses_merkle_root: merkleRoot, + }; + } + const price: Coin | null = mintPeriod.price + ? { + amount: mintPeriod.price.amount || "0", + denom: mintPeriod.price.denom, + } + : null; + return { + price, + end_time: mintPeriod.endTime, + max_tokens: mintPeriod.maxTokens + ? parseInt(mintPeriod.maxTokens, 10) + : 0, + limit_per_address: mintPeriod.perAddressLimit + ? parseInt(mintPeriod.perAddressLimit, 10) + : 0, + start_time: mintPeriod.startTime, + whitelist_info, + }; + }, + ); + + const assetsMetadataFormsValues: + | CollectionAssetsMetadatasFormValues + | undefined + | null = collectionFormValues.assetsMetadatas; + + // ========== Final collection + const collection: CollectionToSubmit = { + name: collectionFormValues.name, + desc: collectionFormValues.description, + symbol: collectionFormValues.symbol, + website_link: collectionFormValues.websiteLink, + contact_email: collectionFormValues.email, + project_type: collectionFormValues.projectTypes.join(), + tokens_count: assetsMetadataFormsValues?.assetsMetadatas?.length || 0, + reveal_time: collectionFormValues.revealTime, + team_desc: collectionFormValues.teamDescription, + partners: collectionFormValues.partnersDescription, + investment_desc: collectionFormValues.investDescription, + investment_link: collectionFormValues.investLink, + artwork_desc: collectionFormValues.artworkDescription, + cover_img_uri: "ipfs://" + fileIpfsHash, + is_applied_previously: collectionFormValues.isPreviouslyApplied, + is_project_derivative: collectionFormValues.isDerivativeProject, + is_ready_for_mint: collectionFormValues.isReadyForMint, + is_dox: collectionFormValues.isDox, + escrow_mint_proceeds_period: parseInt( + collectionFormValues.escrowMintProceedsPeriod, + 10, + ), + dao_whitelist_count: parseInt( + collectionFormValues.daoWhitelistCount, + 10, + ), + mint_periods, + royalty_address: collectionFormValues.royaltyAddress, + royalty_percentage: collectionFormValues.royaltyPercentage + ? parseInt(collectionFormValues.royaltyPercentage, 10) + : null, + target_network: selectedNetworkId, + }; + // TODO: Uncomment when the NFT Launchpad MyCollections frontend and useCompleteCollection are merged + // const collectionId = collectionFormValues.symbol; + + // ========== Submit the collection through the contract + await nftLaunchpadContractClient.submitCollection({ + collection, + }); + + // ========== Handle assets metadata + if (!assetsMetadataFormsValues?.assetsMetadatas?.length) { + setToast({ + mode: "normal", + type: "success", + title: "Project submitted (Incomplete)", + message: "You will need to Complete the Project", + }); + } else { + const isCompleteSuccess = false; + // TODO: Uncomment when the NFT Launchpad MyCollections frontend and useCompleteCollection are merged + // await completeCollection( + // collectionId, + // assetsMetadataFormsValues, + // ); + + if (!isCompleteSuccess) { + setToast({ + mode: "normal", + type: "warning", + title: "Project submitted (Incomplete)", + message: + "Error during uploading the Assets.\nYou will need to Complete the Project", + }); + } else { + setToast({ + mode: "normal", + type: "success", + title: "Project submitted", + }); + } + } + + return true; + } catch (e: any) { + console.error("Error creating a NFT Collection in the Launchpad: ", e); + setToast({ + mode: "normal", + type: "error", + title: "Error creating a NFT Collection in the Launchpad", + message: e.message, + }); + } + }, + [ + pinataPinFileToIPFS, + selectedWallet, + userIPFSKey, + uploadFilesToPinata, + selectedNetworkId, + setToast, + // TODO: Uncomment when the NFT Launchpad MyCollections frontend and useCompleteCollection are merged + // completeCollection, + ], + ); + + return { + createCollection, + }; +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/LaunchpadCreateScreen.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/LaunchpadCreateScreen.tsx index 63d72bb85..d79675207 100644 --- a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/LaunchpadCreateScreen.tsx +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/LaunchpadCreateScreen.tsx @@ -10,6 +10,7 @@ import { PrimaryButton } from "@/components/buttons/PrimaryButton"; import { SecondaryButton } from "@/components/buttons/SecondaryButton"; import { SpacerColumn } from "@/components/spacer"; import { useFeedbacks } from "@/context/FeedbacksProvider"; +import { useCreateCollection } from "@/hooks/launchpad/useCreateCollection"; import { useSelectedNetworkInfo } from "@/hooks/useSelectedNetwork"; import { NetworkFeature } from "@/networks"; import { @@ -53,10 +54,7 @@ export const LaunchpadCreateScreen: ScreenFC<"LaunchpadCreate"> = () => { }, resolver: zodResolver(ZodCollectionFormValues), }); - - // TODO: Uncomment when the NFT Launchpad backend is merged - // const { createCollection } = useCreateCollection(); - + const { createCollection } = useCreateCollection(); const [selectedStepKey, setSelectedStepKey] = useState(1); const [isLoading, setLoading] = useState(false); @@ -88,8 +86,9 @@ export const LaunchpadCreateScreen: ScreenFC<"LaunchpadCreate"> = () => { setLoading(true); setLoadingFullScreen(true); try { - // TODO: Uncomment when the NFT Launchpad backend is merged - // const success = await createCollection(collectionForm.getValues()); + // TODO: Uncomment when the NFT Launchpad MyCollections frontend is merged + // const success = + await createCollection(collectionForm.getValues()); // TODO: Uncomment when the NFT Launchpad MyCollections frontend is merged // if (success) navigation.navigate("LaunchpadMyCollections"); } catch (e) { diff --git a/packages/utils/types/launchpad.ts b/packages/utils/types/launchpad.ts index 0ac5fb2a2..57a88bd84 100644 --- a/packages/utils/types/launchpad.ts +++ b/packages/utils/types/launchpad.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { CollectionProject } from "@/contracts-clients/nft-launchpad"; import { DEFAULT_FORM_ERRORS } from "@/utils/errors"; import { isIpfsPathValid } from "@/utils/ipfs"; import { @@ -169,3 +170,12 @@ export type CollectionAssetsMetadataFormValues = z.infer< export type CollectionAssetsMetadatasFormValues = z.infer< typeof ZodCollectionAssetsMetadatasFormValues >; + +// ===== Shapes to build objects to sent to API +export type CollectionToSubmit = Omit< + CollectionProject, + "deployed_address" | "base_token_uri" | "owner" +>; + +// ===== Shapes to build objects received from API +// ...Coming soon diff --git a/yarn.lock b/yarn.lock index 2f0530e1f..71c0810d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14765,6 +14765,29 @@ __metadata: languageName: node linkType: hard +"keccak256@npm:^1.0.6": + version: 1.0.6 + resolution: "keccak256@npm:1.0.6" + dependencies: + bn.js: ^5.2.0 + buffer: ^6.0.3 + keccak: ^3.0.2 + checksum: decafb4b37adcfa6d06b6a5d28546d0d7a9f01ccf4b8cc8963cf8188fcc79a230d7e22988e860813623c602d764259734423e38fd7b9aadfeb409d6928a1d4cf + languageName: node + linkType: hard + +"keccak@npm:^3.0.2": + version: 3.0.4 + resolution: "keccak@npm:3.0.4" + dependencies: + node-addon-api: ^2.0.0 + node-gyp: latest + node-gyp-build: ^4.2.0 + readable-stream: ^3.6.0 + checksum: 2bf27b97b2f24225b1b44027de62be547f5c7326d87d249605665abd0c8c599d774671c35504c62c9b922cae02758504c6f76a73a84234d23af8a2211afaaa11 + languageName: node + linkType: hard + "keyv@npm:^4.0.0, keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -16329,6 +16352,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^2.0.0": + version: 2.0.2 + resolution: "node-addon-api@npm:2.0.2" + dependencies: + node-gyp: latest + checksum: 31fb22d674648204f8dd94167eb5aac896c841b84a9210d614bf5d97c74ef059cc6326389cf0c54d2086e35312938401d4cc82e5fcd679202503eb8ac84814f8 + languageName: node + linkType: hard + "node-dir@npm:^0.1.17": version: 0.1.17 resolution: "node-dir@npm:0.1.17" @@ -16359,6 +16391,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.2.0": + version: 4.8.4 + resolution: "node-gyp-build@npm:4.8.4" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 8b81ca8ffd5fa257ad8d067896d07908a36918bc84fb04647af09d92f58310def2d2b8614d8606d129d9cd9b48890a5d2bec18abe7fcff54818f72bedd3a7d74 + languageName: node + linkType: hard + "node-gyp-build@npm:^4.3.0": version: 4.8.0 resolution: "node-gyp-build@npm:4.8.0" @@ -20466,6 +20509,7 @@ __metadata: graphql-request: ^5 html-to-draftjs: ^1.5.0 immutable: ^4.0.0 + keccak256: ^1.0.6 kubernetes-models: ^4.3.1 leaflet: ^1.9.4 leaflet.markercluster: ^1.5.3