From 0a4d5204f450c9b786c00852ff21f320c53c5b95 Mon Sep 17 00:00:00 2001 From: Arnau Espin <43625217+aspnxdd@users.noreply.github.com> Date: Wed, 4 May 2022 15:07:43 +0200 Subject: [PATCH 01/80] feat: add wallets providers & basic layout (#7) * wallets providers + layout * fixing PR to limit it to the scope of the PR --- components/CreateCM/Form.tsx | 110 ----------------------------------- pages/_app.tsx | 32 ++++++++-- 2 files changed, 26 insertions(+), 116 deletions(-) delete mode 100644 components/CreateCM/Form.tsx diff --git a/components/CreateCM/Form.tsx b/components/CreateCM/Form.tsx deleted file mode 100644 index f768a05..0000000 --- a/components/CreateCM/Form.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { FC, useState, useEffect } from "react"; -import { useWallet } from "@solana/wallet-adapter-react"; - -const Form: FC = () => { - const { publicKey } = useWallet(); - - const [dateTime, setDateTime] = useState(""); - const [time, setTime] = useState(""); - - function UTCify(dateTime: string, time: string): string { - // TODO - return ""; - } - - useEffect(() => { - console.log(UTCify(dateTime, time)); - }, [dateTime, time]); - - return ( -
-
- - - - - - - - - -
-
- ); -}; - -interface Props { - id: string; - text: string; - type: string; - defaultValue?: string; - value?: string; - setValue?: (value: string) => void; -} - -const FormInput: FC = ({ - id, - text, - type, - defaultValue, - value, - setValue, -}) => { - return ( - <> - - { - if (setValue) setValue(e.target.value); - }} - /> - - ); -}; - -export default Form; - -/** -"price": 0.01, -"number": 4, -"gatekeeper": { - "gatekeeperNetwork": "ignREusXmGrscGNUesoU9mxfds9AiYTezUKex2PsZV6", - "expireOnUse": true -}, -"solTreasuryAccount": "BoX451MZzydoVdZE4NFfmMT3J5Ztqo7YgUNbwwMfjPFu", -"splTokenAccount": null, -"splToken": null, -"goLiveDate": "3 May 2021 08:00:00 GMT", -"endSettings": null, -"whitelistMintSettings": null, -"hiddenSettings": null, -"storage": "arweave", -"ipfsInfuraProjectId": null, -"ipfsInfuraSecret": null, -"nftStorageKey": null, -"awsS3Bucket": null, -"noRetainAuthority": false, -"noMutable": false -**/ diff --git a/pages/_app.tsx b/pages/_app.tsx index 9774f6f..2fc0a9f 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -6,25 +6,45 @@ import { WalletProvider, } from "@solana/wallet-adapter-react"; import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; -import { PhantomWalletAdapter } from "@solana/wallet-adapter-wallets"; +import { + GlowWalletAdapter, + PhantomWalletAdapter, + SlopeWalletAdapter, + SolflareWalletAdapter, + SolletExtensionWalletAdapter, + SolletWalletAdapter, + TorusWalletAdapter, +} from '@solana/wallet-adapter-wallets'; import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; import {Wallet,Navbar} from "components/Layout"; import { clusterApiUrl } from "@solana/web3.js"; -// Default styles that can be overridden by your app require("@solana/wallet-adapter-react-ui/styles.css"); + + function MyApp({ Component, pageProps }: AppProps) { const network = WalletAdapterNetwork.Devnet; const endpoint = useMemo(() => clusterApiUrl(network), [network]); - const wallets = useMemo(() => [new PhantomWalletAdapter()], [network]); + const wallets = useMemo( + () => [ + new PhantomWalletAdapter(), + new GlowWalletAdapter(), + new SlopeWalletAdapter(), + new SolflareWalletAdapter({ network }), + new TorusWalletAdapter(), + new SolletWalletAdapter({ network }), + new SolletExtensionWalletAdapter({ network }), + ], + [network] +); return ( - - + + - +
From c981720684e84e7d647a4ea010ed71311f751685 Mon Sep 17 00:00:00 2001 From: Arnau Espin <43625217+aspnxdd@users.noreply.github.com> Date: Thu, 12 May 2022 15:37:26 +0200 Subject: [PATCH 02/80] Feat: upload candy machine (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wallets providers + layout * add cm functions + update form component * config cmv2 + verify assets * commit new changes Co-authored-by: Begoña Álvarez de la Cruz * Create Candy Machine Acc * arweave upload * upload cm arweave * add prettier * create types file and organize code * add typing * fix merge * fixes * fixes * Fix: rename cb to callback * rename ms to millseconds Co-authored-by: Begoña Álvarez de la Cruz --- .prettierrc | 6 + components/CreateCM/Form.tsx | 285 +++++++++++++ components/CreateCM/utils.ts | 20 + components/Layout/Navbar.tsx | 12 +- hooks/index.ts | 1 + hooks/useForm.tsx | 22 + lib/candy-machine/constants.ts | 151 +++++++ lib/candy-machine/types.ts | 112 ++++++ lib/candy-machine/upload/arweave.ts | 134 +++++++ lib/candy-machine/upload/cache.ts | 40 ++ lib/candy-machine/upload/config.ts | 344 ++++++++++++++++ lib/candy-machine/upload/helpers.ts | 141 +++++++ lib/candy-machine/upload/transactions.ts | 256 ++++++++++++ lib/candy-machine/upload/upload.ts | 490 +++++++++++++++++++++++ package.json | 7 + tsconfig.json | 2 +- yarn.lock | 386 +++++++++++++++++- 17 files changed, 2391 insertions(+), 18 deletions(-) create mode 100644 .prettierrc create mode 100644 components/CreateCM/Form.tsx create mode 100644 components/CreateCM/utils.ts create mode 100644 hooks/index.ts create mode 100644 hooks/useForm.tsx create mode 100644 lib/candy-machine/constants.ts create mode 100644 lib/candy-machine/types.ts create mode 100644 lib/candy-machine/upload/arweave.ts create mode 100644 lib/candy-machine/upload/cache.ts create mode 100644 lib/candy-machine/upload/config.ts create mode 100644 lib/candy-machine/upload/helpers.ts create mode 100644 lib/candy-machine/upload/transactions.ts create mode 100644 lib/candy-machine/upload/upload.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1a5676e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true + +} \ No newline at end of file diff --git a/components/CreateCM/Form.tsx b/components/CreateCM/Form.tsx new file mode 100644 index 0000000..e47040f --- /dev/null +++ b/components/CreateCM/Form.tsx @@ -0,0 +1,285 @@ +import React, { FC, useState } from 'react'; +import { + useWallet, + useAnchorWallet, + useConnection, +} from '@solana/wallet-adapter-react'; +import { useForm } from 'hooks'; +import { + getCandyMachineV2Config, + verifyAssets, + loadCandyProgramV2, +} from 'lib/candy-machine/upload/config'; +import { + CandyMachineConfig, + Gatekeeper, + StorageType, +} from 'lib/candy-machine/types'; +import { parseDateToUTC } from './utils'; +import { uploadV2 } from 'lib/candy-machine/upload/upload'; +import { AnchorProvider } from '@project-serum/anchor'; + +const Form: FC = () => { + const { publicKey } = useWallet(); + const anchorWallet = useAnchorWallet(); + const { connection } = useConnection(); + + const [files, setFiles] = useState([]); + + function isFormValid(): boolean { + // TODO add more conditions + // TODO add custom message to show error message + if (files.length === 0) return false; + if (files.length % 2 != 0) return false; + if (values['number-of-nfts'] * 2 != files.length) return false; + if (!values['date-mint'] || !values['time-mint']) return false; + if (values.price == 0 || isNaN(values.price)) return false; + if (values['number-of-nfts'] == 0 || isNaN(values['number-of-nfts'])) + return false; + + return true; + } + + function uploadAssets(e: React.ChangeEvent) { + if (!e.target.files || e.target.files.length == 0) { + window.alert('No files uploaded'); + return; + } + const fileList = new Array(); + Array.from(e.target.files).forEach((file) => { + fileList.push(file); + }); + setFiles(fileList); + } + + async function createCandyMachineV2() { + if (!isFormValid()) return; + const config: CandyMachineConfig = { + price: values.price, + number: values['number-of-nfts'], + gatekeeper: values.captcha ? Gatekeeper : null, + solTreasuryAccount: values['treasury-account'], + splTokenAccount: null, + splToken: null, + goLiveDate: parseDateToUTC(values['date-mint'], values['time-mint']), + endSettings: null, + whitelistMintSettings: null, + hiddenSettings: null, + storage: values.storage.toLowerCase() as StorageType, + ipfsInfuraProjectId: null, + ipfsInfuraSecret: null, + nftStorageKey: null, + awsS3Bucket: null, + noRetainAuthority: false, + noMutable: values.mutable, + arweaveJwk: null, + batchSize: null, + pinataGateway: null, + pinataJwt: null, + uuid: null, + }; + + if (publicKey && anchorWallet) { + const { supportedFiles, elemCount } = verifyAssets( + files, + config.storage, + config.number + ); + + const provider = new AnchorProvider(connection, anchorWallet, { + preflightCommitment: 'recent', + }); + + const anchorProgram = await loadCandyProgramV2(provider); + + const { + storage, + nftStorageKey, + ipfsInfuraProjectId, + number, + ipfsInfuraSecret, + pinataJwt, + pinataGateway, + arweaveJwk, + awsS3Bucket, + retainAuthority, + mutable, + batchSize, + price, + splToken, + treasuryWallet, + gatekeeper, + endSettings, + hiddenSettings, + whitelistMintSettings, + goLiveDate, + uuid, + } = await getCandyMachineV2Config(publicKey, config, anchorProgram); + + const startMilliseconds = Date.now(); + + console.log('started at: ' + startMilliseconds.toString()); + try { + await uploadV2({ + files: supportedFiles, + cacheName: 'example', + env: 'devnet', + totalNFTs: elemCount, + gatekeeper, + storage, + retainAuthority, + mutable, + // nftStorageKey, + // ipfsCredentials:null, + // pinataJwt, + // pinataGateway, + // awsS3Bucket, + batchSize, + price, + treasuryWallet, + anchorProgram, + walletKeyPair: anchorWallet, + // splToken, + endSettings, + hiddenSettings, + whitelistMintSettings, + goLiveDate, + // uuid, + // arweaveJwk, + rateLimit: null, + // collectionMintPubkey, + // setCollectionMint, + // rpcUrl, + }); + } catch (err) { + console.error('upload was not successful, please re-run.', err); + } + const endMilliseconds = Date.now(); + console.log(endMilliseconds.toString()); + } + } + + const initialState = { + price: 0, + 'number-of-nfts': 0, + 'treasury-account': '', + captcha: false, + mutable: false, + 'date-mint': '', + 'time-mint': '', + storage: '', + files: [], + } as const; + + const { onChange, onSubmit, values } = useForm( + createCandyMachineV2, + initialState + ); + + return ( +
+
+ + + + + + + + + + + {Object.keys(StorageType) + .filter((key) => key === 'Arweave') + .map((key) => ( + + + + + + +
+
+ ); +}; + +interface Input { + id: string; + text: string; + type: string; + defaultValue?: string; + value?: string; + onChange: (e: React.ChangeEvent) => void; +} + +const FormInput: FC = ({ + id, + text, + type, + defaultValue, + value, + onChange, +}) => { + return ( + <> + + + + ); +}; + +export default Form; diff --git a/components/CreateCM/utils.ts b/components/CreateCM/utils.ts new file mode 100644 index 0000000..d5a6dcd --- /dev/null +++ b/components/CreateCM/utils.ts @@ -0,0 +1,20 @@ +/** + * + * @param dateTime date to parse + * @param time time to parse + * @returns {string} time parsed to UCT + */ +export function parseDateToUTC(dateTime: string, time: string): string { + let UTCDate: string[] | string = new Date(dateTime) + .toDateString() + .slice(4) + .split(" "); + const _temp = UTCDate[0]; + UTCDate[0] = UTCDate[1]; + UTCDate[1] = _temp; + UTCDate = UTCDate.join(".").replaceAll(".", " "); + + const UTCTime = `${time}:00 GMT`; + + return `${UTCDate} ${UTCTime}`; + } \ No newline at end of file diff --git a/components/Layout/Navbar.tsx b/components/Layout/Navbar.tsx index 68566fb..8cf3fea 100644 --- a/components/Layout/Navbar.tsx +++ b/components/Layout/Navbar.tsx @@ -1,18 +1,18 @@ -import { PropsWithChildren, FC } from "react"; +import { FC } from "react"; import Link from "next/link"; -const Navbar: FC = ({}: PropsWithChildren) => { +const Navbar: FC = () => { return (
- - - + + +
); }; -const SideBarIcon = ({ +const SideBarElement = ({ text, tooltip, href, diff --git a/hooks/index.ts b/hooks/index.ts new file mode 100644 index 0000000..1506054 --- /dev/null +++ b/hooks/index.ts @@ -0,0 +1 @@ +export {default as useForm} from './useForm'; \ No newline at end of file diff --git a/hooks/useForm.tsx b/hooks/useForm.tsx new file mode 100644 index 0000000..5203825 --- /dev/null +++ b/hooks/useForm.tsx @@ -0,0 +1,22 @@ +import { useState } from "react"; + +const useForm = (callback: () => any, initialState: T) => { + const [values, setValues] = useState(initialState); + + const onChange = (event: React.ChangeEvent) => { + setValues({ ...values, [event.target.name]: event.target.type === "checkbox" ? event.target.checked : event.target.value }); + }; + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + await callback(); + }; + + return { + onChange, + onSubmit, + values, + }; +}; + +export default useForm; diff --git a/lib/candy-machine/constants.ts b/lib/candy-machine/constants.ts new file mode 100644 index 0000000..87a7192 --- /dev/null +++ b/lib/candy-machine/constants.ts @@ -0,0 +1,151 @@ +import { PublicKey, clusterApiUrl } from '@solana/web3.js'; +export const CANDY_MACHINE = 'candy_machine'; +export const AUCTION_HOUSE = 'auction_house'; +export const TOKEN_ENTANGLER = 'token_entangler'; +export const ESCROW = 'escrow'; +export const A = 'A'; +export const B = 'B'; +export const FEE_PAYER = 'fee_payer'; +export const TREASURY = 'treasury'; +export const MAX_NAME_LENGTH = 32; +export const MAX_URI_LENGTH = 200; +export const MAX_SYMBOL_LENGTH = 10; +export const MAX_CREATOR_LEN = 32 + 1 + 1; +export const MAX_CREATOR_LIMIT = 5; +export const ARWEAVE_PAYMENT_WALLET = new PublicKey( + '6FKvsq4ydWFci6nGq9ckbjYMtnmaqAoatz5c9XWjiDuS', +); +export const CANDY_MACHINE_PROGRAM_ID = new PublicKey( + 'cndyAnrLdpjq1Ssp1z8xxDsB8dxe7u4HL5Nxi2K5WXZ', +); + +export const CANDY_MACHINE_PROGRAM_V2_ID = new PublicKey( + 'cndy3Z4yapfJBmL3ShUp5exZKqR3z33thTzeNMm2gRZ', +); +export const TOKEN_METADATA_PROGRAM_ID = new PublicKey( + 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s', +); +export const SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID = new PublicKey( + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL', +); +export const TOKEN_PROGRAM_ID = new PublicKey( + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', +); +export const FAIR_LAUNCH_PROGRAM_ID = new PublicKey( + 'faircnAB9k59Y4TXmLabBULeuTLgV7TkGMGNkjnA15j', +); +export const AUCTION_HOUSE_PROGRAM_ID = new PublicKey( + 'hausS13jsjafwWwGqZTUQRmWyvyxn9EQpqMwV1PBBmk', +); +export const TOKEN_ENTANGLEMENT_PROGRAM_ID = new PublicKey( + 'qntmGodpGkrM42mN68VCZHXnKqDCT8rdY23wFcXCLPd', +); +export const WRAPPED_SOL_MINT = new PublicKey( + 'So11111111111111111111111111111111111111112', +); + +export const ARWEAVE_UPLOAD_ENDPOINT = + 'https://us-central1-metaplex-studios.cloudfunctions.net/uploadFile'; + +export const CONFIG_ARRAY_START = + 32 + // authority + 4 + + 6 + // uuid + u32 len + 4 + + 10 + // u32 len + symbol + 2 + // seller fee basis points + 1 + + 4 + + 5 * 34 + // optional + u32 len + actual vec + 8 + //max supply + 1 + //is mutable + 1 + // retain authority + 4; // max number of lines; + +export const CONFIG_ARRAY_START_V2 = + 8 + // key + 32 + // authority + 32 + //wallet + 33 + // token mint + 4 + + 6 + // uuid + 8 + // price + 8 + // items available + 9 + // go live + 10 + // end settings + 4 + + MAX_SYMBOL_LENGTH + // u32 len + symbol + 2 + // seller fee basis points + 4 + + MAX_CREATOR_LIMIT * MAX_CREATOR_LEN + // optional + u32 len + actual vec + 8 + //max supply + 1 + // is mutable + 1 + // retain authority + 1 + // option for hidden setting + 4 + + MAX_NAME_LENGTH + // name length, + 4 + + MAX_URI_LENGTH + // uri length, + 32 + // hash + 4 + // max number of lines; + 8 + // items redeemed + 1 + // whitelist option + 1 + // whitelist mint mode + 1 + // allow presale + 9 + // discount price + 32 + // mint key for whitelist + 1 + + 32 + + 1; // gatekeeper + +export const CONFIG_LINE_SIZE_V2 = 4 + 32 + 4 + 200; +export const CONFIG_LINE_SIZE = 4 + 32 + 4 + 200; + +export const CACHE_PATH = './.cache'; + +export const DEFAULT_TIMEOUT = 30000; + +export const EXTENSION_PNG = '.png'; +export const EXTENSION_JPG = '.jpg'; +export const EXTENSION_GIF = '.gif'; +export const EXTENSION_MP4 = '.mp4'; +export const EXTENSION_MOV = '.mov'; +export const EXTENSION_MP3 = '.mp3'; +export const EXTENSION_FLAC = '.flac'; +export const EXTENSION_WAV = '.wav'; +export const EXTENSION_GLB = '.glb'; +export const EXTENSION_HTML = '.html'; +export const EXTENSION_JSON = '.json'; + +type Cluster = { + name: string; + url: string; +}; +export const CLUSTERS: Cluster[] = [ + { + name: 'mainnet-beta', + url: 'https://api.metaplex.solana.com/', + }, + { + name: 'testnet', + url: clusterApiUrl('testnet'), + }, + { + name: 'devnet', + url: clusterApiUrl('devnet'), + }, +]; +export const DEFAULT_CLUSTER = CLUSTERS[2]; + +export const supportedImageTypes = ['image/png', 'image/gif', 'image/jpeg']; + +export const supportedAnimationTypes = [ + 'video/mp4', + 'video/quicktime', + 'audio/mpeg', + 'audio/x-flac', + 'audio/wav', + 'model/gltf-binary', + 'text/html', +]; +export const JSON_EXTENSION = 'application/json'; diff --git a/lib/candy-machine/types.ts b/lib/candy-machine/types.ts new file mode 100644 index 0000000..eec43d5 --- /dev/null +++ b/lib/candy-machine/types.ts @@ -0,0 +1,112 @@ +import { PublicKey } from '@solana/web3.js'; +import {BN} from '@project-serum/anchor'; + +export interface WhitelistMintMode { + neverBurn: undefined | boolean; + burnEveryTime: undefined | boolean; +} + +export interface CandyMachineConfig { + price: number; + number: number; + gatekeeper: typeof Gatekeeper | null; + solTreasuryAccount: string; + splTokenAccount: null; + splToken: null; + goLiveDate: string; + endSettings: any; + whitelistMintSettings: whitelistMintSettings | null; + hiddenSettings: hiddenSettings | null; + storage: StorageType; + ipfsInfuraProjectId: null; + ipfsInfuraSecret: null; + nftStorageKey: null; + awsS3Bucket: null; + noRetainAuthority: boolean; + noMutable: boolean; + pinataJwt: null; + pinataGateway: null; + batchSize: null; + uuid: null; + arweaveJwk: null; +} +export const Gatekeeper = { + gatekeeperNetwork: 'ignREusXmGrscGNUesoU9mxfds9AiYTezUKex2PsZV6', + expireOnUse: true, +} as const; + +interface whitelistMintSettings { + mode: any; + mint: PublicKey; + presale: boolean; + discountPrice: null | BN; +} +interface hiddenSettings { + name: string; + uri: string; + hash: Uint8Array; +} + +export enum StorageType { + ArweaveBundle = 'arweave-bundle', + ArweaveSol = 'arweave-sol', + Arweave = 'arweave', + Ipfs = 'ipfs', + Aws = 'aws', + NftStorage = 'nft-storage', + Pinata = 'pinata', +} + +/** + * The Manifest object for a given asset. + * This object holds the contents of the asset's JSON file. + * Represented here in its minimal form. + */ +export type Manifest = { + image: string; + animation_url: string; + name: string; + symbol: string; + seller_fee_basis_points: number; + properties: { + files: Array<{ type: string; uri: string }>; + creators: Array<{ + address: string; + share: number; + }>; + }; +}; + + +export interface CandyMachineData { + itemsAvailable: BN; + uuid: null | string; + symbol: string; + sellerFeeBasisPoints: number; + isMutable: boolean; + maxSupply: BN; + price: BN; + retainAuthority: boolean; + gatekeeper: null | { + expireOnUse: boolean; + gatekeeperNetwork: PublicKey; + }; + goLiveDate: null | BN; + endSettings: null | [number, BN]; + whitelistMintSettings: null | { + mode: WhitelistMintMode; + mint: PublicKey; + presale: boolean; + discountPrice: null | BN; + }; + hiddenSettings: null | { + name: string; + uri: string; + hash: Uint8Array; + }; + creators: { + address: PublicKey; + verified: boolean; + share: number; + }[]; +} diff --git a/lib/candy-machine/upload/arweave.ts b/lib/candy-machine/upload/arweave.ts new file mode 100644 index 0000000..c771960 --- /dev/null +++ b/lib/candy-machine/upload/arweave.ts @@ -0,0 +1,134 @@ +import * as anchor from '@project-serum/anchor'; +import { calculate } from '@metaplex/arweave-cost'; +import { ARWEAVE_PAYMENT_WALLET, ARWEAVE_UPLOAD_ENDPOINT } from '../constants'; +import { sendTransactionWithRetryWithKeypair } from './transactions'; +import { Manifest } from '../types'; +import { getFileExtension } from './helpers'; +import { AnchorWallet } from '@solana/wallet-adapter-react'; + +/** + * @param fileSizes - array of file sizes + * @returns {Promise} - estimated cost to store files in lamports + */ +async function fetchAssetCostToStore(fileSizes: number[]): Promise { + const result = await calculate(fileSizes); + console.log('Arweave cost estimates:', result); + + return result.solana * anchor.web3.LAMPORTS_PER_SOL; +} + +/** + * After doing a tx to the metaplex arweave wallet to store the NFTs and their metadata, this function calls a serverless function from metaplex + * in which the files to upload are attached to the http form. + * @param data - FormData object + * @param manifest json manifest containing metadata + * @param index index of the NFTs to upload + * @returns http response + */ +async function upload(data: FormData, manifest: Manifest, index: number) { + console.log(`trying to upload image ${index}: ${manifest.name}`); + const res = await ( + await fetch(ARWEAVE_UPLOAD_ENDPOINT, { + method: 'POST', + body: data, + }) + ).json(); + return res; +} + +function estimateManifestSize(filenames: string[]) { + const paths: { [key: string]: any } = {}; + for (const name of filenames) { + console.log('name', name); + paths[name] = { + id: 'artestaC_testsEaEmAGFtestEGtestmMGmgMGAV438', + ext: getFileExtension(name), + }; + } + + const manifest = { + manifest: 'arweave/paths', + version: '0.1.0', + paths, + index: { + path: 'metadata.json', + }, + }; + + const data = Buffer.from(JSON.stringify(manifest), 'utf8'); + console.log('Estimated manifest size:', data.length); + return data.length; +} + +export async function arweaveUpload( + walletKeyPair: AnchorWallet, + anchorProgram: anchor.Program, + env: string, + image: File, + manifestBuffer: Buffer, + manifest: Manifest, + index: number +) { + const imageExt = image.type; + const estimatedManifestSize = estimateManifestSize([ + image.name, + 'metadata.json', + ]); + + const storageCost = await fetchAssetCostToStore([ + image.size, + manifestBuffer.length, + estimatedManifestSize, + ]); + + console.log(`lamport cost to store ${image.name}: ${storageCost}`); + + const instructions = [ + anchor.web3.SystemProgram.transfer({ + fromPubkey: walletKeyPair.publicKey, + toPubkey: ARWEAVE_PAYMENT_WALLET, + lamports: storageCost, + }), + ]; + + const tx = await sendTransactionWithRetryWithKeypair( + anchorProgram.provider.connection, + walletKeyPair, + instructions, + 'confirmed' + ); + console.log(`solana transaction (${env}) for arweave payment:`, tx); + + const data = new FormData(); + const manifestBlob = new Blob([manifestBuffer], { type: 'application/json' }); + + data.append('transaction', tx['txid']); + data.append('env', env); + data.append('file[]', image, image.name); + data.append('file[]', manifestBlob, 'metadata.json'); + + const result = await upload(data, manifest, index); + + console.log('result', result); + + const metadataFile = result.messages?.find( + (m: any) => m.filename === 'manifest.json' + ); + const imageFile = result.messages?.find( + (m: any) => m.filename === image.name + ); + + if (metadataFile?.transactionId) { + const link = `https://arweave.net/${metadataFile.transactionId}`; + const imageLink = `https://arweave.net/${ + imageFile.transactionId + }?ext=${imageExt.replace('.', '')}`; + console.log(`File uploaded: ${link}`); + console.log(`imageLink uploaded: ${imageLink}`); + + return [link, imageLink]; + } else { + // @todo improve + throw new Error(`No transaction ID for upload: ${index}`); + } +} diff --git a/lib/candy-machine/upload/cache.ts b/lib/candy-machine/upload/cache.ts new file mode 100644 index 0000000..0a8ec43 --- /dev/null +++ b/lib/candy-machine/upload/cache.ts @@ -0,0 +1,40 @@ +import fileDownloader from 'js-file-download'; + +// export function cachePath( +// env: string, +// cacheName: string, +// cPath: string = CACHE_PATH, +// legacy: boolean = false, +// ) { +// const filename = `${env}-${cacheName}`; +// return path.join(cPath, legacy ? filename : `${filename}.json`); +// } + +// export function loadCache( +// cacheName: string, +// env: string, +// cPath: string = CACHE_PATH, +// legacy: boolean = false, +// ) { +// const path = cachePath(env, cacheName, cPath, legacy); + +// if (!fs.existsSync(path)) { +// if (!legacy) { +// return loadCache(cacheName, env, cPath, true); +// } +// return undefined; +// } + +// return JSON.parse(fs.readFileSync(path).toString()); +// } + +export function saveCache( + cacheName: string, + env: string, + cacheContent: any + // cPath: string = CACHE_PATH, +) { + cacheContent.env = env; + cacheContent.cacheName = cacheName; + fileDownloader(JSON.stringify(cacheContent), cacheName); +} diff --git a/lib/candy-machine/upload/config.ts b/lib/candy-machine/upload/config.ts new file mode 100644 index 0000000..4fa072e --- /dev/null +++ b/lib/candy-machine/upload/config.ts @@ -0,0 +1,344 @@ +import * as anchor from '@project-serum/anchor'; +import { + CANDY_MACHINE_PROGRAM_V2_ID, + supportedImageTypes, + supportedAnimationTypes, + JSON_EXTENSION, +} from '../constants'; + +import { PublicKey } from '@solana/web3.js'; +import { getMint, TOKEN_PROGRAM_ID, getAccount } from '@solana/spl-token'; +import { getAtaForMint, parseDate } from './helpers'; +import { WhitelistMintMode, CandyMachineConfig, StorageType } from '../types'; + +export interface CandyMachineData { + itemsAvailable: anchor.BN; + uuid: null | string; + symbol: string; + sellerFeeBasisPoints: number; + isMutable: boolean; + maxSupply: anchor.BN; + price: anchor.BN; + retainAuthority: boolean; + gatekeeper: null | { + expireOnUse: boolean; + gatekeeperNetwork: PublicKey; + }; + goLiveDate: null | anchor.BN; + endSettings: null | [number, anchor.BN]; + whitelistMintSettings: null | { + mode: WhitelistMintMode; + mint: anchor.web3.PublicKey; + presale: boolean; + discountPrice: null | anchor.BN; + }; + hiddenSettings: null | { + name: string; + uri: string; + hash: Uint8Array; + }; + creators: { + address: PublicKey; + verified: boolean; + share: number; + }[]; +} + +export async function loadCandyProgramV2( + provider: anchor.Provider, + customRpcUrl?: string +) { + if (customRpcUrl) console.log('USING CUSTOM URL', customRpcUrl); + const idl = (await anchor.Program.fetchIdl( + CANDY_MACHINE_PROGRAM_V2_ID, + provider + )) as anchor.Idl; + + const program = new anchor.Program( + idl, + CANDY_MACHINE_PROGRAM_V2_ID, + provider + ); + console.log('program id from anchor', program.programId.toBase58()); + return program; +} + +export async function getCandyMachineV2Config( + walletKeyPair: PublicKey, + configForm: CandyMachineConfig, + anchorProgram: anchor.Program +): Promise<{ + storage: StorageType; + nftStorageKey: string | null; + ipfsInfuraProjectId: string | null; + number: number; + ipfsInfuraSecret: string | null; + pinataJwt: string | null; + pinataGateway: string | null; + awsS3Bucket: string | null; + retainAuthority: boolean; + mutable: boolean; + batchSize: number | null; + price: anchor.BN; + treasuryWallet: PublicKey; + splToken: PublicKey | null; + gatekeeper: null | { + expireOnUse: boolean; + gatekeeperNetwork: PublicKey; + }; + endSettings: null | [number, anchor.BN]; + whitelistMintSettings: null | { + mode: any; + mint: PublicKey; + presale: boolean; + discountPrice: null | anchor.BN; + }; + hiddenSettings: null | { + name: string; + uri: string; + hash: Uint8Array; + }; + goLiveDate: anchor.BN | null; + uuid: string | null; + arweaveJwk: string | null; +}> { + if (configForm === undefined) { + throw new Error('The configForm is undefined'); + } + + const config = configForm; + + const { + storage, + nftStorageKey, + ipfsInfuraProjectId, + number, + ipfsInfuraSecret, + pinataJwt, + pinataGateway, + awsS3Bucket, + noRetainAuthority, + noMutable, + batchSize, + price, + splToken, + splTokenAccount, + solTreasuryAccount, + gatekeeper, + endSettings, + hiddenSettings, + whitelistMintSettings, + goLiveDate, + uuid, + arweaveJwk, + } = config; + + let wallet; + let parsedPrice = price; + + const splTokenAccountFigured = splTokenAccount + ? splTokenAccount + : splToken + ? (await getAtaForMint(new PublicKey(splToken), walletKeyPair))[0] + : null; + + if (splToken) { + if (solTreasuryAccount) { + throw new Error( + 'If spl-token-account or spl-token is set then sol-treasury-account cannot be set' + ); + } + if (!splToken) { + throw new Error( + 'If spl-token-account is set, spl-token must also be set' + ); + } + const splTokenKey = new PublicKey(splToken); + const splTokenAccountKey = new PublicKey( + splTokenAccountFigured as anchor.web3.PublicKey + ); + if (!splTokenAccountFigured) { + throw new Error( + 'If spl-token is set, spl-token-account must also be set' + ); + } + + console.log('anchor program loaded', anchorProgram); + + const mintInfo = await getMint( + anchorProgram.provider.connection, + splTokenKey, + undefined, + TOKEN_PROGRAM_ID + ); + if (!mintInfo.isInitialized) { + throw new Error(`The specified spl-token is not initialized`); + } + const tokenAccount = await getAccount( + anchorProgram.provider.connection, + splTokenAccountKey, + undefined, + TOKEN_PROGRAM_ID + ); + if (!tokenAccount.isInitialized) { + throw new Error(`The specified spl-token-account is not initialized`); + } + if (!tokenAccount.mint.equals(splTokenKey)) { + throw new Error( + `The spl-token-account's mint (${tokenAccount.mint.toString()}) does not match specified spl-token ${splTokenKey.toString()}` + ); + } + + wallet = new PublicKey(splTokenAccountKey); + parsedPrice = price * 10 ** mintInfo.decimals; + if ( + (whitelistMintSettings && whitelistMintSettings?.discountPrice) || + (whitelistMintSettings && + whitelistMintSettings?.discountPrice?.toNumber() === 0) + ) { + (whitelistMintSettings.discountPrice as any) *= 10 ** mintInfo.decimals; + } + } else { + parsedPrice = price * 10 ** 9; + if ( + whitelistMintSettings?.discountPrice || + whitelistMintSettings?.discountPrice?.toNumber() === 0 + ) { + (whitelistMintSettings.discountPrice as any) *= 10 ** 9; + } + wallet = solTreasuryAccount + ? new PublicKey(solTreasuryAccount) + : walletKeyPair; + } + + if (whitelistMintSettings) { + whitelistMintSettings.mint = new PublicKey(whitelistMintSettings.mint); + if ( + whitelistMintSettings?.discountPrice || + whitelistMintSettings?.discountPrice?.toNumber() === 0 + ) { + whitelistMintSettings.discountPrice = new anchor.BN( + whitelistMintSettings.discountPrice + ); + } + } + + if (endSettings) { + if (endSettings.endSettingType.date) { + endSettings.number = new anchor.BN(parseDate(endSettings.value)); + } else if (endSettings.endSettingType.amount) { + endSettings.number = new anchor.BN(endSettings.value); + } + delete endSettings.value; + } + + if (hiddenSettings) { + const utf8Encode = new TextEncoder(); + hiddenSettings.hash = utf8Encode.encode(hiddenSettings.hash.toString()); + } + console.log('correct config'); + + return { + storage, + nftStorageKey, + ipfsInfuraProjectId, + number, + ipfsInfuraSecret, + pinataJwt, + pinataGateway: pinataGateway ? pinataGateway : null, + awsS3Bucket, + retainAuthority: !noRetainAuthority, + mutable: !noMutable, + batchSize, + price: new anchor.BN(parsedPrice), + treasuryWallet: wallet, + splToken: splToken ? new PublicKey(splToken) : null, + gatekeeper: gatekeeper + ? { + gatekeeperNetwork: new PublicKey(gatekeeper.gatekeeperNetwork), + expireOnUse: gatekeeper.expireOnUse, + } + : null, + endSettings, + hiddenSettings, + whitelistMintSettings, + goLiveDate: goLiveDate ? new anchor.BN(parseDate(goLiveDate)) : null, + uuid, + arweaveJwk, + }; +} + +/** + * @typedef {Object} VerifiedAssets + * @property {File[]} supportedFiles how the person is called + * @property {number} elemCount how many years the person lived + */ + +/** + * + * @param files : list of files to analuze (json+image) + * @param storage : Storage to use + * @param number :number of assets + * @returns {VerifiedAssets} returns an array of verified assets and the number of assets + */ + +export function verifyAssets( + files: File[], + storage: StorageType, + number: number +): { supportedFiles: File[]; elemCount: number } { + let imageFileCount = 0; + let animationFileCount = 0; + let jsonFileCount = 0; + + /** + * From the files list, check that the files are valid images and animations or json files. + */ + const supportedFiles = files.filter((it) => { + if (supportedImageTypes.some((e) => e === it.type)) { + imageFileCount++; + } else if (supportedAnimationTypes.some((e) => e === it.type)) { + animationFileCount++; + } else if (it.type == JSON_EXTENSION) { + jsonFileCount++; + } else { + console.warn(`WARNING: Skipping unsupported file type ${it}`); + return false; + } + + return true; + }); + + if (animationFileCount !== 0 && storage === StorageType.Arweave) { + throw new Error( + 'The "arweave" storage option is incompatible with animation files. Please try again with another storage option using `--storage