# Lesson 1 - What are we building? Welcome fellow builder! My name is Matt and I'm going to be your sticker dealer! You are going to join a special group of credit card anarchists who are building the future of digital payments. Or to put it simply, you will be learning how to build an IRL payment processor with Solana Pay that issues Coupons and Receipts in the form of Compressed NFT's. Right about now you're probably asking, "What makes this different than Square, PayPal, or one of the major IRL players like Visa and Mastercard?" The answer: * transaction fees are .00001 SOL, a fraction of 1 cent * privacy, quit sharing your customer data with the greedy corporations * instant fund settlement, quit waiting for your cash to be released So lets dive in... ๐Ÿ›  **The project** So what will we be building here :)? Here's a little video: <iframe width="560" height="315" src="https://www.youtube.com/embed/-KeHLpFBgds?si=x8bpcpfw49AftK_Q" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe> If you have no experience w/ React **or** Next - checkout [this intro tutorial](https://www.freecodecamp.org/news/nextjs-tutorial/) before you get started with this or maybe check out the intro docs [here](https://nextjs.org/learn/foundations/about-nextjs). You will also need the [Solana Tool Suite](https://docs.solana.com/cli/install-solana-cli-tools) so you can create your own compressed NFT collections from the terminal. Trust me, it's pretty sweet! :) ๐Ÿคš **Where to find Help** Find us on [X](https://twitter.com/swissDAOspace) formerly known as [Twitter](https://twitter.com/swissDAOspace), or join us on [Telegram](https://t.me/+8kAfO-simRkxY2Jh). ๐Ÿค˜ **See an issue? Want to improve something? Fix it yourself ;)** All this content is completely open-source. If you see an issue, typo, etc โ€” you can fix it yourself easily and make a PR! At the very least, drop a โญ on the repo if you're feeling fancy! Let's get you some open-source rep!!! # Lesson 2 - Let's Grab the Client Code Time to make the money printer go **BRRRRRR!!!!** ### ๐Ÿฅ… Goals Here's the plan: you are going to * โŒจ๏ธ display products * ๐Ÿ›’ add them to a cart * ๐Ÿ“ฑ generate a scannable Solana Pay QR Code * ๐Ÿค‘ get paid Once the transaction is confirmed on the Solana Blockchain 1-2 things will happen: **1.** A receipt will be minted and airdropped to the buyer's wallet as a cNFT (compressed NFT) **2.** A coupon will be sent to the buyer IF the order was greater than $10 We'll get to what a compressed NFT is later. Because we are only producing a QR Code this app is intended for IRL use, however you could easily incorporate Solana's [Wallet Adapter](https://www.npmjs.com/package/@solana/wallet-adapter-wallets) then allow a buyer to connect and pay via web browser. At the end I'll teach you how to deploy this app live, because did you really build it if you can't share it??? Let's get started! ### โฌ‡๏ธ Getting the code For security purposes, Solana Pay requires `https://` for their transaction url's, so we will use `ngrok` to stream our `localhost` through their secure server so we don't have to deploy every change and then test. So before we grab the client code let's download and install [ngrok](https://ngrok.com/download) so our QR codes work in development. Once downloaded, you can get your Auth Token [here](https://dashboard.ngrok.com/get-started/setup) and add it with: ``` ngrok config add-authtoken <token> ``` We won't use this right away, so if you're having trouble just keep going and ping us on X/Twitter or Telegram. Head over to [this link](https://github.com/swissDAO/solana-pay-cnfts/tree/starter) and click "fork" on the top right. ![Fork GitHub Repo](https://i.imgur.com/OnOIO2A.png) When you fork this repo, you are actually creating an identical copy of it that lives on your GitHub profile. Now you can make all the changes your builder heart desires! The final step here is to actually get your newly forked repo on your local machine. Click the "Code" button and copy that link! Head to your terminal and `cd` into whatever directory your project will live in. I'm putting mine on the Desktop. Next, clone it down from GitHub and `cd` into it, then `git checkout` to the starter branch called `starter`. ``` cd ~/Desktop git clone https://github.com/swissDAO/solana-pay-cnfts.git cd solana-pay-cnfts git checkout starter ``` Once you have that done, let's install the packages and see what we have: ``` yarn install yarn dev ``` If you see this in your terminal, you are good to go! ![Yarn Dev Success](https://hackmd.io/_uploads/Hy93ONCp3.png) Open your browser, type/paste in the `localhost` url and you should see this. ![Starter Image](https://hackmd.io/_uploads/SJ5EvNATh.png) Sick! What you have now is the stripped code base for what we are building. From here I will help you navigate this code, understand the important pieces, and show you what to add to make it functional. My one request: **TYPE THE CODE YOURSELF!!** You can easily copy and paste the snippets provided, but you won't *really* learn that way. So take your time, read the comments provided and try to truly grasp whats going on. Now click the **Make it rain** button and let's get this money printer started!!! ### ๐Ÿ›’ Store Products & Shopping Cart Let's talk overall goal real quick. The way we are building this app is for a singular store where products do not change often. For this reason, we are hardcoding the products into our code. But, what if you wanted something more dynamic, like a Web3 Shopify platform where anyone could create stores and add products? In that case, you would add a database of some sort to your app. Some may use Amazon Web Services S3 or some may want a decentralized approach like IPFS. However, for simplicity sake we will hardcode the products and focus more on Solana Pay/cNFT's use case. Open up your code in whatever your preferred IDE is, for anyone new I recommend [VS Code](https://code.visualstudio.com/). Navigate to `products.tsx` inside of the `constants` directory and let's add some products. For my products, I'm going to keep it simple: * ID * Name * Price * Description * Image You aren't just limited to these fields, if this were a clothing shop you may add a `variants` field that contains sizes or color options. Spice it up however you see fit! Mine looks like this: ``` /constants/products.tsx export const products = [ { id: 0, name: 'swissDAO Sticker', description: 'sticker with swissDAO logo', priceUsdc: 5, image: '/product_0.png' }, { id: 1, name: 'Sol Pay Sticker', description: 'sticker pack with solana pay logo', priceUsdc: 10, image: '/product_1.png' } ]; ``` What we are doing here is exporting an array of objects we are calling `products`. Like previously mentioned, each product has an id, name, description, price, and image. Few things to note are: * With a database your product ID should correlate to the ID in the database. * Images right now are linked to 2 I've included for you in the `public` folder, feel free to switch them up! * We are using priceUsdc here, but you can swap this for any token you prefer. Why are we using USDC? In Web3 the prices of a token can vary, but with [stablecoins](https://dslsingapore.medium.com/the-role-of-stablecoins-in-web3-ac715a84becb#:~:text=Stablecoins%20play%20a%20crucial%20role%20in%20the%20Web3%20ecosystem%20as,to%20maintain%20a%20stable%20value.) like USDC you can always guarantee 1 = 1. Now let's get those products to populate on our page. Open up your `/src/app/page.tsx`. In React/Next.JS this is what you could consider your 'Homepage'. If you were building a large scale project this page would be much more sparse because you would isolate portions into `components` or `hooks`, but again for simplicity sake we'll do the bulk of our work here. Now, I've already created placeholder functions for the majority of our needs, but let's build one to render our products. You can see on `ln 9` that we are already importing our `products` so let's map them out. Below `const notify` let's input this: ``` /src/app/page.tsx export default function Home() { const [qrActive, setQrActive] = useState(false); const [cart, setCart] = useState<any[]>([]); const notify = (message: string ) => toast(message); const renderProducts = () => { return( <div className="flex flex-col items-center justify-center w-full max-w-5xl font-mono text-sm lg:flex-row gap-10"> {products.map((product, index) => { return ( <div className="flex flex-col items-center" key={index}> <Image src={product.image!} style={{ position: "relative", background: "transparent" }} alt={product.name} width={200} height={200} /> <div className="text-lg font-semibold">{product.name}</div> <div className="text-lg font-semibold">{product.description}</div> <div className="text-lg font-semibold">{product.priceUsdc} USDC</div> <div className="flex flex-row"> <button className='text-sm font-semibold p-2 bg-gray-200 rounded-md m-2 cursor-pointer hover:bg-black hover:text-white' onClick={() => { subtractFromCart(product.id); }} > - </button> <div className="flex flex-col items-center justify-center text-sm font-semibold p-2 bg-gray-200 rounded-md m-2"> {cart.find((item) => item.id === product.id)?.quantity || 0} </div> <button className='text-sm font-semibold p-2 bg-gray-200 rounded-md m-2 cursor-pointer hover:bg-black hover:text-white' onClick={() => { addToCart(product.id); }} > + </button> </div> </div> ) })} </div> ) } ...rest of code... } ``` Let's breakdown this down a little bit. The first 3 variables we have are: ``` const [qrActive, setQRActive] = useState(false); const [cart, setCart] = useState<any[]>([]); const notify = (message: string ) => toast(message); ``` Because we are building a single page app, we'll use `qrActive` to manage a couple things like displaying our products and checkout button. Our `cart` is self-explanatory. `notify` is tied to our `toast` package that is a cleaner way to display messages to the user. You'll see later, no big deal. Now for our new code, what we are doing is [mapping](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/Map) out our products we hardcoded. If this were tied to a live database, you would query the products then map. Quick note on the crazy `className`, that's [tailwind](https://tailwindcss.com/) and it helps style things quickly, especially when using it with something like [GitHub CoPilot](https://github.com/features/copilot) definitely recommend checking them both out. The first portion of our render is the product itself, displaying the image, name, description, and price. ``` <Image src={product.image!} style={{ position: "relative", background: "transparent" }} alt={product.name} width={200} height={200} /> <div className="text-lg font-semibold">{product.name}</div> <div className="text-lg font-semibold">{product.description}</div> <div className="text-lg font-semibold">{product.priceUsdc} USDC</div> ``` In the next portion we start building out three functions important with shopping: adding to cart, increasing and decreasing quantity. ``` <div className="flex flex-row"> <button className='text-sm font-semibold p-2 bg-gray-200 rounded-md m-2 cursor-pointer hover:bg-black hover:text-white' onClick={() => { subtractFromCart(product.id); }} > - </button> <div className="flex flex-col items-center justify-center text-sm font-semibold p-2 bg-gray-200 rounded-md m-2"> {cart.find((item) => item.id === product.id)?.quantity || 0} </div> <button className='text-sm font-semibold p-2 bg-gray-200 rounded-md m-2 cursor-pointer hover:bg-black hover:text-white' onClick={() => { addToCart(product.id); }} > + </button> </div> ``` Note how we are just passing in the `product.id` to the `subtractFromCart` and `addToCart`, we'll use that to manage the state of our cart. That way when it updates the `quantity` displayed will also dynamically update since we are using `cart.find`. Next, let's build out the `subtractFromCart` and `addToCart` functions. ``` const addToCart = (id: number) => { // map through the cart and find the item, if it exists then increment the quantity, otherwise add it to the cart const item = cart.find((item) => item.id === id); if(item) { item.quantity += 1; setCart([...cart]); } else { const product = products.find((product) => product.id === id); setCart([...cart, {id: product?.id, quantity: 1}]); } } const subtractFromCart = (id: number) => { // map through the cart if the quantity is 1 then remove the item from the cart, otherwise decrement the quantity const item = cart.find((item) => item.id === id); if(item) { if(item.quantity === 1) { const newCart = cart.filter((item) => item.id !== id); setCart([...newCart]); } else { item.quantity -= 1; setCart([...cart]); } } } ``` What we are doing is the same thing twice, just opposite ways. First we map through the existing `cart` state, in the `addToCart` function if the product is not found then it gets added, where in `subtractFromCart` if the product quantity is only 1 then it get's completely removed. Nice job, you just grasped the `useState` ability of React. Now let's replace our **BRRRRR!** .gif for our `renderProducts` function. ``` /src/app/page.tsx <main className="flex flex-col items-center justify-center min-h-screen py-2 bg-from-gray-100 via-gray-50 to-gray-100"> <h1 className="text-4xl font-bold text-center">Solana Pay Demo</h1> <div> <div className="flex-col z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex"> {renderProducts()} ...rest of code... </main> ``` Awesome, now what we should have is this: ![Products Displayed](https://hackmd.io/_uploads/rJxF1dRp2.png) ### ๐Ÿ›๏ธ Creating QR Code When you click the `-/+` buttons you should see the quantity increase and decrease accordingly. Now let's change the `Make it rain` button to it's actual purpose. If you look at the `onClick` of button you see it's calling on the `handleGenerateQR` function, so let's build that out next. At the top let's first declare a `qrRef` element we can manipulate on page. Then we'll create a `reference` state as well as a `generateNewReference` for when a transaction is complete. The `reference` is a random `PublicKey` we generate to attach to the transaction so we can confirm it later on. After that, we use React's `useMemo()` to update our order every time the cart is changed, which dynamically calculates the price. ``` /src/app/page.tsx export default function Home() { ... // ref to a div where we'll show the QR code const qrRef = useRef<HTMLDivElement>(null) const [reference, setReference] = useState<PublicKey>(Keypair.generate().publicKey); const generateNewReference = () => { setReference(Keypair.generate().publicKey); } const order = useMemo(() => { // map through the cart and get the total amount and update the order fixed to 2 decimals const total = cart.reduce((acc, item) => { const product = products.find((product) => product.id === item.id); return acc + (product?.priceUsdc || 0) * item.quantity; }, 0); const order = { products: cart, amount: total.toFixed(2), currency: 'USDC', reference: reference.toBase58(), } return order; }, [cart]); const handleGenerateQR = async () => { const { location } = window // convert order.products to a string of products=123+456&quantity=1+2 respectively const order_products = order?.products.map((product) => product.id).join('+'); const order_products_quantity = order?.products.map((product) => product.quantity).join('+'); const order_as_string = `products=${order_products}&quantity=${order_products_quantity}&amount=${order?.amount}&currency=${order?.currency}&reference=${order?.reference}` const apiUrl = `${location.protocol}//${location.host}/api/pay?${order_as_string}` console.log('order as string', order_as_string) console.log('api url', apiUrl) const urlParams: TransactionRequestURLFields = { link: new URL(apiUrl), label: "swissDAO", message: "Thanks for your order! ๐Ÿค‘", } const solanaUrl = encodeURL(urlParams) const qr = createQR(solanaUrl, 512, 'transparent') if (qrRef.current && order?.amount) { qrRef.current.innerHTML = '' qr.append(qrRef.current) } setQrActive(true); } ...rest of code... } ``` For the `handleGenerateQR` function we prepare our order that is then formed into a url and encoded into a Solana QR Code. When the QR Code is scanned it is sent to our backend at `/api/pay` and responds with a transaction for a wallet to sign. Hang with me here, it will make more sense soon. We'll check out `/api/pay` in a second, but takes a look at what we are sending there. ``` // convert order.products to a string of products=123+456&quantity=1+2 respectively const order_products = order?.products.map((product) => product.id).join('+'); const order_products_quantity = order?.products.map((product) => product.quantity).join('+'); const order_as_string = `products=${order_products}&quantity=${order_products_quantity}&amount=${order?.amount}&currency=${order?.currency}&reference=${order?.reference}` const apiUrl = `${location.protocol}//${location.host}/api/pay?${order_as_string}` ``` Remember how we are using `useMemo` on our `order` to track our cart? Well here we are taking that `order` and breaking it down into a URL style string we can pass to our API endpoint. You could hard code your official domain in + the dynamic `order_as_string`, but for testing purposes with `ngrok` our `${location.protocol}` and `${location.host}` will change frequently so here we are just having it capture whatever is currently in the browser with `{location} = window`. Once we have the `apiUrl` we are ready to create the QR ``` const urlParams: TransactionRequestURLFields = { link: new URL(apiUrl), label: "swissDAO", message: "Thanks for your order! ๐Ÿค‘", } ``` Solana Pay transactions have a certain structure you can read more about [here](https://docs.solanapay.com/spec#link). What we are doing here is providing the `urlParams` three things 1. [**link**](https://docs.solanapay.com/spec#link) - the url we just created, please note if we structure the link with out the necessary info the QR will still encode and display, but the wallet will reject it when scanned. 2. [**label**](https://docs.solanapay.com/spec#message) - The <label> value must be a UTF-8 string that describes the source of the transaction request. For example, this might be the name of a brand, store, application, or person making the request. 3. [**message**](https://docs.solanapay.com/spec#message) this is an optional that describes the nature of the transfer request. It's encoded in the url, then decoded and displayed to the user. This is how the transaction will utlimately look when scanned. ![Transaction from QR code](https://hackmd.io/_uploads/r1nKbQyA2.jpg) After creating the params we are ready to encode the url and get a QR Code. ``` const solanaUrl = encodeURL(urlParams) const qr = createQR(solanaUrl, 512, 'transparent') if (qrRef.current && order?.amount) { qrRef.current.innerHTML = '' qr.append(qrRef.current) } setQrActive(true); ``` Here we using our `@solana/pay` library to encode + create the URL, note if you are using a black background then change the `transparent` to a different color or you won't be able to see the actual QR. This threw me off for a complete hour as my laptop went into auto dark mode. Next, we use an `if` statement to make sure our [qrRef.current](https://legacy.reactjs.org/docs/refs-and-the-dom.html) exists and an order amount is present, then we append it to the `qr` we created. Now if you run `yarn dev` and click `Make it rain` you'll see your encoded in your developer console. Let's make it appear on screen now. Let's change up the `div` where we are rendering our products and `Make it rain` button. ``` /src/app/page.tsx <div> <div className="flex-col z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex"> {renderProducts()} <button className="z-20 px-4 py-2 font-bold text-white bg-red-500 rounded-lg shadow-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75" onClick={handleGenerateQR} > {!qrActive ? "Generate QR" : "Clear QR"} </button> <div ref={qrRef} /> </div> </div> ``` The main thing we are doing here is now including a `div` and attaching the `qrRef` to a reference within that `div`, so when the QR gets encoded it will populate. The other thing is changing our button text to `Generate QR` when no qr is present and `Clear QR` when it is present. This is what you should have when you click `Generate QR`. ![Qr code present](https://hackmd.io/_uploads/H1Rw_QyRn.png) Looks a little crowded so lets fix that by doing conditional rendering based on whether our `qrActive` variable is true or not. ``` {!qrActive && renderProducts()} ``` This way the products hide when the QR pops up. Sick! But now you probably notice clicking `Clear QR` just re-runs `handleGenerateQR`, let's fix that so it wipes out our current QR and displays our products back. Right below your `handleGenerateQR` let's add a function to clear the QR ``` const handleClearQR = () => { qrRef.current?.removeChild(qrRef.current?.firstChild!); setQrActive(false); } ``` Pretty simple, just removing the current reference attached to our qrRef div. Now let's add this function to our button. ``` <button className="z-20 px-4 py-2 font-bold text-white bg-red-500 rounded-lg shadow-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75" onClick={!qrActive ? handleGenerateQR : handleClearQR} > {!qrActive ? "Generate QR" : "Clear QR"} </button> ``` We are using a [ternary operator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator) that acts a condensed `if/else` statement, telling the code to either `handleGenerateQR` or `handleClearQR` depending on whether the `qrACtive` is true or not. ![QR Active](https://hackmd.io/_uploads/ryHN07kCn.png) Aweeeessooommmee!!! Now when you click generate, you should only see the QR and when you click clear you should just see the products and generate qr button. Notice when you scan the QR you see this: ![Invalid QR](https://hackmd.io/_uploads/rJJCCQJ03.jpg) That's expected at this point, but let's fix that after we take a quick 5 minute water break. If you want to compare your current state of code to what it should look like, check out this repo [here.](https://github.com/swissDAO/solana-pay-cnfts/tree/products/qr) # Lesson 3 - Setting up our API endpoint Before we start messing with our API, let's open up our `env.example` and replace some values. Right now, the two you need to fill in are: * NEXT_PUBLIC_RPC_URL * NEXT_PUBLIC_STORE_WALLET_ADDRESS The rest will be needed later, but for now these we will get our transaction working. For the `NEXT_PUBLIC_RPC_URL` I would recommend using [Helius](https://www.helius.dev/) because of their [DAS ReadAPI](https://www.helius.dev/blog/solana-nft-compression#reading-compressed-nfts), which makes them one of the few RPC's that can read compressed NFT's at the moment (will explain later). But again, we aren't dealing with the cNFT's just yet so you can use whomever, but later will need to change just FYI. For `NEXT_PUBLIC_STORE_WALLET_ADDRESS` enter your Solana Wallet Address. I would recommend have two accounts ready in your wallet so you can treat one as the Store and the other as the Customer wallet. If you do not have a Solana Wallet, then no worries! I would suggest starting with a Mobile Solana Wallet because you will need it for scanning the QR code. Check out [Phantom](https://phantom.app/) on your phone. COOL! Now rename that file to just `.env` and it's time to code. ### ๐Ÿค‘ Pay API Endpoint Go ahead and open up `/pages/api/pay.tsx`. This is where we are going to generate a transaction from our `apiURL` encoded in the QR code and have the frontend then request the user to approve the transaction. If you don't understand everything just yet, that's ok, keep building and I'll break it down. Here's the code: ``` /pages/api/pay.tsx import { NextApiRequest, NextApiResponse } from 'next'; import { Connection, PublicKey, Transaction } from '@solana/web3.js'; import { createTransferCheckedInstruction, getAssociatedTokenAddress, getMint } from "@solana/spl-token"; import BigNumber from 'bignumber.js'; import {printConsoleSeparator} from "../../utils/helpers"; import { WrapperConnection } from "../../ReadApi/WrapperConnection" import { clusterApiUrl, AccountMeta } from "@solana/web3.js"; import { PROGRAM_ID as BUBBLEGUM_PROGRAM_ID, createTransferInstruction, } from "@metaplex-foundation/mpl-bubblegum"; import { SPL_ACCOUNT_COMPRESSION_PROGRAM_ID, SPL_NOOP_PROGRAM_ID, ConcurrentMerkleTreeAccount, MerkleTree, MerkleTreeProof, } from "@solana/spl-account-compression"; export type MakeTransactionInputData = { account: string, } type MakeTransactionGetResponse = { label: string, icon: string, } export type MakeTransactionOutputData = { transaction: string, message: string, } type ErrorOutput = { error: string } // CONSTANTS const myWallet : string = process.env.NEXT_PUBLIC_STORE_WALLET_ADDRESS!; const shopPublicKey = new PublicKey(myWallet); const solanaConnection = new Connection(process.env.NEXT_PUBLIC_RPC_URL!, 'confirmed'); // Get details about the USDC token - Mainnet // const usdcAddress = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); // Get details about the USDC token - Devnet const usdcAddress =new PublicKey('Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr') function get(res: NextApiResponse<MakeTransactionGetResponse>) { res.status(200).json({ label: "swissDAO", icon: "https://freesvg.org/img/ch.png", }) } async function post( req: NextApiRequest, res: NextApiResponse<MakeTransactionOutputData | ErrorOutput> ) { try { // We pass the selected items in the query, calculate the expected cost // A general rule of thumb would be to calculate the cost server-side to avoid manipulation // But for the sake of simplicity we do it here const { amount } = req.query const amountBigNumber = new BigNumber(amount as string) // We pass the reference to use in the query const { reference } = req.query if (!reference) { res.status(400).json({ error: "No reference provided" }) return } // We pass the buyer's public key in JSON body const { account } = req.body as MakeTransactionInputData if (!account) { res.status(40).json({ error: "No account provided" }) return } const buyerPublicKey = new PublicKey(account) // Get details about the USDC token const usdcMint = await getMint(solanaConnection, usdcAddress) // Get the buyer's USDC token account address const buyerUsdcAddress = await getAssociatedTokenAddress(usdcAddress, buyerPublicKey) // Get the shop's USDC token account address const shopUsdcAddress = await getAssociatedTokenAddress(usdcAddress, shopPublicKey) // Get a recent blockhash to include in the transaction const { blockhash } = await (solanaConnection.getLatestBlockhash('finalized')) const transaction = new Transaction({ recentBlockhash: blockhash, // The buyer pays the transaction fee feePayer: buyerPublicKey, }) // Create the instruction to send USDC from the buyer to the shop const transferInstruction = createTransferCheckedInstruction( buyerUsdcAddress, // source usdcAddress, // mint (token address) shopUsdcAddress, // destination buyerPublicKey, // owner of source address ((amountBigNumber.toNumber() * (10 ** usdcMint.decimals))), // amount to transfer (in units of the USDC token) usdcMint.decimals, // decimals of the USDC token ) // Add the reference to the instruction as a key // This will mean this transaction is returned when we query for the reference transferInstruction.keys.push({ pubkey: new PublicKey(reference), isSigner: false, isWritable: false, }) // Add the instruction to the transaction transaction.add(transferInstruction) // Serialize the transaction and convert to base64 to return it const serializedTransaction = transaction.serialize({ // We will need the buyer to sign this transaction after it's returned to them requireAllSignatures: false }) const base64 = serializedTransaction.toString('base64') // Insert into database: reference, amount const message = "Thanks for your order! ๐Ÿค‘" // Return the serialized transaction res.status(200).json({ transaction: base64, message: message, }) } catch (err) { console.error(err); res.status(500).json({ error: 'error creating transaction', }) return } } export default async function handler( req: NextApiRequest, res: NextApiResponse<MakeTransactionGetResponse | MakeTransactionOutputData | ErrorOutput> ) { if (req.method === "GET") { return get(res) } else if (req.method === "POST") { return await post(req, res) } else { return res.status(405).json({ error: "Method not allowed" }) } } ``` That's a lot so let's bite off small portions to digest. We have a lot of imports and not all of them will be used at this moment, but I figured we just knock those out of the way. If you get any error message about unused imports just comment them out if it bothers you. First things first: ``` /pages/api/pay.tsx ...imports... export type MakeTransactionInputData = { account: string, } type MakeTransactionGetResponse = { label: string, icon: string, } export type MakeTransactionOutputData = { transaction: string, message: string, } type ErrorOutput = { error: string } ... rest of code... export default async function handler( req: NextApiRequest, res: NextApiResponse<MakeTransactionGetResponse | MakeTransactionOutputData | ErrorOutput> ) { if (req.method === "GET") { return get(res) } else if (req.method === "POST") { return await post(req, res) } else { return res.status(405).json({ error: "Method not allowed" }) } } ``` If you are familiar with TypeScript you can probably tell what's going on here, but just in case you don't I'll explain. In TypeScript you can have [static typing](https://www.typescriptlang.org/docs/handbook/2/basic-types.html#static-type-checking) which allows you to define a structure of a variable. When this variable is used it must match the defined structure or an error occurs. I like using TypeScript with Solana Pay development because it allows me to catch my errors when compiling, opposed catching errors on the client side like JavaScript. **HOWEVER**, YOU CAN STILL USE PLAIN JAVASCRIPT WITH SOLANA PAY. Don't let TypeScript stop you, it just makes JavaScript better IMO. Anyways, after defining our types and exporting, we create a `handler` at the bottom of our code because we are making this an API endpoint. Meaning, we need to tell our code how to respond to `GET` or `POST` request and what to do for an `Error`. Now let's look at our constants and `GET` request function. ``` /pages/api/pay.tsx ...rest of code ... // CONSTANTS const myWallet : string = process.env.NEXT_PUBLIC_STORE_WALLET_ADDRESS!; const shopPublicKey = new PublicKey(myWallet); const solanaConnection = new Connection(process.env.NEXT_PUBLIC_RPC_URL!, 'confirmed'); // Get details about the USDC token - Mainnet // const usdcAddress = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'); // Get details about the USDC token - Devnet const usdcAddress =new PublicKey('Gh9ZwEmdLJ8DscKNTkTqPbNwLNNBjuSzaG9Vp2KGtKJr') function get(res: NextApiResponse<MakeTransactionGetResponse>) { res.status(200).json({ label: "swissDAO", icon: "https://freesvg.org/img/ch.png", }) } ...rest of code... ``` Again, if you haven't already changed the `NEXT_PUBLIC_STORE_WALLET_ADDRESS`, `NEXT_PUBLIC_RPC_URL` and renamed the file to just `.env` then go ahead and do so. I would hate to receive all of your hard earned Devnet funds. On Solana accounts are stored as a `PublicKey` so we convert our wallet from a string address into our `shopPublicKey`, next we establish our connection to the Solana Devnet. Next we define our `usdcAddress` on Devnet this can get tricky because there is no **real** devnet usdc so the address can vary, but if you follow my steps for devnet funds you can use this address. On Mainnet the `usdcAddress` doesn't change from the `EPjF...Dt1v` you can check it out [Solana Explorer](https://explorer.solana.com/address/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v). For our `get` function, that is being used for any `GET` requests,we are responding with a `label` and `icon` in JSON form. For my icon I'm using the Swiss Flag, but feel free to switch it up! Now let's break down the `post` function into two parts. ``` /pages/api/pay.tsx async function post( req: NextApiRequest, res: NextApiResponse<MakeTransactionOutputData | ErrorOutput> ) { try { // We pass the selected items in the query, calculate the expected cost // A general rule of thumb would be to calculate the cost server-side to avoid manipulation // But for the sake of simplicity we do it here const { amount } = req.query const amountBigNumber = new BigNumber(amount as string) // We pass the reference to use in the query const { reference } = req.query if (!reference) { res.status(400).json({ error: "No reference provided" }) return } // We pass the buyer's public key in JSON body const { account } = req.body as MakeTransactionInputData if (!account) { res.status(40).json({ error: "No account provided" }) return } const buyerPublicKey = new PublicKey(account) // Get details about the USDC token const usdcMint = await getMint(solanaConnection, usdcAddress) // Get the buyer's USDC token account address const buyerUsdcAddress = await getAssociatedTokenAddress(usdcAddress, buyerPublicKey) // Get the shop's USDC token account address const shopUsdcAddress = await getAssociatedTokenAddress(usdcAddress, shopPublicKey) // Get a recent blockhash to include in the transaction const { blockhash } = await (solanaConnection.getLatestBlockhash('finalized')) ...rest of code... } ``` At the start of our we are grabbing our `amount` from the `req.query` (you know that `apiURL` we created earlier, that's our request). **Now I must mention taking in the amount here is bad practice!** If we wanted to make this super secure we would accept the order's products + quantities and then calculate the total `amount` on the back-end. The reason for this is it prevents any manipulation with the `apiURL`, buuuut for simplicity we'll do it this way. Next, we convert the `amount` to a `BigNumber` to prevent any number formatting issues, and then grab the `reference` from the `req.query`. Now, the next variable we are grabbing is `account` from the `req.body` instead of the `req.query`. If you recall, we never included the buyer's `PublicKey` so how does it know? `const { account } = req.body as MakeTransactionInputData` That's the cool thing about Solana Pay's QR codes, when the buyer scan's the QR code, it sends the Wallet's PublicKey to the encoded API. This is how we are able to form our Transaction with proper instructions back to the wallet for approval. This is also how we'll parse the wallet for coupons later! After converting the `account` back to a useable `PublicKey` we begin to prepare the other variables of our transaction. ``` // Get details about the USDC token const usdcMint = await getMint(solanaConnection, usdcAddress) // Get the buyer's USDC token account address const buyerUsdcAddress = await getAssociatedTokenAddress(usdcAddress, buyerPublicKey) // Get the shop's USDC token account address const shopUsdcAddress = await getAssociatedTokenAddress(usdcAddress, shopPublicKey) ``` Every Solana Token has a `Mint` address that uniquely identifies the token on chain, we use that to get the `associatedTokenAddress` of the accounts involved on the transaction. Now for the rest of the `post` function: ``` /pages/api/pay.tsx async function post( req: NextApiRequest, res: NextApiResponse<MakeTransactionOutputData | ErrorOutput> ) { try { ...rest of code ... // Get a recent blockhash to include in the transaction const { blockhash } = await (solanaConnection.getLatestBlockhash('finalized')) const transaction = new Transaction({ recentBlockhash: blockhash, // The buyer pays the transaction fee feePayer: buyerPublicKey, }) // Create the instruction to send USDC from the buyer to the shop const transferInstruction = createTransferCheckedInstruction( buyerUsdcAddress, // source usdcAddress, // mint (token address) shopUsdcAddress, // destination buyerPublicKey, // owner of source address ((amountBigNumber.toNumber() * (10 ** usdcMint.decimals))), // amount to transfer (in units of the USDC token) usdcMint.decimals, // decimals of the USDC token ) // Add the reference to the instruction as a key // This will mean this transaction is returned when we query for the reference transferInstruction.keys.push({ pubkey: new PublicKey(reference), isSigner: false, isWritable: false, }) // Add the instruction to the transaction transaction.add(transferInstruction) // Serialize the transaction and convert to base64 to return it const serializedTransaction = transaction.serialize({ // We will need the buyer to sign this transaction after it's returned to them requireAllSignatures: false }) const base64 = serializedTransaction.toString('base64') // Insert into database: reference, amount const message = "Thanks for your order! ๐Ÿค‘" // Return the serialized transaction res.status(200).json({ transaction: base64, message: message, }) } catch (err) { console.error(err); res.status(500).json({ error: 'error creating transaction', }) return } } ``` To start the transaction we use `getLatestBlockhash` to get a "timestamp" for the transaction, you can read more about it [here](https://docs.solana.com/developing/transaction_confirmation#:~:text=Each%20transaction%20includes%20a%20%E2%80%9Crecent,to%20process%20in%20a%20block.). Next, we begin to define our Transaction, attaching the latest blockhash and the `buyerPublicKey` as the `feePayer`. ``` const transaction = new Transaction({ recentBlockhash: blockhash, // The buyer pays the transaction fee feePayer: buyerPublicKey, }) ``` The fee payer here is responsible for paying the transaction fee, commonly referred to as gas. The [transfaction fee](https://docs.solana.com/transaction_fees) is paid to process the transfer instructions on chain. Next we create the transfer instructions and add them to the `transaction`. Each line has a comment breaking down it's purpose, but if you want to dig into it more check out these [Solana Docs](https://docs.solana.com/developing/programming-model/transactions#instruction-format), their team does a great job breaking the code down. To finish off the instructions we `serialize` the transaction and convert it to a base64 string that we send back to the wallet for approval. You may have noticed we redefine our message here instead of using the one we created on the front-end. This isn't necessary, but we'll be making it dynamic here in a minute, so we'll leave it for now. Ok, enough explaining, let's see what we got! In your terminal, run `yarn dev` and open up a second tab within the terminal and start up ngrok by running `ngrok http 3000`. Don't forget to use that forwarding address in your browser instead of `localhost:3000`. It should look like this: ![ngrok fowarding address](https://hackmd.io/_uploads/ByjHs_gR3.png) Awesome, now you should be able to scan your QR code and get a response. Before we get to what it should look like, let's talk about some possible errors. ## ๐Ÿชฒ Common Bugs If you scan your QR Code and receive this error: ![Transaction error](https://hackmd.io/_uploads/BJ9xltxCn.jpg) A few reasons this could be happening: * Your wallet is not on Devnet * You don't have Devnet funds * There is a bug in your /pages/api/pay.tsx I'll walk through real quick how to solve these, if you aren't having any issues here just skip ahead. **Your wallet is not on Devnet** This is a simple check, click the 'W1' icon at the top of your mobile wallet, open up your 'Developer Settings' and toggle on 'Testnet Mode'. It should automatically select Solana Devnet, but if not then select it. ![Phantom Wallet Header](https://hackmd.io/_uploads/rkpnGteC3.jpg) ![Phantom Settings Page](https://hackmd.io/_uploads/BJr1XYgRn.jpg) ![Phantom Developer Settings](https://hackmd.io/_uploads/S1_bQFxR2.jpg) **You don't have Devnet funds** I used this [faucet](https://spl-token-faucet.com/?token-name=USDC-Dev) to get both **USDC-Dev** (for the txn) and **SOL** (for the txn fee). You can use your browser wallet and send it to your mobile. ![Solana Devnet Faucet](https://hackmd.io/_uploads/SJcg5tx0h.png) **Bug in your /pages/api/pay.tsx** This one takes more problem solving than the others. I'm a big fan of using `console.log()` within in my code to make sure things are working as expected. For example: ``` const buyerPublicKey = new PublicKey(account) console.log("buyerPublicKey: ", buyerPublicKey.toBase58()); // Get details about the USDC token const usdcMint = await getMint(solanaConnection, usdcAddress) console.log("usdcMint: ", usdcMint.address.toBase58()); // Get the buyer's USDC token account address const buyerUsdcAddress = await getAssociatedTokenAddress(usdcAddress, buyerPublicKey) console.log("buyerUsdcAddress: ", buyerUsdcAddress.toBase58()); // Get the shop's USDC token account address const shopUsdcAddress = await getAssociatedTokenAddress(usdcAddress, shopPublicKey) console.log("shopUsdcAddress: ", shopUsdcAddress.toBase58()); ``` The `console.log()` in your `/api/pay.tsx` writes the corresponding values into your terminal when you scan the QR Code. ![Terminal console logs](https://hackmd.io/_uploads/rkBWRFeC3.png) You can use this to target errors typically stemming from an `undefined` or `null` value. ## ๐Ÿฅต Still stuck? No worries! Compare your code to the repo [here](https://github.com/swissDAO/solana-pay-cnfts/tree/api/pay). # Lesson 4 - Creating a compressed NFT Collection ## ๐Ÿคจ What is a compressed NFT? Time to switch gears and cover one of the **newest** things on Solana: [Compressed NFTs](https://docs.solana.com/developing/guides/compressed-nfts#intro-to-compressed-nfts). The concept of Compressed NFTs can get pretty complex, but two key features are these: * [**State Compression**](https://docs.solana.com/learn/state-compression#what-is-state-compression) * [**Merkle Trees**](https://docs.solana.com/learn/state-compression#what-is-a-merkle-tree) Which allow for even cheaper storage on-chain with out sacrificing any security. A good post to read is [Helius.Dev's](https://www.helius.dev/blog/solana-nft-compression) blog on Solana compressed NFTs. Here is their **TL;DR.** ![Helius.Dev Blog on compressed NFTs](https://hackmd.io/_uploads/HJIjvcgA2.png) That's wild if you ask me. Let's estimate SOL at a price of $25 USD, that would mean each cNFT would only cost $0.000125!!!! That is what **Only Possible on Solana** means. Now there are some really cool [use cases for cNFTs](https://www.helius.dev/blog/solana-nft-compression#compression-use-cases), but we will be focusing on using them as a **receipt for IRL goods** and a **coupon** for discounts. We will create two seperate cNFT collections with similar structures, however the cNFT metadata itself will be different for both. Let's dive in. ## ๐Ÿ‘จโ€๐ŸŽจ Creating a compressed NFT Collection Instead of pasting the whole script here, I've put it into a [gist here](https://gist.github.com/maweiche/16d390c8c68a157f8e3f4bb0bf262120). So copy that, create a new file in your `/scripts` folder called `createReceiptCollection.ts`, paste it in there, then look over it. Want to give a huge shoutout to [Nick Frosty](https://twitter.com/nickfrosty) here. About 99% of this script is 99% and a all of the cNFT functions we will be using came from his [Solana Developers](https://github.com/solana-developers/compressed-nfts) repo. So give him a follow!! Because of Nick's great work explaining the code in comments I won't cover it all, but I do want to highlight some portions. #### ๐ŸŒณ Defining your Tree Size On `ln 95` we define our `maxDepthSizePair`, what this is doing is creating the size of memory allocation we need. Above each `maxDepth`/`maxBufferSize` combo is a `max=# of nodes`. The `nodes` here represent the **Max amount of cNFTs**. So for our script we will using a `maxDepth: 14` and `maxBufferSize: 64` to allow us to mint up to 16,384 cNFTs on this Merkle Tree. ![Defining your tree size](https://hackmd.io/_uploads/rkeaNoeAh.png) If you want to mess with seeing what different size trees would cost, then check out [Compressed.App](https://compressed.app/)(also made by Nick Frosty) where you can get the estimated price on creating a cNFT collection. ![Get minimum rent for merkle tree](https://hackmd.io/_uploads/rkmw8sxAh.png) After that, we create the tree and save the `treeAddress` and `treeAuthority` PublicKey's to a `/local-keys` folder, but more on that later. In `createCollectionNftURI()` there is a `CONFIG` part where we are defining the metadata of our Collection cNFT, this is what all of our Receipt cNFTs will belong to. Customize it and make it yours, also change the `receiptImage.png` in the `uploads/assets` folder to something unique and creative ๐Ÿ˜€. ![Collection Config](https://hackmd.io/_uploads/HJUboolA2.png) After we have our collection ready, we will `uploadNFTMetadataURI` and mint 2 cNFT's to verify it works! This `uploadNFTMetadataURI` will be similar to what we use when issuing receipts. Mess with the attributes here and come up with your own structure, or just use the one I provided. ![Receipt cNFT Config](https://hackmd.io/_uploads/HJtf3jxCh.png) In the last line of the `mintCompressedNFT` function is where we instruct where the cNFT should be sent after creation, this is how we can "airdrop" it to our buyers. ![Mint Compressed NFT](https://hackmd.io/_uploads/SkjxaixCn.png) Now before we run the script, let's fix a value in our `.env`. In your terminal run `solana config get`, this should return: ``` Config File: /Users/steve/.config/solana/cli/config.yml RPC URL: https://api.devnet.solana.com WebSocket URL: wss://api.devnet.solana.com/ (computed) Keypair Path: /Users/steve/.config/solana/id.json Commitment: confirmed ``` If you don't have the Solana Tool Suite installed, you can do so by following [these directions](https://docs.solana.com/cli/install-solana-cli-tools). Once you have it installed, run `solana airdrop 2` to get some Devnet Sol then use `solana config get` to get your `Keypair Path` Once you have your `Keypair Path` paste it directly into your `LOCAL_PAYER_JSON_ABSPATH` ``` .env LOCAL_PAYER_JSON_ABSPATH=/Users/steve/.config/solana/id.json ``` Well done. Now let's run the script with `yarn demo ./scripts/createReceiptCollection.ts` and watch your terminal go to work! If in the end you see this with no errors, then it worked! ![Result](https://hackmd.io/_uploads/HJn7yneCn.png) Take the time to go through the `console.log` messages in the terminal to see more info about the collection and check out the transactions where the cNFT was minted. Pretty frickin' cool if you ask me. Now before we move on, you should now have a folder called `./local_keys` with two files, one named `keys.json` let's rename that to `receipt_keys.json` ![local keys folder](https://hackmd.io/_uploads/rkzle2gAh.png) Reason being, we are going to use a similar script to create the Coupon Collection and I don't want you to get your tree keys confused! # Lesson 5 - Payment Confirmation and cNFT Receipt **CONGRATS!** You are know one of a few hundred? maybe thousand? people who know how to create 1 billion cNFTs **FOR CHEAP.** Will you create the next [Drip.Haus](https://drip.haus/) or [Dialect](https://www.dialect.to/)?? If so, don't forget who taught you everything you know plz ๐Ÿฅน. Until then, let's recap where we are: our payments are going through and we now have a cNFT receipt collection, that's awesome! The current flow of things is this: 1. Add Items to cart and generate the QR Code 2. Buyer scans the QR Code and Payment goes through But nothing happens after that. Let's set up a confirmation function on our Front-End so we can see the Payment has went through with out having to look on our "buyers" phone. Then after confirmation we will have our app mint and airdrop a receipt. What we are building next is step 3 & 4: 3. Confirm payment on the front-end 4. Issue a receipt as a compressed NFT ## ๐Ÿงพ Payment Confirmation Let's head back to our `src/page.tsx` First we will need create a Solana connection and two new states up top and before our `return()` we will insert a `useEffect()`. This is where we will perform our transaction confirmation. ``` /src/page.tsx ... const solanaConnection = new Connection(process.env.NEXT_PUBLIC_RPC_URL!, 'confirmed'); const mostRecentNotifiedTransaction = useRef<string | undefined>(undefined); const [paymentConfirmation, setPaymentConfirmation] = useState<any>(undefined) ... useEffect(() => { const interval = setInterval(async () => { try { // Check if there is any transaction for the reference const signatureInfo = await findReference(solanaConnection, reference, { until: mostRecentNotifiedTransaction.current }); console.log('Transaction confirmed', signatureInfo); mostRecentNotifiedTransaction.current = signatureInfo.signature; // get the parsed transaction info from the store wallet address const parsed_transaction = await solanaConnection.getParsedTransactions([mostRecentNotifiedTransaction.current!], 'confirmed'); // parse parsed_transaction[0]?.transaction?.message.accountKeys for where signer: true and get the public key const signer_of_transaction = parsed_transaction[0]?.transaction?.message.accountKeys.filter((account: any) => account.signer)[0].pubkey.toBase58(); // get the transfered amount from the parsed transaction // @ts-ignore const transfered_amount = parsed_transaction[0]?.transaction?.message.instructions.filter((program: any) => program.program === 'spl-token')[0].parsed.info.tokenAmount.uiAmount; console.log('parsed transaction', parsed_transaction) console.log('transfered amount', transfered_amount) const confirmation = { signer: signer_of_transaction, amount: transfered_amount, reference: reference.toBase58(), } setPaymentConfirmation(confirmation); } catch (e) { if (e instanceof FindReferenceError) { // No transaction found yet, ignore this error return; } console.error('Unknown error', e) } }, 500) return () => { clearInterval(interval) } }, [solanaConnection, reference]) ...rest of code... ``` To break it down, when a `solanaConnection` and `reference` are established then every 1.5 seconds we will check for a `signature`, thus confirming the transaction. Another option here would be to use a WebSocket Solana RPC and listen for any account changes on the USDC Shop Account, depending on your use case you can switch it up. Once we have that confirmation, we parse the transaction for the `signer` and the token amount of that transaction. The reason we are grabbing the amount after confirmation is to account for any discounts applied when creating the receipt next. Once we have that `confirmation` we assign it to our `paymentConfirmation` state. Now when you scan the QR Code and pay, you should see this in your browser console. ![Browser Console](https://hackmd.io/_uploads/ryG5zk-Rh.png) **Nice!** Let's keep rolling. Let's now create a `useEffect()` for our `paymentConfirmation`. What we want this `useEffect()` to do is issue a receipt everytime a `paymentConfirmation` and then reset our app for a new transaction. Above your previous `useEffect()` let's insert this new one: ``` /src/app/page.tsx useEffect(() => { if(!paymentConfirmation) return; console.log('payment confirmed, minting receipt') handleReceiptMint(paymentConfirmation?.signer!); if(qrRef.current?.firstChild){ qrRef.current?.removeChild(qrRef.current?.firstChild!); } setQrActive(false); setCart([]); generateNewReference(); notify(`Transaction verified, you spent ${paymentConfirmation?.amount} USDC. ${ parseFloat(paymentConfirmation?.amount) >= parseFloat('10.00') ? `You spent ${paymentConfirmation?.amount} USDC and will receive a coupon!` : `Spend ${parseFloat('10.00') - parseFloat(paymentConfirmation?.amount)} more USDC to receive a coupon next time!` }`); }, [paymentConfirmation]); ``` Everything there is self-explanatory, clear the QR, reset the cart, generate a new ref and notify the user. Now let's go completethe `handleReceiptMint` function so we can get rid of this error. ``` /src/app/page.tsx const handleReceiptMint = async (buyer: string) => { const cart_as_string = cart.map((item) => `${item.id} x ${item.quantity}`).join(', '); if(!buyer) return; const CONFIG = { buyerPublicKey: buyer, products: cart_as_string, amount: paymentConfirmation?.amount, reference: paymentConfirmation?.reference, }; const res = await fetch( '/api/receipt', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(CONFIG), } ); const response_status = res.status; if(response_status === 200) { console.log('coupon minted'); } return ; }; ``` So what we are doing here is similar to our transaction creation, batching some info and sending it off to another api endpoint. This is all of the information we are including on our receipt, you could switch it to however you see fit. Now let's create that endpoint. ## ๐Ÿ“ Minting Receipt In your `/pages/api` folder let's create a new file called `receipt.tsx'. ``` /pages/api/receipt.tsx import { NextApiRequest, NextApiResponse } from 'next'; import { PublicKey, Keypair, Connection, Transaction } from '@solana/web3.js'; import { MetadataArgs, TokenProgramVersion, TokenStandard, } from "@metaplex-foundation/mpl-bubblegum"; // import custom helpers to mint compressed NFTs import { mintCompressedNFT } from "../../utils/compression"; import { Metaplex, keypairIdentity, bundlrStorage, toMetaplexFile, toBigNumber, CreateCandyMachineInput, DefaultCandyGuardSettings, CandyMachineItem, toDateTime, sol, TransactionBuilder, CreateCandyMachineBuilderContext } from "@metaplex-foundation/js"; import { numberFormatter } from "@/utils/helpers"; // load the env variables and store the cluster RPC url import dotenv from "dotenv"; dotenv.config(); export default async function handler(req: NextApiRequest, res: NextApiResponse) { //Handle POST requests to issue a coupon if (req.method === 'POST') { try{ const CLUSTER_URL = process.env.NEXT_PUBLIC_RPC_URL!; const connection = new Connection(CLUSTER_URL, 'confirmed'); const { buyerPublicKey, products, amount, reference } = req.body; console.log('buyerPublicKey',buyerPublicKey); console.log('products',products); console.log('amount',amount); console.log('reference',reference); // USE THIS IN PRODUCTION const keyfileBytes = await JSON.parse(process.env.NEXT_PUBLIC_DEMO_KEY!); // parse the loaded secretKey into a valid keypair const payer = Keypair.fromSecretKey(Uint8Array.from(keyfileBytes!)); console.log('creator wallet address', payer.publicKey.toBase58()); const receiptTreeAddress = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_TREE_ADDRESS!); const receiptTreeAuthority = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_TREE_AUTHORITY!); const receiptCollectionMint = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_COLLECTION_MINT!); const receiptCollectionMetadataAccount = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_COLLECTION_METADATA_ACCOUNT!); const receiptCollectionMasterEditionAccount = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_COLLECTION_MASTER_EDITION_ACCOUNT!); // create the metadata for the compressed NFT************************************************************************ async function createCollectionNftURI() { // airdrop SOL to the wallet // await SOLANA_CONNECTION.requestAirdrop(WALLET.publicKey, 1000000000); // wait for the balance to update // await new Promise((resolve) => setTimeout(resolve, 1000)); // get the wallet's balance const METAPLEX = Metaplex.make(connection) .use(keypairIdentity(payer)) .use(bundlrStorage({ address: 'https://devnet.bundlr.network', providerUrl: CLUSTER_URL, timeout: 60000, })); const balance = await connection.getBalance(payer.publicKey); console.log(`Wallet Balance: ${numberFormatter(balance)} SOL`); // UPLOAD YOUR OWN METADATA URI const CONFIG = { uploadPath: 'uploads/assets/', imgFileName: 'image.png', imgType: 'image/png', imgName: 'swissDAO Receipt Token', description: 'swissDAO Receipt for your purchase.', attributes: [ {trait_type: 'Date', value: new Date().toISOString().slice(0, 10)}, {trait_type: 'Products', value: products}, {trait_type: 'Amount', value: amount}, {trait_type: 'Reference', value: reference}, ], sellerFeeBasisPoints: 500,//500 bp = 5% symbol: 'swissDAO', creators: [ {address: payer.publicKey, share: 100}, // store as creator {address: new PublicKey(buyerPublicKey), share: 0} // buyerPublicKey as reference to see who made original purchase ] }; // REPLACE WITH THE IMAGE YOU WANT ON YOUR NFT const receiptImgUri = 'https://arweave.net/Aw4FYdlYsv_nLCZILfaWvigEi5QcCSixW3BSuQxXAOY' async function uploadMetadata(imgUri: string, imgType: string, nftName: string, description: string, attributes: {trait_type: string, value: string}[]) { console.log(`Step 2 - Uploading Metadata`); const { uri } = await METAPLEX .nfts() .uploadMetadata({ name: CONFIG.imgName, description: CONFIG.description, image: receiptImgUri, sellerFeeBasisPoints: CONFIG.sellerFeeBasisPoints, symbol: CONFIG.symbol, attributes: CONFIG.attributes, properties: { files: [ { uri: receiptImgUri, type: CONFIG.imgType, }, ], creators: [ { address: payer.publicKey.toBase58(), share: 100, }, ], }, }); console.log(` Metadata URI:`,uri); return uri; } const metadataUri = await uploadMetadata( receiptImgUri, CONFIG.imgType, CONFIG.imgName, CONFIG.description, CONFIG.attributes ); console.log('metadataUri',metadataUri); return metadataUri; } const metadataUri = await createCollectionNftURI(); const compressedNFTMetadata: MetadataArgs = { name: "swissDAO Receipt", symbol: "swissDAO", // specific json metadata for each NFT uri: metadataUri, sellerFeeBasisPoints: 100, creators: [ { address: payer.publicKey, verified: false, share: 100, }, { address: new PublicKey(buyerPublicKey), verified: false, share: 0, }, ], editionNonce: 0, uses: null, collection: null, primarySaleHappened: false, isMutable: true, // values taken from the Bubblegum package tokenProgramVersion: TokenProgramVersion.Original, tokenStandard: TokenStandard.NonFungible, }; // fully mint a single compressed NFT to the payer console.log(`Minting a single receipt compressed NFT to ${buyerPublicKey}...`); await mintCompressedNFT( connection, payer, receiptTreeAddress, receiptCollectionMint, receiptCollectionMetadataAccount, receiptCollectionMasterEditionAccount, compressedNFTMetadata, // mint to this specific wallet (in this case, the tree owner aka `payer`) new PublicKey(buyerPublicKey), ); console.log("\nSuccessfully minted the compressed NFT!"); // return status: success and the txSignature return res.status(200).json( { status: 'success' } ) } catch (error) { console.log(error); return res.status(500).json({ status: 'error' }) } } } ``` This should look pretty familiar to the cNFT script we ran in the previous lesson. The biggest thing we are doing here is defining our cNFT Metadata's attributes with the transaction info. ``` ... const { buyerPublicKey, products, amount, reference } = req.body; ... // UPLOAD YOUR OWN METADATA URI const CONFIG = { uploadPath: 'uploads/assets/', imgFileName: 'image.png', imgType: 'image/png', imgName: 'swissDAO Receipt Token', description: 'swissDAO Receipt for your purchase.', attributes: [ {trait_type: 'Date', value: new Date().toISOString().slice(0, 10)}, {trait_type: 'Products', value: products}, {trait_type: 'Amount', value: amount}, {trait_type: 'Reference', value: reference}, ], sellerFeeBasisPoints: 500,//500 bp = 5% symbol: 'swissDAO', creators: [ {address: payer.publicKey, share: 100}, // store as creator {address: new PublicKey(buyerPublicKey), share: 0} // buyerPublicKey as reference to see who made original purchase ] }; ``` Another thing to note is this portion: ``` /pages/api/receipt.tsx const receiptTreeAddress = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_TREE_ADDRESS!); const receiptTreeAuthority = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_TREE_AUTHORITY!); const receiptCollectionMint = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_COLLECTION_MINT!); const receiptCollectionMetadataAccount = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_COLLECTION_METADATA_ACCOUNT!); const receiptCollectionMasterEditionAccount = new PublicKey(process.env.NEXT_PUBLIC_RECEIPT_COLLECTION_MASTER_EDITION_ACCOUNT!); ``` Make sure you update these values in your `.env` with the values from the the `/.local_keys/receipt_keys.json` so your cNFTs are coming from the correct tree that you are the authority of. Now let's restart our app/ngrok and see where we are at after paying for a transaction. Don't forget that our receipt mint/airdrop is happening on our backend, so check the terminal for it's `console.log` messages. If all goes well you should see this. ![Successful mint](https://hackmd.io/_uploads/ryImRJZA2.png) And if you check your 'Collectibles' page in your Mobile Wallet you should see your receipt! Here is how my detailed look appears with the transaction details: ![Receipt cNFT](https://hackmd.io/_uploads/S15T0kbRn.jpg) Nice job! You are now a Solana Pay and cNFT Wizard. Now we could probably end the lesson here, but we really want to reward our buyers so why not also issue a coupon cNFT for anyone who spends 10 USDC? Let's do it. But first, here's the [updated code](https://github.com/swissDAO/solana-pay-cnfts/tree/confirm/cNFT) if you want to prepare. # Lesson 6 - Issuing and Checking for Coupon cNFTs We are going to power through this a little since the process will be the same as the Receipt cNFT so if something doesn't work just refer to the previous section. Let's first create another file in our `/scripts` folder, we'll name it `createCouponCollection`. Here is the [gist](https://gist.github.com/maweiche/173fd4d9f67880953abbc0b7e29dd503) you can copy. It's almost the exact same as our Receipt Collection script, we just provide a different name to our collection and a different structure to our test cNFT. Once you customize it, run the script with: `yarn demo ./scripts/createCouponCollection.ts` and again rename the `keys.json` in `/.local_keys` to `coupon_keys.json` just so we don't get them confused. And don't forget to update these values in the `.env` ``` .env NEXT_PUBLIC_COUPON_TREE_ADDRESS=HyL6dzYkBuoSjYL1WLQtH1pVKw1437cPiaHDdFWCnoYn NEXT_PUBLIC_COUPON_TREE_AUTHORITY=6y7q5vtUWEdiYRc2zt5GJv9mLNHNzyfKr3niHTQTefef NEXT_PUBLIC_COUPON_COLLECTION_MINT=ADu43hTmCyFuEBLAqxjkq3ac5edfHmNmn3Jf5sqbV2Pf NEXT_PUBLIC_COUPON_COLLECTION_METADATA_ACCOUNT=BpYFwjF2wBxQK7T41zQTN5Pf4ijKLTnw5YB4xszQW5kx NEXT_PUBLIC_COUPON_COLLECTION_MASTER_EDITION_ACCOUNT=FDCkkHdq4RVUAqzTrWzCY94f1RV2wsEEwUXJM57nr6CM ``` Niiiice, let's keep it moving here. Now, switch back to your `/src/app/page.tsx` and let's throw in some conditionals to trigger our `handleCouponMint()` Let's update our`useEffect()` that operates off of our `paymentConfirmation` state. ``` /srce/app/page.tsx useEffect(() => { if(!paymentConfirmation) return; console.log('payment confirmed, minting receipt') handleReceiptMint(paymentConfirmation?.signer!); if(parseFloat(paymentConfirmation?.amount) >= parseFloat('10.00')){ console.log('minting coupon') handleCouponMint(paymentConfirmation?.signer!) if(qrRef.current?.firstChild){ qrRef.current?.removeChild(qrRef.current?.firstChild!); } setQrActive(false); setCart([]); generateNewReference(); notify(`Transaction verified, you spent ${paymentConfirmation?.amount} USDC. ${ parseFloat(paymentConfirmation?.amount) >= parseFloat('10.00') ? `You spent ${paymentConfirmation?.amount} USDC and will receive a coupon!` : `Spend ${parseFloat('10.00') - parseFloat(paymentConfirmation?.amount)} more USDC to receive a coupon next time!` }`); } else { if(qrRef.current?.firstChild){ qrRef.current?.removeChild(qrRef.current?.firstChild!); } setQrActive(false); setCart([]); generateNewReference(); notify(`Transaction verified, you spent ${paymentConfirmation?.amount} USDC. ${ parseFloat(paymentConfirmation?.amount) >= parseFloat('10.00') ? `You spent ${paymentConfirmation?.amount} USDC and will receive a coupon!` : `Spend ${parseFloat('10.00') - parseFloat(paymentConfirmation?.amount)} more USDC to receive a coupon next time!` }`); } }, [paymentConfirmation]); ``` The important line to note here is our conditional statement `if(parseFloat(paymentConfirmation?.amount) >= parseFloat('10.00'))`. This is where you can update the trigger amount for issuing a coupon. Next, let's finish the last function needed here `handleCouponMint()`, this will be similar to our `handleReceiptMint()` function. We're just pinging our back-end with the buyer's PublicKey so we know where to airdrop the coupon. ``` /src/app/page.tsx const handleCouponMint = async (buyer: string) => { if(!buyer) return; const CONFIG = { buyerPublicKey: buyer, reference: paymentConfirmation?.reference, }; const res = await fetch( '/api/coupon', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(CONFIG), } ); const response_status = res.status; if(response_status === 200) { console.log('receipt minted'); } return }; ``` Pretty much the same, right? Let's set up that end point now and we'll be ready for the final touches. Here is the [gist](https://gist.github.com/maweiche/173fd4d9f67880953abbc0b7e29dd503) to copy. As before, it's similar to our other cNFT script, just switching up a few structures. Now when we run `yarn dev` and `ngrok http 3000`, and scan/pay via QR code, our terminal console should show two successfull cNFT Mint/Transfer transactions! If you see the success messages, check your mobile wallet and you should now see the reward token! ![Reward Token](https://hackmd.io/_uploads/HkBp7WZR3.jpg) ## ๐Ÿ“– Parsing wallet for cNFT for discount Now that we are issuing coupons for purchases, let's implement a function to check for a coupon cNFT and if it is present, provide a 50% discount and transfer the coupon back to the shop. Let's head back to our `pay` api endpoint to insert a `checkForCoupon` function above our `get` and `post` function. ``` /pages/api/pay.tsx const check_for_coupons = async (buyerPublicKey: PublicKey) => { const YOUR_WALLET_ADDRESS = buyerPublicKey.toBase58(); await solanaConnection .getAssetsByOwner({ ownerAddress: YOUR_WALLET_ADDRESS, sortBy: { sortBy: "recent_action", sortDirection: "asc", }, }) .then(res => { console.log("Total assets returned:", res.total); // search for NFTs from the same tree res.items ?.filter(asset => asset.compression.tree === treeAddress.toBase58()) .map(asset => { // display some info about the asset console.log("assetId:", asset.id); console.log("ownership:", asset.ownership); console.log("compression:", asset.compression); // verify the asset is owned by the current user if (asset.compression.tree === treeAddress.toBase58() && asset.ownership.owner === buyerPublicKey.toBase58()){ all_asset_ids.push(new PublicKey(asset.id)); } }); }); } ``` You should see a few errors, so let's fix them. You'll probably notice on `getAssetsByOwner` does not exist on `Connection` and that's because it doesn't lol. We're still in the EARLY stages of compressed NFT's and a lot of the standard Solana tools are still working on implemnting tools for them. But have no fear! We are going to use a Wrapper class to add additional methods on top of the standard Connection, specifically adding the RPC methods used by the DAS for state compression and compressed NFTs. So where ever you have defined your `solanaConnection` let's change that to a `WrapperConnection()`. It should look like this: `const solanaConnection = new WrapperConnection(process.env.NEXT_PUBLIC_RPC_URL!, 'confirmed');` And right below that let's import your Coupon Tree Address from the `.env` as the `treeAddress`. `const treeAddress = new PublicKey(process.env.NEXT_PUBLIC_COUPON_TREE_ADDRESS!);` Cool, now the only error that should be left is for `all_assets_ids` and that is where we are going to push any cNFTs that we find associated with the `buyersPublicKey` and the `Coupon Tree`. So up top let's define `all_asset_ids` , it should look like this: ``` let all_asset_ids: PublicKey[] = []; ``` Cool, now there should be no more errors left in that function. So now we are parsing the wallet for any coupons, but if one is identified how do we add a discount AND transfer it back? Let's look. Inside of your `post` function and below your defined `buyerPublicKey` let's `checkForCoupons` and set a starting discount of 1. ``` await check_for_coupons(buyerPublicKey); let discount = 1; if (all_asset_ids.length! >= 1){ discount = 0.5; } ``` What we are doing here is setting a discount to multiply our `amount` against during the `tranferInstructions`. So, if 1 or more coupons are present in the wallet, we provide a 50% discount. Now let's update our `transferInstructions` with the discount, it should look like this: `((amountBigNumber.toNumber() * (10 ** usdcMint.decimals)) * discount)` Awesome, so we have the discount being applied, but now we want to transfer back the cNFT too, so what we need to do is create `transferInstructions` for that and add them to our `transaction`. So let's define another async function that takes in an `assetIdUserAddress` and the `buyerPublicKey`. ``` const createCnftTransferInstruction = async (assetIdUserAddress: PublicKey, buyerPublicKey: PublicKey) => {} ``` We'll also need the Coupon Tree Authority for this so let's define that up top below our tree address: ``` const treeAuthority = new PublicKey(process.env.NEXT_PUBLIC_COUPON_TREE_AUTHORITY!); ``` Now let's add the code to our function. **I want to note that this is again pulled straight from [Solana's Developer Repo](https://github.com/solana-developers/compressed-nfts). There is no special way of learning these things other than looking at code and figuring out how to be creative with it.** ``` const createCnftTransferInstruction = async (assetIdUserAddress: PublicKey, buyerPublicKey: PublicKey) => { console.log("Creating transfer instruction for asset ID:", assetIdUserAddress.toBase58()); console.log("User Asset ID:", assetIdUserAddress.toBase58()); ////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// /** * Get the asset details from the RPC */ printConsoleSeparator("Get the asset details from the RPC"); const asset = await solanaConnection.getAsset(assetIdUserAddress); console.log(asset); console.log("Is this a compressed NFT?", asset.compression.compressed); console.log("Current owner:", asset.ownership.owner); console.log("Current delegate:", asset.ownership.delegate); // ensure the current asset is actually a compressed NFT if (!asset.compression.compressed) return console.error(`The asset ${asset.id} is NOT a compressed NFT!`); if (asset.ownership.owner !== buyerPublicKey.toBase58()) return console.error(`The asset ${asset.id} is NOT owned by the buyer!`); /** * Get the asset's proof from the RPC */ printConsoleSeparator("Get the asset proof from the RPC"); const assetProof = await solanaConnection.getAssetProof(assetIdUserAddress); console.log(assetProof); /** * Get the tree's current on-chain account data */ // parse the tree's address from the `asset` // get the tree's account info from the cluster const treeAccount = await ConcurrentMerkleTreeAccount.fromAccountAddress(solanaConnection, treeAddress); /** * Perform client side verification of the proof that was provided by the RPC * --- * NOTE: This is not required to be performed, but may aid in catching errors * due to your RPC providing stale or incorrect data (often due to caching issues) * The actual proof validation is performed on-chain. */ printConsoleSeparator("Validate the RPC provided asset proof on the client side:"); const merkleTreeProof: MerkleTreeProof = { leafIndex: asset.compression.leaf_id, leaf: new PublicKey(assetProof.leaf).toBuffer(), root: new PublicKey(assetProof.root).toBuffer(), proof: assetProof.proof.map((node: string) => new PublicKey(node).toBuffer()), }; const currentRoot = treeAccount.getCurrentRoot(); const rpcRoot = new PublicKey(assetProof.root).toBuffer(); console.log( "Is RPC provided proof/root valid:", MerkleTree.verify(rpcRoot, merkleTreeProof, false), ); console.log( "Does the current on-chain root match RPC provided root:", new PublicKey(currentRoot).toBase58() === new PublicKey(rpcRoot).toBase58(), ); /** * INFO: * The current on-chain root value does NOT have to match this RPC provided * root in order to perform the transfer. This is due to the on-chain * "changelog" (set via the tree's `maxBufferSize` at creation) keeping track * of valid roots and proofs. Thus allowing for the "concurrent" nature of * these special "concurrent merkle trees". */ ////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////// /** * Build the transfer instruction to transfer ownership of the compressed NFT * --- * By "transferring" ownership of a compressed NFT, the `leafOwner` * value is updated to the new owner. * --- * NOTE: This will also remove the `leafDelegate`. If a new delegate is * desired, then another instruction needs to be built (using the * `createDelegateInstruction`) and added into the transaction. */ // set the new owner of the compressed NFT -- TRANSFER BACK TO STORE const newLeafOwner = new PublicKey(process.env.NEXT_PUBLIC_STORE_WALLET_ADDRESS!); // set the current leafOwner (aka the current owner of the NFT) const leafOwner = new PublicKey(asset.ownership.owner); // set the current leafDelegate const leafDelegate = !!asset.ownership?.delegate ? new PublicKey(asset.ownership.delegate) : leafOwner; /** * NOTE: When there is NOT a current `leafDelegate`, * the current leafOwner` address should be used */ const canopyDepth = treeAccount.getCanopyDepth(); // parse the list of proof addresses into a valid AccountMeta[] const proofPath: AccountMeta[] = assetProof.proof .map((node: string) => ({ pubkey: new PublicKey(node), isSigner: false, isWritable: false, })) .slice(0, assetProof.proof.length - (!!canopyDepth ? canopyDepth : 0)); // console.log(proofPath); // create the NFT transfer instruction (via the Bubblegum package) const transferIx = createTransferInstruction( { merkleTree: treeAddress, treeAuthority, leafOwner, leafDelegate, newLeafOwner, logWrapper: SPL_NOOP_PROGRAM_ID, compressionProgram: SPL_ACCOUNT_COMPRESSION_PROGRAM_ID, anchorRemainingAccounts: proofPath, }, { root: [...new PublicKey(assetProof.root.trim()).toBytes()], dataHash: [...new PublicKey(asset.compression.data_hash.trim()).toBytes()], creatorHash: [...new PublicKey(asset.compression.creator_hash.trim()).toBytes()], nonce: asset.compression.leaf_id, index: asset.compression.leaf_id, }, BUBBLEGUM_PROGRAM_ID, ); return transferIx; } ``` Now that's a lot of code, but I encourage you to look through it and try to understand what's going on. Instead of me explaining it, I left a lot of the Solana team's comments in there explaining what & why things are happening. **TL;DR** We're confirming it's a cNFT, checking ownership, and then creating a transfer insctruction with `Bubblegum` (the cNFT version of Metaplex's CandyMachine). Now that we have that set up let's tell our code when to run it and what do with the returned `transferIx`. Back to our `post` function. Below where we define our `transaction` let's add this: ``` // If total_cNFts! >=1, then we need to add the instruction to send the cNFT's back to shop owner if (all_asset_ids.length! >=1){ try{ const transferInstruction = await createCnftTransferInstruction(all_asset_ids[0], buyerPublicKey); printConsoleSeparator(`Sending the transfer transaction for asset_id: ${all_asset_ids[0].toBase58()}...`); if(transferInstruction) { transaction.add(transferInstruction!); } } catch (error) { console.log(error); return res.status(500).json({ error: 'error creating transaction', }) } } ``` In our use case we are taking the 1st cNFT returned in the account, but you can get creative with it and maybe wait until they acquire three coupons and then apply a discount. Or maybe instead of transferring them back to the store, you want to burn the cNFT (effectively deleting it from the blcokchain). Regardless, in the end we are adding it to the transaction with `transaction.add(transferInstruction!);` and we are ready to give it a try! Spin up the code and lets see what we have!! ## ๐Ÿซก Recap At this point you have: * Created a digital storefront * Created the ability to accept payments via Solana * Created AND Minted your own compressed NFT collection * Created UTILITY for those cNFTS. Nice job!! You are now one of the small group of Solana Developers capable of these things. Your abilities are endless!! Grab some more water real quick and then let's get this build off localhost! # Lesson 7 - Final touches and Deployment Now a few things left we could do are center our "Generate QR" button and display the cart total, maybe even display the items in the cart on the side? Make your build unique and something you can be proud of! If you are having any issues or are stuck, compare your code with our final build [here](https://github.com/swissDAO/solana-pay-cnfts/tree/final). Now let's deploy this sucker so we can get paid!! ## Deployment My favorite way to deploy my builds is with [Vercel](https://vercel.com/) it works great with Next.JS. I'd advise signing up via your GitHub to streamline the process, from there you can just import your repo! The only change you will need to make is to your Environment Variables in the settings page of your deployment. Here you will just copy and paste your `.env`. You will also need to change `NEXT_PUBLIC_DEMO_KEY` to the actual contents of your Keypair file and swap out any references to the `LOCAL_PAYER_JSON_ABSPATH`. ![Vercel Settings](https://hackmd.io/_uploads/ByegH--Ch.png) After updating your Environment Variables **you will need to redeploy** for them to take effect. ![Deployed URL](https://hackmd.io/_uploads/BJo2_-UR2.png) After all is done, check out your dedicated URL from Vercel. You've made your very own Digital Payment Processor!!! Now, take a break, then get to selling your products you Solana Wizard! ## Credits **Big shout out** to these guys and all of the info they put out there. This build would not have been possible with out them. Give them a follow on Twitter! [**Callum McIntyre**](https://twitter.com/callum_codes) - Solana Pay Build on Pointer.GG (https://www.pointer.gg/tutorials/solana-pay-irl-payments/) [**Raza**](https://twitter.com/AlmostEfficient) - Solana Pay Build on buildspace.so (https://buildspace.so/builds/solana-pay) [**Nick Frosty**](https://twitter.com/nickfrosty) - Compressed NFT's on Solana repo (https://github.com/solana-developers/compressed-nfts)