├── .env.template ├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.tsx ├── Header.tsx ├── Home.tsx ├── MintButton.tsx ├── MintCountdown.tsx ├── candy-machine.ts ├── connection.tsx ├── index.css ├── index.tsx ├── logo.svg ├── react-app-env.d.ts ├── reportWebVitals.ts ├── setupTests.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.env.template: -------------------------------------------------------------------------------- 1 | # Uncomment (remove the "#") for development mode 2 | 3 | # REACT_APP_CANDY_MACHINE_ID= 4 | # REACT_APP_SOLANA_NETWORK=devnet 5 | # REACT_APP_SOLANA_RPC_HOST=https://api.devnet.solana.com 6 | 7 | 8 | # Uncomment the three lines below for production 9 | 10 | # REACT_APP_CANDY_MACHINE_ID= 11 | # REACT_APP_SOLANA_NETWORK=mainnet-beta 12 | # REACT_APP_SOLANA_RPC_HOST=https://ssc-dao.genesysgo.net/ 13 | 14 | # Necessary for Crossmint 15 | 16 | REACT_APP_CROSSMINT_ID= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCrossMint%2Fcandy-machine-react-ui&env=REACT_APP_CANDY_MACHINE_ID,REACT_APP_SOLANA_NETWORK,REACT_APP_SOLANA_RPC_HOST,REACT_APP_CROSSMINT_ID&project-name=candy-machine-ui&repo-name=candy-machine-react-ui) 2 | 3 | # candy-machine-react-ui 4 | 5 | Create your NFT minting site for Solana Candy Machine, in less than 5 minutes. 6 | 7 | This repository contains a minimal UI front-end in React, for creating NFT drop sites for Solana Candy Machines. 8 | 9 | It also includes the option to accept credit cards via Crossmint (which takes under 5 min to set up as well). 10 | 11 | Example image 12 | 13 | ## Accept credit card payments 14 | 15 | Accepting credit card payments allows you to sell to more customers who don't yet have a wallet, or are using their phones. With crossmint, accepting credit cards is free for you (seller), and you'll still receive all the funds instantly in SOL. 16 | 17 | To get more in-depth integration instructions visit our [Solana Candy Machine documentation](https://docs.crossmint.io/accept-credit-cards/integration-guides/solana-candy-machine/b-i-have-an-existing-candy-machine-website). 18 | 19 | It takes less than 5 lines of code and 5 minutes to integrate. 20 | 21 | ## Set up 22 | 23 | Make sure you have `yarn` and `git` installed. Then run: 24 | 25 | ``` 26 | git clone https://github.com/CrossMint/candy-machine-react-ui.git 27 | cd candy-machine-react-ui 28 | yarn 29 | ``` 30 | 31 | ## Configure 32 | 33 | Copy the `.env.template` file into a file named `.env` 34 | 35 | Then, uncomment either the "development" or "production" lines, depending on whether you are running a devnet candy machine or a production one. 36 | 37 | Finally, in ``, enter the candy machine ID you obtained when uploading your assets. [More info](https://docs.metaplex.com/candy-machine-v2/creating-candy-machine) 38 | 39 | ## Develop 40 | 41 | Run `yarn dev` to start a local server 42 | 43 | Then make any UI changes into the `src/Home.tsx` file 44 | 45 | Also be sure to update the title and description of your site in `public/index.html` 46 | 47 | ## Deploy with Vercel 48 | 49 | We recommend you deploy to Vercel, as it's free, scalable and very easy to use. 50 | 51 | To do so: 52 | 53 | 1. Create an account at vercel.com 54 | 2. Install the vercel command line 55 | 3. Run `vercel` on your project folder (generally the recommended configuration works) 56 | 4. Go to the vercel console and configure the domain for your drop 57 | 58 | Or just click this button: 59 | 60 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FCrossMint%2Fcandy-machine-react-ui&env=REACT_APP_CANDY_MACHINE_ID,REACT_APP_SOLANA_NETWORK,REACT_APP_SOLANA_RPC_HOST,REACT_APP_CROSSMINT_ID&project-name=candy-machine-ui&repo-name=candy-machine-react-ui) 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "candy-machine-mint", 3 | "version": "0.2.0", 4 | "private": true, 5 | "dependencies": { 6 | "@babel/runtime": "^7.15.5", 7 | "@civic/solana-gateway-react": "^0.4.10", 8 | "@crossmint/client-sdk-react-ui": "^0.2.0", 9 | "@material-ui/core": "^4.12.3", 10 | "@material-ui/icons": "^4.11.2", 11 | "@material-ui/lab": "^4.0.0-alpha.60", 12 | "@project-serum/anchor": "^0.14.0", 13 | "@solana/spl-token": "^0.1.8", 14 | "@solana/wallet-adapter-base": "^0.7.0", 15 | "@solana/wallet-adapter-material-ui": "^0.13.1", 16 | "@solana/wallet-adapter-react": "^0.13.1", 17 | "@solana/wallet-adapter-react-ui": "^0.6.1", 18 | "@solana/wallet-adapter-wallets": "^0.11.3", 19 | "@solana/web3.js": "^1.31.0", 20 | "@testing-library/dom": "^7.21.4", 21 | "@testing-library/jest-dom": "^5.11.4", 22 | "@testing-library/react": "^11.1.0", 23 | "@testing-library/user-event": "^12.1.10", 24 | "@types/jest": "^26.0.15", 25 | "@types/node": "^12.0.0", 26 | "@types/react": "^17.0.0", 27 | "@types/react-dom": "^17.0.0", 28 | "mime": "^3.0.0", 29 | "prop-types": "^15.7.2", 30 | "react": "^17.0.2", 31 | "react-countdown": "^2.3.2", 32 | "react-dom": "^17.0.2", 33 | "react-is": "^16.8.0", 34 | "react-scripts": "4.0.3", 35 | "styled-components": "^5.3.1", 36 | "typescript": "^4.1.2", 37 | "web-vitals": "^1.0.1" 38 | }, 39 | "scripts": { 40 | "dev": "react-scripts start", 41 | "build": "react-scripts build", 42 | "test": "react-scripts test", 43 | "eject": "react-scripts eject", 44 | "deploy:gh": "gh-pages -d ./build/ --repo https://github.com/pit-v/metaplex -t true --branch gh-pages-3", 45 | "deploy": "cross-env ASSET_PREFIX=/metaplex/ yarn build && yarn deploy:gh" 46 | }, 47 | "eslintConfig": { 48 | "extends": [ 49 | "react-app", 50 | "react-app/jest" 51 | ] 52 | }, 53 | "browserslist": { 54 | "production": [ 55 | ">0.2%", 56 | "not dead", 57 | "not op_mini all" 58 | ], 59 | "development": [ 60 | "last 1 chrome version", 61 | "last 1 firefox version", 62 | "last 1 safari version" 63 | ] 64 | }, 65 | "devDependencies": { 66 | "@types/styled-components": "^5.1.14" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crossmint/candy-machine-react-ui/7f94b256557922bb86c9ff29855aa48524cbbe9e/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Candy Machine UI 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crossmint/candy-machine-react-ui/7f94b256557922bb86c9ff29855aa48524cbbe9e/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Crossmint/candy-machine-react-ui/7f94b256557922bb86c9ff29855aa48524cbbe9e/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | import { useMemo } from "react"; 3 | import * as anchor from "@project-serum/anchor"; 4 | import Home from "./Home"; 5 | 6 | import { clusterApiUrl } from "@solana/web3.js"; 7 | import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; 8 | import { 9 | getPhantomWallet, 10 | getSlopeWallet, 11 | getSolflareWallet, 12 | getSolletWallet, 13 | getSolletExtensionWallet, 14 | } from "@solana/wallet-adapter-wallets"; 15 | 16 | import { 17 | ConnectionProvider, 18 | WalletProvider, 19 | } from "@solana/wallet-adapter-react"; 20 | import { WalletDialogProvider } from "@solana/wallet-adapter-material-ui"; 21 | 22 | import { ThemeProvider, createTheme } from "@material-ui/core"; 23 | 24 | const theme = createTheme({ 25 | palette: { 26 | type: "dark", 27 | }, 28 | }); 29 | 30 | const getCandyMachineId = (): anchor.web3.PublicKey | undefined => { 31 | try { 32 | const candyMachineId = new anchor.web3.PublicKey( 33 | process.env.REACT_APP_CANDY_MACHINE_ID! 34 | ); 35 | 36 | return candyMachineId; 37 | } catch (e) { 38 | console.log("Failed to construct CandyMachineId", e); 39 | return undefined; 40 | } 41 | }; 42 | 43 | const candyMachineId = getCandyMachineId(); 44 | const network = process.env.REACT_APP_SOLANA_NETWORK as WalletAdapterNetwork; 45 | const rpcHost = process.env.REACT_APP_SOLANA_RPC_HOST!; 46 | const connection = new anchor.web3.Connection( 47 | rpcHost ? rpcHost : anchor.web3.clusterApiUrl("devnet") 48 | ); 49 | 50 | const startDateSeed = parseInt(process.env.REACT_APP_CANDY_START_DATE!, 10); 51 | const txTimeoutInMilliseconds = 30000; 52 | 53 | const App = () => { 54 | const endpoint = useMemo(() => clusterApiUrl(network), []); 55 | 56 | const wallets = useMemo( 57 | () => [ 58 | getPhantomWallet(), 59 | getSolflareWallet(), 60 | getSlopeWallet(), 61 | getSolletWallet({ network }), 62 | getSolletExtensionWallet({ network }), 63 | ], 64 | [] 65 | ); 66 | 67 | return ( 68 | 69 | 70 | 71 | 72 | 79 | 80 | 81 | 82 | 83 | ); 84 | }; 85 | 86 | export default App; 87 | -------------------------------------------------------------------------------- /src/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as anchor from '@project-serum/anchor'; 2 | 3 | import Grid from '@material-ui/core/Grid'; 4 | import Typography from '@material-ui/core/Typography'; 5 | import { MintCountdown } from './MintCountdown'; 6 | import { toDate, formatNumber } from './utils'; 7 | import { CandyMachineAccount } from './candy-machine'; 8 | 9 | type HeaderProps = { 10 | candyMachine?: CandyMachineAccount; 11 | }; 12 | 13 | export const Header = ({ candyMachine }: HeaderProps) => { 14 | return ( 15 | 16 | 17 | {candyMachine && ( 18 | 19 | 20 | 21 | Remaining 22 | 23 | 30 | {`${candyMachine?.state.itemsRemaining}`} 31 | 32 | 33 | 34 | 35 | Price 36 | 37 | 42 | {getMintPrice(candyMachine)} 43 | 44 | 45 | 46 | )} 47 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | const getMintPrice = (candyMachine: CandyMachineAccount): string => { 70 | const price = formatNumber.asNumber( 71 | candyMachine.state.isPresale && candyMachine.state.whitelistMintSettings?.discountPrice 72 | ? candyMachine.state.whitelistMintSettings?.discountPrice! 73 | : candyMachine.state.price!, 74 | ); 75 | return `◎ ${price}`; 76 | }; 77 | -------------------------------------------------------------------------------- /src/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState, useCallback } from "react"; 2 | import * as anchor from "@project-serum/anchor"; 3 | 4 | import styled from "styled-components"; 5 | import { Container, Snackbar } from "@material-ui/core"; 6 | import Paper from "@material-ui/core/Paper"; 7 | import Alert from "@material-ui/lab/Alert"; 8 | import { PublicKey } from "@solana/web3.js"; 9 | import { useWallet } from "@solana/wallet-adapter-react"; 10 | import { WalletDialogButton } from "@solana/wallet-adapter-material-ui"; 11 | import { 12 | awaitTransactionSignatureConfirmation, 13 | CandyMachineAccount, 14 | CANDY_MACHINE_PROGRAM, 15 | getCandyMachineState, 16 | mintOneToken, 17 | } from "./candy-machine"; 18 | import { AlertState } from "./utils"; 19 | import { Header } from "./Header"; 20 | import { MintButton } from "./MintButton"; 21 | import { GatewayProvider } from "@civic/solana-gateway-react"; 22 | import { CrossmintPayButton } from "@crossmint/client-sdk-react-ui"; 23 | 24 | const ConnectButton = styled(WalletDialogButton)` 25 | width: 100%; 26 | height: 60px; 27 | margin-top: 10px; 28 | margin-bottom: 5px; 29 | background: linear-gradient(180deg, #604ae5 0%, #813eee 100%); 30 | color: white; 31 | font-size: 16px; 32 | font-weight: bold; 33 | `; 34 | 35 | const MintContainer = styled.div``; // add your owns styles here 36 | 37 | export interface HomeProps { 38 | candyMachineId?: anchor.web3.PublicKey; 39 | connection: anchor.web3.Connection; 40 | startDate: number; 41 | txTimeout: number; 42 | rpcHost: string; 43 | } 44 | 45 | const Home = (props: HomeProps) => { 46 | const [isUserMinting, setIsUserMinting] = useState(false); 47 | const [candyMachine, setCandyMachine] = useState(); 48 | const [alertState, setAlertState] = useState({ 49 | open: false, 50 | message: "", 51 | severity: undefined, 52 | }); 53 | 54 | const rpcUrl = props.rpcHost; 55 | const wallet = useWallet(); 56 | 57 | const anchorWallet = useMemo(() => { 58 | if ( 59 | !wallet || 60 | !wallet.publicKey || 61 | !wallet.signAllTransactions || 62 | !wallet.signTransaction 63 | ) { 64 | return; 65 | } 66 | 67 | return { 68 | publicKey: wallet.publicKey, 69 | signAllTransactions: wallet.signAllTransactions, 70 | signTransaction: wallet.signTransaction, 71 | } as anchor.Wallet; 72 | }, [wallet]); 73 | 74 | const refreshCandyMachineState = useCallback(async () => { 75 | if (!anchorWallet) { 76 | return; 77 | } 78 | 79 | if (props.candyMachineId) { 80 | try { 81 | const cndy = await getCandyMachineState( 82 | anchorWallet, 83 | props.candyMachineId, 84 | props.connection 85 | ); 86 | setCandyMachine(cndy); 87 | } catch (e) { 88 | console.log("There was a problem fetching Candy Machine state"); 89 | console.log(e); 90 | } 91 | } 92 | }, [anchorWallet, props.candyMachineId, props.connection]); 93 | 94 | const onMint = async () => { 95 | try { 96 | setIsUserMinting(true); 97 | document.getElementById("#identity")?.click(); 98 | if (wallet.connected && candyMachine?.program && wallet.publicKey) { 99 | const mintTxId = ( 100 | await mintOneToken(candyMachine, wallet.publicKey) 101 | )[0]; 102 | 103 | let status: any = { err: true }; 104 | if (mintTxId) { 105 | status = await awaitTransactionSignatureConfirmation( 106 | mintTxId, 107 | props.txTimeout, 108 | props.connection, 109 | true 110 | ); 111 | } 112 | 113 | if (status && !status.err) { 114 | setAlertState({ 115 | open: true, 116 | message: "Congratulations! Mint succeeded!", 117 | severity: "success", 118 | }); 119 | } else { 120 | setAlertState({ 121 | open: true, 122 | message: "Mint failed! Please try again!", 123 | severity: "error", 124 | }); 125 | } 126 | } 127 | } catch (error: any) { 128 | let message = error.msg || "Minting failed! Please try again!"; 129 | if (!error.msg) { 130 | if (!error.message) { 131 | message = "Transaction Timeout! Please try again."; 132 | } else if (error.message.indexOf("0x137") !== -1) { 133 | message = `SOLD OUT!`; 134 | } else if (error.message.indexOf("0x135") !== -1) { 135 | message = `Insufficient funds to mint. Please fund your wallet.`; 136 | } 137 | } else { 138 | if (error.code === 311) { 139 | message = `SOLD OUT!`; 140 | window.location.reload(); 141 | } else if (error.code === 312) { 142 | message = `Minting period hasn't started yet.`; 143 | } 144 | } 145 | 146 | setAlertState({ 147 | open: true, 148 | message, 149 | severity: "error", 150 | }); 151 | } finally { 152 | setIsUserMinting(false); 153 | } 154 | }; 155 | 156 | useEffect(() => { 157 | refreshCandyMachineState(); 158 | }, [ 159 | anchorWallet, 160 | props.candyMachineId, 161 | props.connection, 162 | refreshCandyMachineState, 163 | ]); 164 | 165 | return ( 166 | 167 | 168 | 175 | {!wallet.connected ? ( 176 | Connect Wallet 177 | ) : ( 178 | <> 179 |
180 | 181 | {candyMachine?.state.isActive && 182 | candyMachine?.state.gatekeeper && 183 | wallet.publicKey && 184 | wallet.signTransaction ? ( 185 | 199 | 204 | 205 | ) : ( 206 | 211 | )} 212 | 213 | 214 | )} 215 | {process.env.REACT_APP_CROSSMINT_ID && ( 216 | 221 | )} 222 | 223 | 224 | 225 | setAlertState({ ...alertState, open: false })} 229 | > 230 | setAlertState({ ...alertState, open: false })} 232 | severity={alertState.severity} 233 | > 234 | {alertState.message} 235 | 236 | 237 | 238 | ); 239 | }; 240 | 241 | export default Home; 242 | -------------------------------------------------------------------------------- /src/MintButton.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Button from '@material-ui/core/Button'; 3 | import { CandyMachineAccount } from './candy-machine'; 4 | import { CircularProgress } from '@material-ui/core'; 5 | import { GatewayStatus, useGateway } from '@civic/solana-gateway-react'; 6 | import { useEffect, useState } from 'react'; 7 | 8 | export const CTAButton = styled(Button)` 9 | width: 100%; 10 | height: 60px; 11 | margin-top: 10px; 12 | margin-bottom: 5px; 13 | background: linear-gradient(180deg, #604ae5 0%, #813eee 100%); 14 | color: white; 15 | font-size: 16px; 16 | font-weight: bold; 17 | `; // add your own styles here 18 | 19 | export const MintButton = ({ 20 | onMint, 21 | candyMachine, 22 | isMinting, 23 | }: { 24 | onMint: () => Promise; 25 | candyMachine?: CandyMachineAccount; 26 | isMinting: boolean; 27 | }) => { 28 | const { requestGatewayToken, gatewayStatus } = useGateway(); 29 | const [clicked, setClicked] = useState(false); 30 | 31 | useEffect(() => { 32 | if (gatewayStatus === GatewayStatus.ACTIVE && clicked) { 33 | onMint(); 34 | setClicked(false); 35 | } 36 | }, [gatewayStatus, clicked, setClicked, onMint]); 37 | 38 | const getMintButtonContent = () => { 39 | if (candyMachine?.state.isSoldOut) { 40 | return 'SOLD OUT'; 41 | } else if (isMinting) { 42 | return ; 43 | } else if (candyMachine?.state.isPresale) { 44 | return 'PRESALE MINT'; 45 | } 46 | 47 | return 'MINT'; 48 | }; 49 | 50 | return ( 51 | { 58 | setClicked(true); 59 | if (candyMachine?.state.isActive && candyMachine?.state.gatekeeper) { 60 | if (gatewayStatus === GatewayStatus.ACTIVE) { 61 | setClicked(true); 62 | } else { 63 | await requestGatewayToken(); 64 | } 65 | } else { 66 | await onMint(); 67 | setClicked(false); 68 | } 69 | }} 70 | variant="contained" 71 | > 72 | {getMintButtonContent()} 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/MintCountdown.tsx: -------------------------------------------------------------------------------- 1 | import { Paper } from '@material-ui/core'; 2 | import Countdown from 'react-countdown'; 3 | import { Theme, createStyles, makeStyles } from '@material-ui/core/styles'; 4 | 5 | const useStyles = makeStyles((theme: Theme) => 6 | createStyles({ 7 | root: { 8 | display: 'flex', 9 | padding: theme.spacing(0), 10 | '& > *': { 11 | margin: theme.spacing(0.5), 12 | marginRight: 0, 13 | width: theme.spacing(6), 14 | height: theme.spacing(6), 15 | display: 'flex', 16 | flexDirection: 'column', 17 | alignContent: 'center', 18 | alignItems: 'center', 19 | justifyContent: 'center', 20 | background: '#384457', 21 | color: 'white', 22 | borderRadius: 5, 23 | fontSize: 10, 24 | }, 25 | }, 26 | done: { 27 | display: 'flex', 28 | margin: theme.spacing(1), 29 | marginRight: 0, 30 | padding: theme.spacing(1), 31 | flexDirection: 'column', 32 | alignContent: 'center', 33 | alignItems: 'center', 34 | justifyContent: 'center', 35 | background: '#384457', 36 | color: 'white', 37 | borderRadius: 5, 38 | fontWeight: 'bold', 39 | fontSize: 18, 40 | }, 41 | item: { 42 | fontWeight: 'bold', 43 | fontSize: 18, 44 | }, 45 | }), 46 | ); 47 | 48 | interface MintCountdownProps { 49 | date: Date | undefined; 50 | style?: React.CSSProperties; 51 | status?: string; 52 | onComplete?: () => void; 53 | } 54 | 55 | interface MintCountdownRender { 56 | days: number; 57 | hours: number; 58 | minutes: number; 59 | seconds: number; 60 | completed: boolean; 61 | } 62 | 63 | export const MintCountdown: React.FC = ({ 64 | date, 65 | status, 66 | style, 67 | onComplete, 68 | }) => { 69 | const classes = useStyles(); 70 | const renderCountdown = ({ 71 | days, 72 | hours, 73 | minutes, 74 | seconds, 75 | completed, 76 | }: MintCountdownRender) => { 77 | hours += days * 24; 78 | if (completed) { 79 | return status ? {status} : null; 80 | } else { 81 | return ( 82 |
83 | 84 | 85 | {hours < 10 ? `0${hours}` : hours} 86 | 87 | hrs 88 | 89 | 90 | 91 | {minutes < 10 ? `0${minutes}` : minutes} 92 | 93 | mins 94 | 95 | 96 | 97 | {seconds < 10 ? `0${seconds}` : seconds} 98 | 99 | secs 100 | 101 |
102 | ); 103 | } 104 | }; 105 | 106 | if (date) { 107 | return ( 108 | 113 | ); 114 | } else { 115 | return null; 116 | } 117 | }; 118 | -------------------------------------------------------------------------------- /src/candy-machine.ts: -------------------------------------------------------------------------------- 1 | import * as anchor from '@project-serum/anchor'; 2 | 3 | import { MintLayout, TOKEN_PROGRAM_ID, Token } from '@solana/spl-token'; 4 | import { SystemProgram } from '@solana/web3.js'; 5 | import { sendTransactions } from './connection'; 6 | 7 | import { 8 | CIVIC, 9 | getAtaForMint, 10 | getNetworkExpire, 11 | getNetworkToken, 12 | SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, 13 | } from './utils'; 14 | 15 | export const CANDY_MACHINE_PROGRAM = new anchor.web3.PublicKey( 16 | 'cndy3Z4yapfJBmL3ShUp5exZKqR3z33thTzeNMm2gRZ', 17 | ); 18 | 19 | const TOKEN_METADATA_PROGRAM_ID = new anchor.web3.PublicKey( 20 | 'metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s', 21 | ); 22 | 23 | interface CandyMachineState { 24 | itemsAvailable: number; 25 | itemsRedeemed: number; 26 | itemsRemaining: number; 27 | treasury: anchor.web3.PublicKey; 28 | tokenMint: anchor.web3.PublicKey; 29 | isSoldOut: boolean; 30 | isActive: boolean; 31 | isPresale: boolean; 32 | goLiveDate: anchor.BN; 33 | price: anchor.BN; 34 | gatekeeper: null | { 35 | expireOnUse: boolean; 36 | gatekeeperNetwork: anchor.web3.PublicKey; 37 | }; 38 | endSettings: null | [number, anchor.BN]; 39 | whitelistMintSettings: null | { 40 | mode: any; 41 | mint: anchor.web3.PublicKey; 42 | presale: boolean; 43 | discountPrice: null | anchor.BN; 44 | }; 45 | hiddenSettings: null | { 46 | name: string; 47 | uri: string; 48 | hash: Uint8Array; 49 | }; 50 | } 51 | 52 | export interface CandyMachineAccount { 53 | id: anchor.web3.PublicKey; 54 | program: anchor.Program; 55 | state: CandyMachineState; 56 | } 57 | 58 | export const awaitTransactionSignatureConfirmation = async ( 59 | txid: anchor.web3.TransactionSignature, 60 | timeout: number, 61 | connection: anchor.web3.Connection, 62 | queryStatus = false, 63 | ): Promise => { 64 | let done = false; 65 | let status: anchor.web3.SignatureStatus | null | void = { 66 | slot: 0, 67 | confirmations: 0, 68 | err: null, 69 | }; 70 | let subId = 0; 71 | status = await new Promise(async (resolve, reject) => { 72 | setTimeout(() => { 73 | if (done) { 74 | return; 75 | } 76 | done = true; 77 | console.log('Rejecting for timeout...'); 78 | reject({ timeout: true }); 79 | }, timeout); 80 | 81 | while (!done && queryStatus) { 82 | // eslint-disable-next-line no-loop-func 83 | (async () => { 84 | try { 85 | const signatureStatuses = await connection.getSignatureStatuses([ 86 | txid, 87 | ]); 88 | status = signatureStatuses && signatureStatuses.value[0]; 89 | if (!done) { 90 | if (!status) { 91 | console.log('REST null result for', txid, status); 92 | } else if (status.err) { 93 | console.log('REST error for', txid, status); 94 | done = true; 95 | reject(status.err); 96 | } else if (!status.confirmations) { 97 | console.log('REST no confirmations for', txid, status); 98 | } else { 99 | console.log('REST confirmation for', txid, status); 100 | done = true; 101 | resolve(status); 102 | } 103 | } 104 | } catch (e) { 105 | if (!done) { 106 | console.log('REST connection error: txid', txid, e); 107 | } 108 | } 109 | })(); 110 | await sleep(2000); 111 | } 112 | }); 113 | 114 | //@ts-ignore 115 | if (connection._signatureSubscriptions[subId]) { 116 | connection.removeSignatureListener(subId); 117 | } 118 | done = true; 119 | console.log('Returning status', status); 120 | return status; 121 | }; 122 | 123 | const createAssociatedTokenAccountInstruction = ( 124 | associatedTokenAddress: anchor.web3.PublicKey, 125 | payer: anchor.web3.PublicKey, 126 | walletAddress: anchor.web3.PublicKey, 127 | splTokenMintAddress: anchor.web3.PublicKey, 128 | ) => { 129 | const keys = [ 130 | { pubkey: payer, isSigner: true, isWritable: true }, 131 | { pubkey: associatedTokenAddress, isSigner: false, isWritable: true }, 132 | { pubkey: walletAddress, isSigner: false, isWritable: false }, 133 | { pubkey: splTokenMintAddress, isSigner: false, isWritable: false }, 134 | { 135 | pubkey: anchor.web3.SystemProgram.programId, 136 | isSigner: false, 137 | isWritable: false, 138 | }, 139 | { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false }, 140 | { 141 | pubkey: anchor.web3.SYSVAR_RENT_PUBKEY, 142 | isSigner: false, 143 | isWritable: false, 144 | }, 145 | ]; 146 | return new anchor.web3.TransactionInstruction({ 147 | keys, 148 | programId: SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, 149 | data: Buffer.from([]), 150 | }); 151 | }; 152 | 153 | export const getCandyMachineState = async ( 154 | anchorWallet: anchor.Wallet, 155 | candyMachineId: anchor.web3.PublicKey, 156 | connection: anchor.web3.Connection, 157 | ): Promise => { 158 | const provider = new anchor.Provider(connection, anchorWallet, { 159 | preflightCommitment: 'recent', 160 | }); 161 | 162 | const idl = await anchor.Program.fetchIdl(CANDY_MACHINE_PROGRAM, provider); 163 | 164 | const program = new anchor.Program(idl, CANDY_MACHINE_PROGRAM, provider); 165 | 166 | const state: any = await program.account.candyMachine.fetch(candyMachineId); 167 | const itemsAvailable = state.data.itemsAvailable.toNumber(); 168 | const itemsRedeemed = state.itemsRedeemed.toNumber(); 169 | const itemsRemaining = itemsAvailable - itemsRedeemed; 170 | 171 | const presale = 172 | state.data.whitelistMintSettings && 173 | state.data.whitelistMintSettings.presale && 174 | (!state.data.goLiveDate || 175 | state.data.goLiveDate.toNumber() > new Date().getTime() / 1000); 176 | 177 | return { 178 | id: candyMachineId, 179 | program, 180 | state: { 181 | itemsAvailable, 182 | itemsRedeemed, 183 | itemsRemaining, 184 | isSoldOut: itemsRemaining === 0, 185 | isActive: 186 | (presale || 187 | state.data.goLiveDate.toNumber() < new Date().getTime() / 1000) && 188 | (state.data.endSettings 189 | ? state.data.endSettings.endSettingType.date 190 | ? state.data.endSettings.number.toNumber() > new Date().getTime() / 1000 191 | : itemsRedeemed < state.data.endSettings.number.toNumber() 192 | : true), 193 | isPresale: presale, 194 | goLiveDate: state.data.goLiveDate, 195 | treasury: state.wallet, 196 | tokenMint: state.tokenMint, 197 | gatekeeper: state.data.gatekeeper, 198 | endSettings: state.data.endSettings, 199 | whitelistMintSettings: state.data.whitelistMintSettings, 200 | hiddenSettings: state.data.hiddenSettings, 201 | price: state.data.price, 202 | }, 203 | }; 204 | }; 205 | 206 | const getMasterEdition = async ( 207 | mint: anchor.web3.PublicKey, 208 | ): Promise => { 209 | return ( 210 | await anchor.web3.PublicKey.findProgramAddress( 211 | [ 212 | Buffer.from('metadata'), 213 | TOKEN_METADATA_PROGRAM_ID.toBuffer(), 214 | mint.toBuffer(), 215 | Buffer.from('edition'), 216 | ], 217 | TOKEN_METADATA_PROGRAM_ID, 218 | ) 219 | )[0]; 220 | }; 221 | 222 | const getMetadata = async ( 223 | mint: anchor.web3.PublicKey, 224 | ): Promise => { 225 | return ( 226 | await anchor.web3.PublicKey.findProgramAddress( 227 | [ 228 | Buffer.from('metadata'), 229 | TOKEN_METADATA_PROGRAM_ID.toBuffer(), 230 | mint.toBuffer(), 231 | ], 232 | TOKEN_METADATA_PROGRAM_ID, 233 | ) 234 | )[0]; 235 | }; 236 | 237 | export const getCandyMachineCreator = async ( 238 | candyMachine: anchor.web3.PublicKey, 239 | ): Promise<[anchor.web3.PublicKey, number]> => { 240 | return await anchor.web3.PublicKey.findProgramAddress( 241 | [Buffer.from('candy_machine'), candyMachine.toBuffer()], 242 | CANDY_MACHINE_PROGRAM, 243 | ); 244 | }; 245 | 246 | export const mintOneToken = async ( 247 | candyMachine: CandyMachineAccount, 248 | payer: anchor.web3.PublicKey, 249 | ): Promise<(string | undefined)[]> => { 250 | const mint = anchor.web3.Keypair.generate(); 251 | 252 | const userTokenAccountAddress = ( 253 | await getAtaForMint(mint.publicKey, payer) 254 | )[0]; 255 | 256 | const userPayingAccountAddress = candyMachine.state.tokenMint 257 | ? (await getAtaForMint(candyMachine.state.tokenMint, payer))[0] 258 | : payer; 259 | 260 | const candyMachineAddress = candyMachine.id; 261 | const remainingAccounts = []; 262 | const signers: anchor.web3.Keypair[] = [mint]; 263 | const cleanupInstructions = []; 264 | const instructions = [ 265 | anchor.web3.SystemProgram.createAccount({ 266 | fromPubkey: payer, 267 | newAccountPubkey: mint.publicKey, 268 | space: MintLayout.span, 269 | lamports: 270 | await candyMachine.program.provider.connection.getMinimumBalanceForRentExemption( 271 | MintLayout.span, 272 | ), 273 | programId: TOKEN_PROGRAM_ID, 274 | }), 275 | Token.createInitMintInstruction( 276 | TOKEN_PROGRAM_ID, 277 | mint.publicKey, 278 | 0, 279 | payer, 280 | payer, 281 | ), 282 | createAssociatedTokenAccountInstruction( 283 | userTokenAccountAddress, 284 | payer, 285 | payer, 286 | mint.publicKey, 287 | ), 288 | Token.createMintToInstruction( 289 | TOKEN_PROGRAM_ID, 290 | mint.publicKey, 291 | userTokenAccountAddress, 292 | payer, 293 | [], 294 | 1, 295 | ), 296 | ]; 297 | 298 | if (candyMachine.state.gatekeeper) { 299 | remainingAccounts.push({ 300 | pubkey: ( 301 | await getNetworkToken( 302 | payer, 303 | candyMachine.state.gatekeeper.gatekeeperNetwork, 304 | ) 305 | )[0], 306 | isWritable: true, 307 | isSigner: false, 308 | }); 309 | if (candyMachine.state.gatekeeper.expireOnUse) { 310 | remainingAccounts.push({ 311 | pubkey: CIVIC, 312 | isWritable: false, 313 | isSigner: false, 314 | }); 315 | remainingAccounts.push({ 316 | pubkey: ( 317 | await getNetworkExpire( 318 | candyMachine.state.gatekeeper.gatekeeperNetwork, 319 | ) 320 | )[0], 321 | isWritable: false, 322 | isSigner: false, 323 | }); 324 | } 325 | } 326 | if (candyMachine.state.whitelistMintSettings) { 327 | const mint = new anchor.web3.PublicKey( 328 | candyMachine.state.whitelistMintSettings.mint, 329 | ); 330 | 331 | const whitelistToken = (await getAtaForMint(mint, payer))[0]; 332 | remainingAccounts.push({ 333 | pubkey: whitelistToken, 334 | isWritable: true, 335 | isSigner: false, 336 | }); 337 | 338 | if (candyMachine.state.whitelistMintSettings.mode.burnEveryTime) { 339 | const whitelistBurnAuthority = anchor.web3.Keypair.generate(); 340 | 341 | remainingAccounts.push({ 342 | pubkey: mint, 343 | isWritable: true, 344 | isSigner: false, 345 | }); 346 | remainingAccounts.push({ 347 | pubkey: whitelistBurnAuthority.publicKey, 348 | isWritable: false, 349 | isSigner: true, 350 | }); 351 | signers.push(whitelistBurnAuthority); 352 | const exists = 353 | await candyMachine.program.provider.connection.getAccountInfo( 354 | whitelistToken, 355 | ); 356 | if (exists) { 357 | instructions.push( 358 | Token.createApproveInstruction( 359 | TOKEN_PROGRAM_ID, 360 | whitelistToken, 361 | whitelistBurnAuthority.publicKey, 362 | payer, 363 | [], 364 | 1, 365 | ), 366 | ); 367 | cleanupInstructions.push( 368 | Token.createRevokeInstruction( 369 | TOKEN_PROGRAM_ID, 370 | whitelistToken, 371 | payer, 372 | [], 373 | ), 374 | ); 375 | } 376 | } 377 | } 378 | 379 | if (candyMachine.state.tokenMint) { 380 | const transferAuthority = anchor.web3.Keypair.generate(); 381 | 382 | signers.push(transferAuthority); 383 | remainingAccounts.push({ 384 | pubkey: userPayingAccountAddress, 385 | isWritable: true, 386 | isSigner: false, 387 | }); 388 | remainingAccounts.push({ 389 | pubkey: transferAuthority.publicKey, 390 | isWritable: false, 391 | isSigner: true, 392 | }); 393 | 394 | instructions.push( 395 | Token.createApproveInstruction( 396 | TOKEN_PROGRAM_ID, 397 | userPayingAccountAddress, 398 | transferAuthority.publicKey, 399 | payer, 400 | [], 401 | candyMachine.state.price.toNumber(), 402 | ), 403 | ); 404 | cleanupInstructions.push( 405 | Token.createRevokeInstruction( 406 | TOKEN_PROGRAM_ID, 407 | userPayingAccountAddress, 408 | payer, 409 | [], 410 | ), 411 | ); 412 | } 413 | const metadataAddress = await getMetadata(mint.publicKey); 414 | const masterEdition = await getMasterEdition(mint.publicKey); 415 | 416 | const [candyMachineCreator, creatorBump] = await getCandyMachineCreator( 417 | candyMachineAddress, 418 | ); 419 | 420 | instructions.push( 421 | await candyMachine.program.instruction.mintNft(creatorBump, { 422 | accounts: { 423 | candyMachine: candyMachineAddress, 424 | candyMachineCreator, 425 | payer: payer, 426 | wallet: candyMachine.state.treasury, 427 | mint: mint.publicKey, 428 | metadata: metadataAddress, 429 | masterEdition, 430 | mintAuthority: payer, 431 | updateAuthority: payer, 432 | tokenMetadataProgram: TOKEN_METADATA_PROGRAM_ID, 433 | tokenProgram: TOKEN_PROGRAM_ID, 434 | systemProgram: SystemProgram.programId, 435 | rent: anchor.web3.SYSVAR_RENT_PUBKEY, 436 | clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, 437 | recentBlockhashes: anchor.web3.SYSVAR_RECENT_BLOCKHASHES_PUBKEY, 438 | instructionSysvarAccount: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY, 439 | }, 440 | remainingAccounts: 441 | remainingAccounts.length > 0 ? remainingAccounts : undefined, 442 | }), 443 | ); 444 | 445 | try { 446 | return ( 447 | await sendTransactions( 448 | candyMachine.program.provider.connection, 449 | candyMachine.program.provider.wallet, 450 | [instructions, cleanupInstructions], 451 | [signers, []], 452 | ) 453 | ).txs.map(t => t.txid); 454 | } catch (e) { 455 | console.log(e); 456 | } 457 | 458 | return []; 459 | }; 460 | 461 | export const shortenAddress = (address: string, chars = 4): string => { 462 | return `${address.slice(0, chars)}...${address.slice(-chars)}`; 463 | }; 464 | 465 | const sleep = (ms: number): Promise => { 466 | return new Promise(resolve => setTimeout(resolve, ms)); 467 | }; 468 | -------------------------------------------------------------------------------- /src/connection.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Keypair, 3 | Commitment, 4 | Connection, 5 | RpcResponseAndContext, 6 | SignatureStatus, 7 | SimulatedTransactionResponse, 8 | Transaction, 9 | TransactionInstruction, 10 | TransactionSignature, 11 | Blockhash, 12 | FeeCalculator, 13 | } from '@solana/web3.js'; 14 | 15 | import { WalletNotConnectedError } from '@solana/wallet-adapter-base'; 16 | 17 | interface BlockhashAndFeeCalculator { 18 | blockhash: Blockhash; 19 | feeCalculator: FeeCalculator; 20 | } 21 | 22 | export const getErrorForTransaction = async ( 23 | connection: Connection, 24 | txid: string, 25 | ) => { 26 | // wait for all confirmation before geting transaction 27 | await connection.confirmTransaction(txid, 'max'); 28 | 29 | const tx = await connection.getParsedConfirmedTransaction(txid); 30 | 31 | const errors: string[] = []; 32 | if (tx?.meta && tx.meta.logMessages) { 33 | tx.meta.logMessages.forEach(log => { 34 | const regex = /Error: (.*)/gm; 35 | let m; 36 | while ((m = regex.exec(log)) !== null) { 37 | // This is necessary to avoid infinite loops with zero-width matches 38 | if (m.index === regex.lastIndex) { 39 | regex.lastIndex++; 40 | } 41 | 42 | if (m.length > 1) { 43 | errors.push(m[1]); 44 | } 45 | } 46 | }); 47 | } 48 | 49 | return errors; 50 | }; 51 | 52 | export enum SequenceType { 53 | Sequential, 54 | Parallel, 55 | StopOnFailure, 56 | } 57 | 58 | export async function sendTransactionsWithManualRetry( 59 | connection: Connection, 60 | wallet: any, 61 | instructions: TransactionInstruction[][], 62 | signers: Keypair[][], 63 | ): Promise<(string | undefined)[]> { 64 | let stopPoint = 0; 65 | let tries = 0; 66 | let lastInstructionsLength = null; 67 | let toRemoveSigners: Record = {}; 68 | instructions = instructions.filter((instr, i) => { 69 | if (instr.length > 0) { 70 | return true; 71 | } else { 72 | toRemoveSigners[i] = true; 73 | return false; 74 | } 75 | }); 76 | let ids: string[] = []; 77 | let filteredSigners = signers.filter((_, i) => !toRemoveSigners[i]); 78 | 79 | while (stopPoint < instructions.length && tries < 3) { 80 | instructions = instructions.slice(stopPoint, instructions.length); 81 | filteredSigners = filteredSigners.slice(stopPoint, filteredSigners.length); 82 | 83 | if (instructions.length === lastInstructionsLength) tries = tries + 1; 84 | else tries = 0; 85 | 86 | try { 87 | if (instructions.length === 1) { 88 | const id = await sendTransactionWithRetry( 89 | connection, 90 | wallet, 91 | instructions[0], 92 | filteredSigners[0], 93 | 'single', 94 | ); 95 | ids.push(id.txid); 96 | stopPoint = 1; 97 | } else { 98 | const { txs } = await sendTransactions( 99 | connection, 100 | wallet, 101 | instructions, 102 | filteredSigners, 103 | SequenceType.StopOnFailure, 104 | 'single', 105 | ); 106 | ids = ids.concat(txs.map(t => t.txid)); 107 | } 108 | } catch (e) { 109 | console.error(e); 110 | } 111 | console.log( 112 | 'Died on ', 113 | stopPoint, 114 | 'retrying from instruction', 115 | instructions[stopPoint], 116 | 'instructions length is', 117 | instructions.length, 118 | ); 119 | lastInstructionsLength = instructions.length; 120 | } 121 | 122 | return ids; 123 | } 124 | 125 | export const sendTransactions = async ( 126 | connection: Connection, 127 | wallet: any, 128 | instructionSet: TransactionInstruction[][], 129 | signersSet: Keypair[][], 130 | sequenceType: SequenceType = SequenceType.Parallel, 131 | commitment: Commitment = 'singleGossip', 132 | successCallback: (txid: string, ind: number) => void = (txid, ind) => {}, 133 | failCallback: (reason: string, ind: number) => boolean = (txid, ind) => false, 134 | block?: BlockhashAndFeeCalculator, 135 | ): Promise<{ number: number; txs: { txid: string; slot: number }[] }> => { 136 | if (!wallet.publicKey) throw new WalletNotConnectedError(); 137 | 138 | const unsignedTxns: Transaction[] = []; 139 | 140 | if (!block) { 141 | block = await connection.getRecentBlockhash(commitment); 142 | } 143 | 144 | for (let i = 0; i < instructionSet.length; i++) { 145 | const instructions = instructionSet[i]; 146 | const signers = signersSet[i]; 147 | 148 | if (instructions.length === 0) { 149 | continue; 150 | } 151 | 152 | let transaction = new Transaction(); 153 | instructions.forEach(instruction => transaction.add(instruction)); 154 | transaction.recentBlockhash = block.blockhash; 155 | transaction.setSigners( 156 | // fee payed by the wallet owner 157 | wallet.publicKey, 158 | ...signers.map(s => s.publicKey), 159 | ); 160 | 161 | if (signers.length > 0) { 162 | transaction.partialSign(...signers); 163 | } 164 | 165 | unsignedTxns.push(transaction); 166 | } 167 | 168 | const signedTxns = await wallet.signAllTransactions(unsignedTxns); 169 | 170 | const pendingTxns: Promise<{ txid: string; slot: number }>[] = []; 171 | 172 | let breakEarlyObject = { breakEarly: false, i: 0 }; 173 | console.log( 174 | 'Signed txns length', 175 | signedTxns.length, 176 | 'vs handed in length', 177 | instructionSet.length, 178 | ); 179 | for (let i = 0; i < signedTxns.length; i++) { 180 | const signedTxnPromise = sendSignedTransaction({ 181 | connection, 182 | signedTransaction: signedTxns[i], 183 | }); 184 | 185 | signedTxnPromise 186 | .then(({ txid, slot }) => { 187 | successCallback(txid, i); 188 | }) 189 | .catch(reason => { 190 | // @ts-ignore 191 | failCallback(signedTxns[i], i); 192 | if (sequenceType === SequenceType.StopOnFailure) { 193 | breakEarlyObject.breakEarly = true; 194 | breakEarlyObject.i = i; 195 | } 196 | }); 197 | 198 | if (sequenceType !== SequenceType.Parallel) { 199 | try { 200 | await signedTxnPromise; 201 | } catch (e) { 202 | console.log('Caught failure', e); 203 | if (breakEarlyObject.breakEarly) { 204 | console.log('Died on ', breakEarlyObject.i); 205 | // Return the txn we failed on by index 206 | return { 207 | number: breakEarlyObject.i, 208 | txs: await Promise.all(pendingTxns), 209 | }; 210 | } 211 | } 212 | } else { 213 | pendingTxns.push(signedTxnPromise); 214 | } 215 | } 216 | 217 | if (sequenceType !== SequenceType.Parallel) { 218 | await Promise.all(pendingTxns); 219 | } 220 | 221 | return { number: signedTxns.length, txs: await Promise.all(pendingTxns) }; 222 | }; 223 | 224 | export const sendTransaction = async ( 225 | connection: Connection, 226 | wallet: any, 227 | instructions: TransactionInstruction[], 228 | signers: Keypair[], 229 | awaitConfirmation = true, 230 | commitment: Commitment = 'singleGossip', 231 | includesFeePayer: boolean = false, 232 | block?: BlockhashAndFeeCalculator, 233 | ) => { 234 | if (!wallet.publicKey) throw new WalletNotConnectedError(); 235 | 236 | let transaction = new Transaction(); 237 | instructions.forEach(instruction => transaction.add(instruction)); 238 | transaction.recentBlockhash = ( 239 | block || (await connection.getRecentBlockhash(commitment)) 240 | ).blockhash; 241 | 242 | if (includesFeePayer) { 243 | transaction.setSigners(...signers.map(s => s.publicKey)); 244 | } else { 245 | transaction.setSigners( 246 | // fee payed by the wallet owner 247 | wallet.publicKey, 248 | ...signers.map(s => s.publicKey), 249 | ); 250 | } 251 | 252 | if (signers.length > 0) { 253 | transaction.partialSign(...signers); 254 | } 255 | if (!includesFeePayer) { 256 | transaction = await wallet.signTransaction(transaction); 257 | } 258 | 259 | const rawTransaction = transaction.serialize(); 260 | let options = { 261 | skipPreflight: true, 262 | commitment, 263 | }; 264 | 265 | const txid = await connection.sendRawTransaction(rawTransaction, options); 266 | let slot = 0; 267 | 268 | if (awaitConfirmation) { 269 | const confirmation = await awaitTransactionSignatureConfirmation( 270 | txid, 271 | DEFAULT_TIMEOUT, 272 | connection, 273 | commitment, 274 | ); 275 | 276 | if (!confirmation) 277 | throw new Error('Timed out awaiting confirmation on transaction'); 278 | slot = confirmation?.slot || 0; 279 | 280 | if (confirmation?.err) { 281 | const errors = await getErrorForTransaction(connection, txid); 282 | 283 | console.log(errors); 284 | throw new Error(`Raw transaction ${txid} failed`); 285 | } 286 | } 287 | 288 | return { txid, slot }; 289 | }; 290 | 291 | export const sendTransactionWithRetry = async ( 292 | connection: Connection, 293 | wallet: any, 294 | instructions: TransactionInstruction[], 295 | signers: Keypair[], 296 | commitment: Commitment = 'singleGossip', 297 | includesFeePayer: boolean = false, 298 | block?: BlockhashAndFeeCalculator, 299 | beforeSend?: () => void, 300 | ) => { 301 | if (!wallet.publicKey) throw new WalletNotConnectedError(); 302 | 303 | let transaction = new Transaction(); 304 | instructions.forEach(instruction => transaction.add(instruction)); 305 | transaction.recentBlockhash = ( 306 | block || (await connection.getRecentBlockhash(commitment)) 307 | ).blockhash; 308 | 309 | if (includesFeePayer) { 310 | transaction.setSigners(...signers.map(s => s.publicKey)); 311 | } else { 312 | transaction.setSigners( 313 | // fee payed by the wallet owner 314 | wallet.publicKey, 315 | ...signers.map(s => s.publicKey), 316 | ); 317 | } 318 | 319 | if (signers.length > 0) { 320 | transaction.partialSign(...signers); 321 | } 322 | if (!includesFeePayer) { 323 | transaction = await wallet.signTransaction(transaction); 324 | } 325 | 326 | if (beforeSend) { 327 | beforeSend(); 328 | } 329 | 330 | const { txid, slot } = await sendSignedTransaction({ 331 | connection, 332 | signedTransaction: transaction, 333 | }); 334 | 335 | return { txid, slot }; 336 | }; 337 | 338 | export const getUnixTs = () => { 339 | return new Date().getTime() / 1000; 340 | }; 341 | 342 | const DEFAULT_TIMEOUT = 15000; 343 | 344 | export async function sendSignedTransaction({ 345 | signedTransaction, 346 | connection, 347 | timeout = DEFAULT_TIMEOUT, 348 | }: { 349 | signedTransaction: Transaction; 350 | connection: Connection; 351 | sendingMessage?: string; 352 | sentMessage?: string; 353 | successMessage?: string; 354 | timeout?: number; 355 | }): Promise<{ txid: string; slot: number }> { 356 | const rawTransaction = signedTransaction.serialize(); 357 | const startTime = getUnixTs(); 358 | let slot = 0; 359 | const txid: TransactionSignature = await connection.sendRawTransaction( 360 | rawTransaction, 361 | { 362 | skipPreflight: true, 363 | }, 364 | ); 365 | 366 | console.log('Started awaiting confirmation for', txid); 367 | 368 | let done = false; 369 | (async () => { 370 | while (!done && getUnixTs() - startTime < timeout) { 371 | connection.sendRawTransaction(rawTransaction, { 372 | skipPreflight: true, 373 | }); 374 | await sleep(500); 375 | } 376 | })(); 377 | try { 378 | const confirmation = await awaitTransactionSignatureConfirmation( 379 | txid, 380 | timeout, 381 | connection, 382 | 'recent', 383 | true, 384 | ); 385 | 386 | if (!confirmation) 387 | throw new Error('Timed out awaiting confirmation on transaction'); 388 | 389 | if (confirmation.err) { 390 | console.error(confirmation.err); 391 | throw new Error('Transaction failed: Custom instruction error'); 392 | } 393 | 394 | slot = confirmation?.slot || 0; 395 | } catch (err: any) { 396 | console.error('Timeout Error caught', err); 397 | if (err.timeout) { 398 | throw new Error('Timed out awaiting confirmation on transaction'); 399 | } 400 | let simulateResult: SimulatedTransactionResponse | null = null; 401 | try { 402 | simulateResult = ( 403 | await simulateTransaction(connection, signedTransaction, 'single') 404 | ).value; 405 | } catch (e) {} 406 | if (simulateResult && simulateResult.err) { 407 | if (simulateResult.logs) { 408 | for (let i = simulateResult.logs.length - 1; i >= 0; --i) { 409 | const line = simulateResult.logs[i]; 410 | if (line.startsWith('Program log: ')) { 411 | throw new Error( 412 | 'Transaction failed: ' + line.slice('Program log: '.length), 413 | ); 414 | } 415 | } 416 | } 417 | throw new Error(JSON.stringify(simulateResult.err)); 418 | } 419 | // throw new Error('Transaction failed'); 420 | } finally { 421 | done = true; 422 | } 423 | 424 | console.log('Latency', txid, getUnixTs() - startTime); 425 | return { txid, slot }; 426 | } 427 | 428 | async function simulateTransaction( 429 | connection: Connection, 430 | transaction: Transaction, 431 | commitment: Commitment, 432 | ): Promise> { 433 | // @ts-ignore 434 | transaction.recentBlockhash = await connection._recentBlockhash( 435 | // @ts-ignore 436 | connection._disableBlockhashCaching, 437 | ); 438 | 439 | const signData = transaction.serializeMessage(); 440 | // @ts-ignore 441 | const wireTransaction = transaction._serialize(signData); 442 | const encodedTransaction = wireTransaction.toString('base64'); 443 | const config: any = { encoding: 'base64', commitment }; 444 | const args = [encodedTransaction, config]; 445 | 446 | // @ts-ignore 447 | const res = await connection._rpcRequest('simulateTransaction', args); 448 | if (res.error) { 449 | throw new Error('failed to simulate transaction: ' + res.error.message); 450 | } 451 | return res.result; 452 | } 453 | 454 | async function awaitTransactionSignatureConfirmation( 455 | txid: TransactionSignature, 456 | timeout: number, 457 | connection: Connection, 458 | commitment: Commitment = 'recent', 459 | queryStatus = false, 460 | ): Promise { 461 | let done = false; 462 | let status: SignatureStatus | null | void = { 463 | slot: 0, 464 | confirmations: 0, 465 | err: null, 466 | }; 467 | let subId = 0; 468 | status = await new Promise(async (resolve, reject) => { 469 | setTimeout(() => { 470 | if (done) { 471 | return; 472 | } 473 | done = true; 474 | console.log('Rejecting for timeout...'); 475 | reject({ timeout: true }); 476 | }, timeout); 477 | try { 478 | subId = connection.onSignature( 479 | txid, 480 | (result, context) => { 481 | done = true; 482 | status = { 483 | err: result.err, 484 | slot: context.slot, 485 | confirmations: 0, 486 | }; 487 | if (result.err) { 488 | console.log('Rejected via websocket', result.err); 489 | reject(status); 490 | } else { 491 | console.log('Resolved via websocket', result); 492 | resolve(status); 493 | } 494 | }, 495 | commitment, 496 | ); 497 | } catch (e) { 498 | done = true; 499 | console.error('WS error in setup', txid, e); 500 | } 501 | while (!done && queryStatus) { 502 | // eslint-disable-next-line no-loop-func 503 | (async () => { 504 | try { 505 | const signatureStatuses = await connection.getSignatureStatuses([ 506 | txid, 507 | ]); 508 | status = signatureStatuses && signatureStatuses.value[0]; 509 | if (!done) { 510 | if (!status) { 511 | console.log('REST null result for', txid, status); 512 | } else if (status.err) { 513 | console.log('REST error for', txid, status); 514 | done = true; 515 | reject(status.err); 516 | } else if (!status.confirmations) { 517 | console.log('REST no confirmations for', txid, status); 518 | } else { 519 | console.log('REST confirmation for', txid, status); 520 | done = true; 521 | resolve(status); 522 | } 523 | } 524 | } catch (e) { 525 | if (!done) { 526 | console.log('REST connection error: txid', txid, e); 527 | } 528 | } 529 | })(); 530 | await sleep(2000); 531 | } 532 | }); 533 | 534 | //@ts-ignore 535 | if (connection._signatureSubscriptions[subId]) 536 | connection.removeSignatureListener(subId); 537 | done = true; 538 | console.log('Returning status', status); 539 | return status; 540 | } 541 | export function sleep(ms: number): Promise { 542 | return new Promise(resolve => setTimeout(resolve, ms)); 543 | } 544 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #000000; 3 | margin: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import reportWebVitals from './reportWebVitals'; 5 | 6 | import './index.css'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root'), 13 | ); 14 | 15 | // If you want to start measuring performance in your app, pass a function 16 | // to log results (for example: reportWebVitals(console.log)) 17 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 18 | reportWebVitals(); 19 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as anchor from '@project-serum/anchor'; 2 | import { TOKEN_PROGRAM_ID } from '@solana/spl-token'; 3 | import { SystemProgram } from '@solana/web3.js'; 4 | import { 5 | LAMPORTS_PER_SOL, 6 | SYSVAR_RENT_PUBKEY, 7 | TransactionInstruction, 8 | } from '@solana/web3.js'; 9 | 10 | export interface AlertState { 11 | open: boolean; 12 | message: string; 13 | severity: 'success' | 'info' | 'warning' | 'error' | undefined; 14 | } 15 | 16 | export const toDate = (value?: anchor.BN) => { 17 | if (!value) { 18 | return; 19 | } 20 | 21 | return new Date(value.toNumber() * 1000); 22 | }; 23 | 24 | const numberFormater = new Intl.NumberFormat('en-US', { 25 | style: 'decimal', 26 | minimumFractionDigits: 2, 27 | maximumFractionDigits: 2, 28 | }); 29 | 30 | export const formatNumber = { 31 | format: (val?: number) => { 32 | if (!val) { 33 | return '--'; 34 | } 35 | 36 | return numberFormater.format(val); 37 | }, 38 | asNumber: (val?: anchor.BN) => { 39 | if (!val) { 40 | return undefined; 41 | } 42 | 43 | return val.toNumber() / LAMPORTS_PER_SOL; 44 | }, 45 | }; 46 | 47 | export const SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID = 48 | new anchor.web3.PublicKey('ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'); 49 | 50 | export const CIVIC = new anchor.web3.PublicKey( 51 | 'gatem74V238djXdzWnJf94Wo1DcnuGkfijbf3AuBhfs', 52 | ); 53 | 54 | export const getAtaForMint = async ( 55 | mint: anchor.web3.PublicKey, 56 | buyer: anchor.web3.PublicKey, 57 | ): Promise<[anchor.web3.PublicKey, number]> => { 58 | return await anchor.web3.PublicKey.findProgramAddress( 59 | [buyer.toBuffer(), TOKEN_PROGRAM_ID.toBuffer(), mint.toBuffer()], 60 | SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, 61 | ); 62 | }; 63 | 64 | export const getNetworkExpire = async ( 65 | gatekeeperNetwork: anchor.web3.PublicKey, 66 | ): Promise<[anchor.web3.PublicKey, number]> => { 67 | return await anchor.web3.PublicKey.findProgramAddress( 68 | [gatekeeperNetwork.toBuffer(), Buffer.from('expire')], 69 | CIVIC, 70 | ); 71 | }; 72 | 73 | export const getNetworkToken = async ( 74 | wallet: anchor.web3.PublicKey, 75 | gatekeeperNetwork: anchor.web3.PublicKey, 76 | ): Promise<[anchor.web3.PublicKey, number]> => { 77 | return await anchor.web3.PublicKey.findProgramAddress( 78 | [ 79 | wallet.toBuffer(), 80 | Buffer.from('gateway'), 81 | Buffer.from([0, 0, 0, 0, 0, 0, 0, 0]), 82 | gatekeeperNetwork.toBuffer(), 83 | ], 84 | CIVIC, 85 | ); 86 | }; 87 | 88 | export function createAssociatedTokenAccountInstruction( 89 | associatedTokenAddress: anchor.web3.PublicKey, 90 | payer: anchor.web3.PublicKey, 91 | walletAddress: anchor.web3.PublicKey, 92 | splTokenMintAddress: anchor.web3.PublicKey, 93 | ) { 94 | const keys = [ 95 | { 96 | pubkey: payer, 97 | isSigner: true, 98 | isWritable: true, 99 | }, 100 | { 101 | pubkey: associatedTokenAddress, 102 | isSigner: false, 103 | isWritable: true, 104 | }, 105 | { 106 | pubkey: walletAddress, 107 | isSigner: false, 108 | isWritable: false, 109 | }, 110 | { 111 | pubkey: splTokenMintAddress, 112 | isSigner: false, 113 | isWritable: false, 114 | }, 115 | { 116 | pubkey: SystemProgram.programId, 117 | isSigner: false, 118 | isWritable: false, 119 | }, 120 | { 121 | pubkey: TOKEN_PROGRAM_ID, 122 | isSigner: false, 123 | isWritable: false, 124 | }, 125 | { 126 | pubkey: SYSVAR_RENT_PUBKEY, 127 | isSigner: false, 128 | isWritable: false, 129 | }, 130 | ]; 131 | return new TransactionInstruction({ 132 | keys, 133 | programId: SPL_ASSOCIATED_TOKEN_ACCOUNT_PROGRAM_ID, 134 | data: Buffer.from([]), 135 | }); 136 | } 137 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------