diff --git a/content/courses/mobile/solana-mobile-dapps-with-expo.md b/content/courses/mobile/solana-mobile-dapps-with-expo.md index bf876a873..6fd5390ef 100644 --- a/content/courses/mobile/solana-mobile-dapps-with-expo.md +++ b/content/courses/mobile/solana-mobile-dapps-with-expo.md @@ -71,14 +71,20 @@ speakers. The libraries are intuitive and the documentation is phenomenal. #### How to create an Expo app To get started with Expo, you first need the prerequisite setup described in the -[Introduction to Solana Mobile lesson](/content/courses/mobile/intro-to-solana-mobile). +[Introduction to Solana Mobile lesson](/content/courses/mobile/intro-to-solana-mobile.md). After that, you'll want to sign up for an -[Expo Application Services (EAS) account](https://expo.dev/). +[Expo Application Services (EAS) account](https://expo.dev/signup). Once you have an EAS account, you can install the EAS CLI and log in: ```bash +# For npm users npm install --global eas-cli + +# For pnpm users +pnpm add --global eas-cli + +# After installation, log in with: eas login ``` @@ -203,49 +209,118 @@ For a Solana + Expo app, you'll need the following: from [@solana/web3.js](https://github.com/solana-labs/solana-web3.js) – such as `Transaction` and `Uint8Array`. - `@solana/web3.js`: Solana Web Library for interacting with the Solana network - through the [JSON RPC API](/docs/rpc/http/index.mdx). -- `react-native-get-random-values`: Secure random number generator polyfill - for `web3.js` underlying Crypto library on React Native. + through the [JSON RPC API](https://docs.solana.com/api/http). +- `expo-crypto`: Secure random number generator polyfill + for `web3.js` underlying Crypto library on Expo. through + the [JSON RPC API](/docs/rpc/http/index.mdx). - `buffer`: Buffer polyfill needed for `web3.js` on React Native. +```bash +# Using npm +npm install @solana-mobile/mobile-wallet-adapter-protocol \ + @solana-mobile/mobile-wallet-adapter-protocol-web3js \ + @solana/web3.js \ + expo-crypto \ + buffer + +# Using pnpm +pnpm add @solana-mobile/mobile-wallet-adapter-protocol \ + @solana-mobile/mobile-wallet-adapter-protocol-web3js \ + @solana/web3.js \ + expo-crypto \ + buffer +``` + #### Metaplex Polyfills If you want to use the Metaplex SDK, you'll need to add the Metaplex library plus a few additional polyfills: -- `@metaplex-foundation/js@0.19.4` - Metaplex Library +- `@metaplex-foundation/mpl-token-metadata@3.2.1` - Metaplex Token Metadata + Program Library +- `@metaplex-foundation/umi@0.9.2` - Unified Metaplex Interface (UMI) Core + Library +- `@metaplex-foundation/umi-bundle-defaults@0.9.2` - Default UMI Plugins Bundle +- `@metaplex-foundation/umi-serializers@0.9.0` - UMI Serializers for Data + Encoding/Decoding +- `@metaplex-foundation/umi-signer-wallet-adapters@0.9.2` - UMI Wallet Adapter + Signers + - Several more polyfills - - `assert` - - `util` - - `crypto-browserify` - - `stream-browserify` - - `readable-stream` + + - `text-encoding` - `browserify-zlib` - - `path-browserify` - `react-native-url-polyfill` +```bash + # Using npm + npm install @metaplex-foundation/mpl-token-metadata@3.2.1 \ + @metaplex-foundation/umi@0.9.2 \ + @metaplex-foundation/umi-bundle-defaults@0.9.2 \ + @metaplex-foundation/umi-serializers@0.9.0 \ + @metaplex-foundation/umi-signer-wallet-adapters@0.9.2 \ + text-encoding \ + browserify-zlib \ + react-native-url-polyfill \ + # Using pnpm + pnpm add @metaplex-foundation/mpl-token-metadata@3.2.1 \ + @metaplex-foundation/umi@0.9.2 \ + @metaplex-foundation/umi-bundle-defaults@0.9.2 \ + @metaplex-foundation/umi-serializers@0.9.0 \ + @metaplex-foundation/umi-signer-wallet-adapters@0.9.2 \ + text-encoding \ + browserify-zlib \ + react-native-url-polyfill \ +``` + All of the libraries that the above polyfills are meant to replace are utilized by the Metaplex library in the background. It's unlikely you'll be importing any of them into your code directly. Because of this, you'll need to register the -polyfills using a `metro.config.js` file. This will ensure that Metaplex uses -the polyfills instead of the usual Node.js libraries that aren't supported in -React Native. Below is an example `metro.config.js` file: +polyfills using a `metro.config.js` and `babel.config.js` file. This will ensure +that Metaplex uses the polyfills instead of the usual Node.js libraries that +aren't supported in React Native. Below is an example `metro.config.js` file: ```js +// Import the default Expo Metro config const { getDefaultConfig } = require("@expo/metro-config"); + +// Get the default Expo Metro configuration const defaultConfig = getDefaultConfig(__dirname); +// Customize the configuration to include your extra node modules defaultConfig.resolver.extraNodeModules = { - crypto: require.resolve("crypto-browserify"), - stream: require.resolve("readable-stream"), url: require.resolve("react-native-url-polyfill"), zlib: require.resolve("browserify-zlib"), - path: require.resolve("path-browserify"), + crypto: require.resolve("expo-crypto"), }; +// Export the modified configuration module.exports = defaultConfig; ``` +Below is an example `babel.config.js` file: + +```js +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: [ + [ + "module-resolver", + { + alias: { + buffer: "buffer", + "@metaplex-foundation/umi/serializers": + "@metaplex-foundation/umi-serializers", + }, + }, + ], + ], + }; +}; +``` + ### Putting it all together As with most new tools or frameworks, initial setup can be challenging. The good @@ -261,7 +336,7 @@ able to mint a single NFT snapshot of their lives daily, creating a permanent diary of sorts. To mint the NFTs we'll be using Metaplex's Javascript SDK along with -[nft.storage](https://nft.storage/) to store images and metadata. All of our +[pinata.cloud](https://pinata.cloud/) to store images and metadata. All of our onchain work will be on Devnet. The first half of this lab is cobbling together the needed components to make @@ -293,12 +368,18 @@ it to run. We use 5GB of ram on our side. To simplify the Expo process, you'll want an Expo Application Services (EAS) account. This will help you build and run the application. -First sign up for an [EAS account](https://expo.dev/). +First sign up for an [EAS account](https://expo.dev/signup). Then, install the EAS CLI and log in: ```bash +# For npm users npm install --global eas-cli + +# For pnpm users +pnpm add --global eas-cli + +# After installation, log in with: eas login ``` @@ -336,7 +417,8 @@ Copy and paste the following into the newly created `eas.json`: "build": { "development": { "developmentClient": true, - "distribution": "internal" + "distribution": "internal", + "env": { "ANDROID_SDK_ROOT": "/path/to/AndroidSDK" } }, "preview": { "distribution": "internal" @@ -349,7 +431,14 @@ Copy and paste the following into the newly created `eas.json`: } ``` -#### 4. Build and emulate +### **Important:** + +- Replace `"/path/to/AndroidSDK"` with the actual path to your Android SDK. +- To find the SDK path, you can navigate to **Android Studio** > **SDK + Manager** > **Android SDK Location**. Copy the path and replace it in the + `ANDROID_SDK_ROOT` field. + +#### 4. Build and Emulate Now let's build the project. You will choose `y` for every answer. This will take a while to complete. @@ -364,8 +453,54 @@ Locate this file in your file explorer and **_drag it_** into your emulator. The emulator should show a message that it is installing the new APK. When it finishes installing, you should see the APK as an app icon in the emulator. -The app that was installed is just a scaffold app from Expo. The last thing -you'll need to do is run the following command to run the development server: +#### Troubleshooting + +#### 1. Incorrect JDK Version or Missing Android SDK + +Follow the +[React Native CLI setup instructions](https://reactnative.dev/docs/environment-setup) +to ensure your local environment is properly configured for Android development. +You'll need: + +- **JDK Version 11**: Ensure that Java Development Kit (JDK) version 11 is + installed. +- **Android SDK**: Install and configure the Android SDK through the Android + Studio SDK Manager. +- **ANDROID_HOME Environment Variable**: Set up the `ANDROID_HOME` environment + variable to point to your Android SDK installation. + +#### 2. Missing Android NDK + +If you encounter errors related to a missing Android NDK, follow these steps to +install it: + +1. Open **Android Studio**. +2. Navigate to **File -> Project Structure -> SDK Location**. +3. Under "Android NDK Location," select **Download Android NDK**. + +This should resolve issues related to the missing Android NDK. + +![Android NDK Download](https://docs.solanamobile.com/assets/images/ndk-download-c7adebb1cb08c1d5e77d7c02aff3f167.png) + +#### **Optional: Create a Remote Development Build** + +If you prefer to create the development build remotely using Expo's EAS +services, you can skip the local build by using the following command: + +```bash +npx eas build --profile development --platform android +``` + +- This command will upload your project to Expo's servers and create the + development build in the cloud. +- Once the build is complete, you will receive a download link to the APK. You + can download the APK and install it on your emulator or device just like + before. + +The app that was installed is just a +[Custom Dev Build](https://docs.expo.dev/develop/development-builds/introduction/) +app from Expo. The last thing you'll need to do is run the following command to +run the development server: ```bash npx expo start --dev-client --android @@ -373,8 +508,8 @@ npx expo start --dev-client --android This should open and run the app in your Android emulator. -**_NOTE_** Every time you add in new dependencies, you'll have to build and -re-install the app. Anything visual or logic-based should be captured by the +**_NOTE_** Every time you add in new native dependencies, you'll have to build +and re-install the app. Anything visual or logic-based should be captured by the hot-reloader. ### 2. Configure your Expo app to work with Solana @@ -386,7 +521,7 @@ already have a Devnet-enabled wallet installed you can skip step 0. #### 0. Install a Devnet-enabled Solana wallet You'll need a wallet that supports Devnet to test with. In -[our Mobile Wallet Adapter lesson](/content/courses/mobile/mwa-deep-dive) we +[our Mobile Wallet Adapter lesson](/content/courses/mobile/mwa-deep-dive.md) we created one of these. Let's install it from the solution branch in a different directory from our app: @@ -416,15 +551,22 @@ all Solana mobile apps. This will include some polyfills that allow otherwise incompatible packages to work with React native: ```bash -npm install \ - @solana/web3.js \ - @solana-mobile/mobile-wallet-adapter-protocol-web3js \ - @solana-mobile/mobile-wallet-adapter-protocol \ - react-native-get-random-values \ - buffer +# Using npm +npm install @solana-mobile/mobile-wallet-adapter-protocol \ + @solana-mobile/mobile-wallet-adapter-protocol-web3js \ + @solana/web3.js \ + expo-crypto \ + buffer + +# Using pnpm +pnpm add @solana-mobile/mobile-wallet-adapter-protocol \ + @solana-mobile/mobile-wallet-adapter-protocol-web3js \ + @solana/web3.js \ + expo-crypto \ + buffer ``` -#### 3. Add Solana boilerplate providers +#### 2. Add Solana boilerplate providers Next, let's add some Solana boilerplate that can springboard you into most Solana-based apps. @@ -432,8 +574,8 @@ Solana-based apps. Create two new folders: `components` and `screens`. We are going to use some boilerplate code from the -[first Mobile lesson](/content/courses/mobile/basic-solana-mobile). We will be -copying over `components/AuthProvider.tsx` and +[first Mobile lesson](/content/courses/mobile/intro-to-solana-mobile.md). We +will be copying over `components/AuthProvider.tsx` and `components/ConnectionProvider.tsx`. These files provide us with a `Connection` object as well as some helper functions that authorize our dapp. @@ -445,6 +587,35 @@ Secondly, create file `components/ConnectionProvider.tsx` and copy the contents [of our existing Connection Provider from Github](https://raw.githubusercontent.com/Unboxed-Software/solana-advance-mobile/main/components/ConnectionProvider.tsx) into the new file. +then, create file `polyfills.ts` and copy the contents from below to that file. +This will ensure that few native node.js packages work in our app. + +```js +import "react-native-url-polyfill/auto"; +import { getRandomValues as expoCryptoGetRandomValues } from "expo-crypto"; +import { Buffer } from "buffer"; + +global.Buffer = Buffer; +global.TextEncoder = require("text-encoding").TextEncoder; + +// getRandomValues polyfill +class Crypto { + getRandomValues = expoCryptoGetRandomValues; +} + +const webCrypto = typeof crypto !== "undefined" ? crypto : new Crypto(); + +(() => { + if (typeof crypto === "undefined") { + Object.defineProperty(window, "crypto", { + configurable: true, + enumerable: true, + get: () => webCrypto, + }); + } +})(); +``` + Now let's create a boilerplate for our main screen in `screens/MainScreen.tsx`: ```tsx @@ -464,14 +635,11 @@ Finally, let's change `App.tsx` to wrap our application in the two providers we just created: ```tsx -import "react-native-get-random-values"; -import { StatusBar } from "expo-status-bar"; -import { StyleSheet, Text, View } from "react-native"; +import "./polyfills"; import { ConnectionProvider } from "./components/ConnectionProvider"; import { AuthorizationProvider } from "./components/AuthProvider"; import { clusterApiUrl } from "@solana/web3.js"; import { MainScreen } from "./screens/MainScreen"; -global.Buffer = require("buffer").Buffer; export default function App() { const cluster = "devnet"; @@ -491,11 +659,10 @@ export default function App() { } ``` -Notice we've added two polyfills above: `buffer` and -`react-native-get-random-values`. These are necessary for the Solana -dependencies to run correctly. +Notice we've imported polyfills at the top of the file. These are necessary for +the Solana dependencies to run correctly. -#### 4. Build and run Solana boilerplate +#### 3. Build and run Solana boilerplate Let's make sure everything is working and compiling correctly. In Expo, anytime you change the dependencies, you'll need to rebuild and re-install the app. @@ -533,78 +700,148 @@ however it was written largely for Node.js, so we'll need several more polyfills to make it work: ```bash -npm install assert \ - util \ - crypto-browserify \ - stream-browserify \ - readable-stream \ - browserify-zlib \ - path-browserify \ - react-native-url-polyfill \ - @metaplex-foundation/js@0.19.4 +# Using npm +npm install @metaplex-foundation/mpl-token-metadata@3.2.1 \ + @metaplex-foundation/umi@0.9.2 \ + @metaplex-foundation/umi-bundle-defaults@0.9.2 \ + @metaplex-foundation/umi-serializers@0.9.0 \ + @metaplex-foundation/umi-signer-wallet-adapters@0.9.2 \ + text-encoding \ + browserify-zlib \ + react-native-url-polyfill \ + +# Using pnpm +pnpm add @metaplex-foundation/mpl-token-metadata@3.2.1 \ + @metaplex-foundation/umi@0.9.2 \ + @metaplex-foundation/umi-bundle-defaults@0.9.2 \ + @metaplex-foundation/umi-serializers@0.9.0 \ + @metaplex-foundation/umi-signer-wallet-adapters@0.9.2 \ + text-encoding \ + browserify-zlib \ + react-native-url-polyfill \ ``` #### 2. Polyfill config -We won't be importing any of the above polyfills in our code directly, so we -need to add them to a `metro.config.js` file to ensure that Metaplex uses them: +To ensure Metaplex works correctly in a React Native environment, we need to set +up some polyfills. Follow these steps: + +1. Create a `polyfill.ts` file at the root of your project: + +```bash +touch polyfill.ts +``` + +2. Copy and paste the following code into `polyfill.ts`: + +```typescript +import "react-native-url-polyfill/auto"; +import { getRandomValues as expoCryptoGetRandomValues } from "expo-crypto"; +import { Buffer } from "buffer"; + +global.Buffer = Buffer; +global.TextEncoder = require("text-encoding").TextEncoder; + +// getRandomValues polyfill +class Crypto { + getRandomValues = expoCryptoGetRandomValues; +} + +const webCrypto = typeof crypto !== "undefined" ? crypto : new Crypto(); + +(() => { + if (typeof crypto === "undefined") { + Object.defineProperty(window, "crypto", { + configurable: true, + enumerable: true, + get: () => webCrypto, + }); + } +})(); +``` + +3. Import the polyfill at the top of your `App.tsx` file: + +```typescript +import "./polyfill"; +``` + +4. Create a `metro.config.js` file in your project root: ```bash touch metro.config.js ``` -Copy and paste the following into `metro.config.js`: +5. Copy and paste the following into `metro.config.js`: ```js // Import the default Expo Metro config const { getDefaultConfig } = require("@expo/metro-config"); - // Get the default Expo Metro configuration const defaultConfig = getDefaultConfig(__dirname); - // Customize the configuration to include your extra node modules defaultConfig.resolver.extraNodeModules = { - crypto: require.resolve("crypto-browserify"), - stream: require.resolve("readable-stream"), url: require.resolve("react-native-url-polyfill"), zlib: require.resolve("browserify-zlib"), - path: require.resolve("path-browserify"), + crypto: require.resolve("expo-crypto"), }; - // Export the modified configuration module.exports = defaultConfig; ``` +6. Update your `babel.config.js` file with the following content: + +```js +module.exports = function (api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + plugins: [ + [ + "module-resolver", + { + alias: { + buffer: "buffer", + "@metaplex-foundation/umi/serializers": + "@metaplex-foundation/umi-serializers", + }, + }, + ], + ], + }; +}; +``` + +These configurations ensure that the necessary polyfills are in place and that +Metaplex can use them properly in your React Native Expo project. + #### 3. Metaplex provider -We're going to create a Metaplex provider file that will help us access a -`Metaplex` object. This `Metaplex` object is what gives us access to all of the -functions we'll need like `fetch` and `create`. To do this we create a new file +We're going to create a Metaplex provider file that will help us access a `Umi` +object. This `Umi` object is what gives us access to all of the functions we'll +need like `fetch` and `create`. To do this we create a new file `/components/MetaplexProvider.tsx`. Here we pipe our mobile wallet adapter into -an `IdentitySigner` for the `Metaplex` object to use. This allows it to call -several privileged functions on our behalf: +an `IdentitySigner` for the `Umi` object to use. This allows it to call several +privileged functions on our behalf: ```tsx +import { createUmi } from "@metaplex-foundation/umi-bundle-defaults"; +import { mplTokenMetadata } from "@metaplex-foundation/mpl-token-metadata"; +import { Connection, Transaction, VersionedTransaction } from "@solana/web3.js"; +import { useMemo } from "react"; import { - IdentitySigner, - Metaplex, - MetaplexPlugin, -} from "@metaplex-foundation/js"; + WalletAdapter, + walletAdapterIdentity, +} from "@metaplex-foundation/umi-signer-wallet-adapters"; import { transact, Web3MobileWallet, } from "@solana-mobile/mobile-wallet-adapter-protocol-web3js"; -import { Connection, Transaction } from "@solana/web3.js"; -import { useMemo } from "react"; import { Account } from "./AuthProvider"; -export const mobileWalletAdapterIdentity = ( - mwaIdentitySigner: IdentitySigner, -): MetaplexPlugin => ({ - install(metaplex: Metaplex) { - metaplex.identity().setDriver(mwaIdentitySigner); - }, -}); +type Web3JsTransactionOrVersionedTransaction = + | Transaction + | VersionedTransaction; export const useMetaplex = ( connection: Connection, @@ -613,10 +850,9 @@ export const useMetaplex = ( ) => { return useMemo(() => { if (!selectedAccount || !authorizeSession) { - return { mwaIdentitySigner: null, metaplex: null }; + return { umi: null }; } - - const mwaIdentitySigner: IdentitySigner = { + const mwaIdentity: WalletAdapter = { publicKey: selectedAccount.publicKey, signMessage: async (message: Uint8Array): Promise => { return await transact(async (wallet: Web3MobileWallet) => { @@ -630,9 +866,11 @@ export const useMetaplex = ( return signedMessages[0]; }); }, - signTransaction: async ( - transaction: Transaction, - ): Promise => { + signTransaction: async < + T extends Web3JsTransactionOrVersionedTransaction, + >( + transaction: T, + ): Promise => { return await transact(async (wallet: Web3MobileWallet) => { await authorizeSession(wallet); @@ -643,9 +881,11 @@ export const useMetaplex = ( return signedTransactions[0]; }); }, - signAllTransactions: async ( - transactions: Transaction[], - ): Promise => { + signAllTransactions: async < + T extends Web3JsTransactionOrVersionedTransaction, + >( + transactions: T[], + ): Promise => { return transact(async (wallet: Web3MobileWallet) => { await authorizeSession(wallet); const signedTransactions = await wallet.signTransactions({ @@ -656,12 +896,10 @@ export const useMetaplex = ( }, }; - const metaplex = Metaplex.make(connection).use( - mobileWalletAdapterIdentity(mwaIdentitySigner), - ); - - return { metaplex }; - }, [authorizeSession, selectedAccount, connection]); + const umi = createUmi(connection).use(mplTokenMetadata()); + umi.use(walletAdapterIdentity(mwaIdentity)); + return { umi }; + }, [connection, selectedAccount, authorizeSession]); }; ``` @@ -698,7 +936,7 @@ export function NFTProvider(props: NFTProviderProps) { const { connection } = useConnection(); const { authorizeSession } = useAuthorization(); const [account, setAccount] = useState(null); - const { metaplex } = useMetaplex(connection, account, authorizeSession); + const { umi } = useMetaplex(connection, account, authorizeSession); const state = {}; @@ -716,13 +954,12 @@ Notice we've added yet another polyfill to the top Now, let's wrap our new `NFTProvider` around `MainScreen` in `App.tsx`: ```tsx -import "react-native-get-random-values"; +import "./polyfills"; import { ConnectionProvider } from "./components/ConnectionProvider"; import { AuthorizationProvider } from "./components/AuthProvider"; import { clusterApiUrl } from "@solana/web3.js"; import { MainScreen } from "./screens/MainScreen"; import { NFTProvider } from "./components/NFTProvider"; -global.Buffer = require("buffer").Buffer; export default function App() { const cluster = "devnet"; @@ -774,7 +1011,7 @@ form of minting an NFT. The app will need access to the device's camera and a place to remotely store the captured images. Fortunately, Expo SDK can provide access to the camera and -[NFT.Storage](https://nft.storage) can store your NFT files for free. +[Pinata.cloud](https://pinata.cloud/) can store your NFT files for free. #### 1. Camera setup @@ -798,7 +1035,7 @@ as a plugin in `app.json`: [ "expo-image-picker", { - "photosPermission": "Allows you to use images to create solana NFTs" + "photosPermission": "The app accesses your photos to let you use images to create solana NFTs" } ] ], @@ -821,38 +1058,29 @@ const result = await ImagePicker.launchCameraAsync({ No need to add this anywhere yet - we'll get to it in a few steps. -#### 2. NFT.Storage setup +#### 2. Pinata.cloud setup The last thing we need to do is set up our access to -[nft.storage](https://nft.storage). We'll need to get an API key and add it as -an environment variable, then we need to add one last dependency to convert our -images into a file type we can upload. +[Pinata.cloud](https://pinata.cloud/). We'll need to get an API key as well as a +gateway domain and add it as an environment variable. -We'll be using NFT.storage to host our NFTs with IPFS since they do this for -free. [Sign up, and create an API key](https://nft.storage/manage/). Keep this -API key private. +We'll be using Pinata.cloud to host our NFTs with IPFS since they do this for +free. [Sign up, and create an API key](https://app.pinata.cloud/signin). Keep +this API key private. -Best practices suggest keeping API keys in a `.env` file with `.env` added to -your `.gitignore`. It's also a good idea to create a `.env.example` file that -can be committed to your repo and shows what environment variables are needed -for the project. +Best practices suggest keeping API keys as well as the gateway domain in a +`.env` file with `.env` added to your `.gitignore`. It's also a good idea to +create a `.env.example` file that can be committed to your repo and shows what +environment variables are needed for the project. Create both files, in the root of your directory and add `.env` to your `.gitignore` file. Then, add your API key to the `.env` file with the name -`EXPO_PUBLIC_NFT_STORAGE_API`. Now you'll be able to access your API key safely +`EXPO_PUBLIC_PINATA_CLOUD_API`. Now you'll be able to access your API key safely in the application. -Lastly, install `rn-fetch-blob`. This package will help us grab images from the -device's URI scheme and turn them into Blobs we can the upload to -[NFT.storage](https://nft.storage). - -Install it with the following: - -```bash -npm i rn-fetch-blob -``` +Then, add your Gateway domain with the name `EXPO_PUBLIC_NFT_PINATA_GATEWAY_URL` #### 3. Final build @@ -887,8 +1115,8 @@ The app itself is relatively straightforward. The general flow is: 1. The user connects (authorizes) using the `transact` function and by calling `authorizeSession` inside the callback -2. Our code then uses the `Metaplex` object to fetch all of the NFTs created by - the user +2. Our code then uses the `Umi` object to fetch all of the NFTs created by the + user 3. If an NFT has not been created for the current day, allow the user to take a picture, upload it, and mint it as an NFT @@ -897,224 +1125,282 @@ The app itself is relatively straightforward. The general flow is: `NFTProvider.tsx` will control the state with our custom `NFTProviderContext`. This should have the following fields: -- `metaplex: Metaplex | null` - Holds the metaplex object that we use to call - `fetch` and `create` - `publicKey: PublicKey | null` - The NFT creator's public key - `isLoading: boolean` - Manages loading state -- `loadedNFTs: (Nft | Sft | SftWithToken | NftWithToken)[] | null` - An array of - the user's snapshot NFTs -- `nftOfTheDay: (Nft | Sft | SftWithToken | NftWithToken) | null` - A reference - to the NFT created today +- `loadedNFTs: DigitalAsset[] | null` - An array of the user's snapshot NFTs +- `nftOfTheDay: DigitalAsset | null` - A reference to the NFT created today - `connect: () => void` - A function for connecting to the Devnet-enabled wallet - `fetchNFTs: () => void` - A function that fetches the user's snapshot NFTs - `createNFT: (name: string, description: string, fileUri: string) => void` - A function that creates a new snapshot NFT +- `account: Account | null` - An object to store mwa account ```tsx export interface NFTContextState { - metaplex: Metaplex | null; // Holds the metaplex object that we use to call `fetch` and `create` on. - publicKey: PublicKey | null; // The public key of the authorized wallet - isLoading: boolean; // Loading state - loadedNFTs: (Nft | Sft | SftWithToken | NftWithToken)[] | null; // Array of loaded NFTs that contain metadata - nftOfTheDay: (Nft | Sft | SftWithToken | NftWithToken) | null; // The NFT snapshot created on the current day - connect: () => void; // Connects (and authorizes) us to the Devnet-enabled wallet - fetchNFTs: () => void; // Fetches the NFTs using the `metaplex` object - createNFT: (name: string, description: string, fileUri: string) => void; // Creates the NFT + publicKey: PublicKey | null; + isLoading: boolean; + loadedNFTs: DigitalAsset[] | null; + nftOfTheDay: DigitalAsset | null; + connect: () => void; + fetchNFTs: () => void; + createNFT: ( + name: string, + description: string, + fileUri: ImagePickerAsset + ) => void; + account: Account | null; +} } ``` -The state flow here is: `connect`, `fetchNFTs`, and then `createNFT`. We'll walk +The state flow here is: connect, fetchNFTs, and then createNFT. We'll walk through the code for each of them and then show you the entire file at the end: 1. `connect` - This function will connect and authorize the app, and then store the resulting `publicKey` into the state. - ```tsx - const connect = () => { - if (isLoading) return; - - setIsLoading(true); - transact(async wallet => { - const auth = await authorizeSession(wallet); - setAccount(auth); - }).finally(() => { - setIsLoading(false); - }); - }; - ``` - -2. `fetchNFTs` - This function will fetch the NFTs using Metaplex: - - ```tsx - const fetchNFTs = async () => { - if (!metaplex || !account || isLoading) return; - - setIsLoading(true); - - try { - const nfts = await metaplex.nfts().findAllByCreator({ - creator: account.publicKey, - }); - - const loadedNFTs = await Promise.all( - nfts.map(nft => { - return metaplex.nfts().load({ metadata: nft as Metadata }); - }), - ); - setLoadedNFTs(loadedNFTs); - - // Check if we already took a snapshot today - const nftOfTheDayIndex = loadedNFTs.findIndex(nft => { - return formatDate(new Date(Date.now())) === nft.name; - }); - - if (nftOfTheDayIndex !== -1) { - setNftOfTheDay(loadedNFTs[nftOfTheDayIndex]); - } - } catch (error) { - console.log(error); - } finally { - setIsLoading(false); - } - }; - ``` - -3. `createNFT` - This function will upload a file to NFT.Storage, and then use +```tsx +const connect = useCallback(async (): Promise => { + try { + if (isLoading) return; + setIsLoading(true); + await transact(async wallet => { + const account = await authorizeSession(wallet); + setAccount(account); + }); + } catch (error) { + console.log("error connecting wallet"); + } finally { + setIsLoading(false); + } +}, [authorizeSession]); +``` + +2. `fetchNFTs` - This function will fetch the NFTs using Metaplex's Umi object: + +```tsx +const fetchNFTs = async () => { + if (!umi || !account || isLoading) return; + + setIsLoading(true); + + try { + const nfts = await fetchAllDigitalAssetByCreator( + umi, + addressToMetaplexPublicKey(account.publicKey), + ); + + setLoadedNFTs(nfts); + + // Check if we already took a snapshot today + const nftOfTheDayIndex = nfts.findIndex(nft => { + return formatDate(new Date(Date.now())) === nft.metadata.name; + }); + + if (nftOfTheDayIndex) { + setNftOfTheDay(nfts[nftOfTheDayIndex]); + } + } catch (error) { + console.log(error); + } finally { + setIsLoading(false); + } +}; +``` + +3. `createNFT` - This function will upload a file to Pinata.cloud, and then use Metaplex to create and mint an NFT to your wallet. This comes in three parts, uploading the image, uploading the metadata and then minting the NFT. - To upload to NFT.Storage you just make a POST with your API key and the + To upload to Pinata.cloud you just make a POST with your API key and the image/metadata as the body. We'll create two helper functions for uploading the image and metadata separately, then tie them together into a single `createNFT` function: - ```tsx - // https://nft.storage/api-docs/ - const uploadImage = async (fileUri: string): Promise => { - const imageBytesInBase64: string = await RNFetchBlob.fs.readFile( - fileUri, - "base64", - ); - const bytes = Buffer.from(imageBytesInBase64, "base64"); - - const response = await fetch("https://api.nft.storage/upload", { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.EXPO_PUBLIC_NFT_STORAGE_API}`, - "Content-Type": "image/jpg", - }, - body: bytes, - }); - - const data = await response.json(); - const cid = data.value.cid; - - return cid as string; - }; - - const uploadMetadata = async ( - name: string, - description: string, - imageCID: string, - ): Promise => { - const response = await fetch("https://api.nft.storage/upload", { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.EXPO_PUBLIC_NFT_STORAGE_API}`, - }, - body: JSON.stringify({ - name, - description, - image: `https://ipfs.io/ipfs/${imageCID}`, - }), - }); - - const data = await response.json(); - const cid = data.value.cid; - - return cid; - }; - ``` - - Minting the NFT after the image and metadata have been uploaded is as simple - as calling `metaplex.nfts().create(...)`. Below shows the `createNFT` - function tying everything together: - - ```tsx - const createNFT = async ( - name: string, - description: string, - fileUri: string, - ) => { - if (!metaplex || !account || isLoading) return; - - setIsLoading(true); - try { - const imageCID = await uploadImage(fileUri); - const metadataCID = await uploadMetadata(name, description, imageCID); - - const nft = await metaplex.nfts().create({ - uri: `https://ipfs.io/ipfs/${metadataCID}`, - name: name, - sellerFeeBasisPoints: 0, - }); - - setNftOfTheDay(nft.nft); - } catch (error) { - console.log(error); - } finally { - setIsLoading(false); - } - }; - ``` +```tsx +// https://docs.pinata.cloud/quickstart +const uploadImage = async ( + file: ImagePickerAsset, + name: string, +): Promise => { + try { + if (!file) return; + + const formData = new FormData(); + formData.append("file", { + uri: file.uri, + type: "image/jpeg", + name, + }); + + const pinataMetadata = JSON.stringify({ + name, + }); + formData.append("pinataMetadata", pinataMetadata); + const pinataOptions = JSON.stringify({ + cidVersion: 0, + }); + formData.append("pinataOptions", pinataOptions); + + const response = await fetch( + "https://api.pinata.cloud/pinning/pinFileToIPFS", + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.EXPO_PUBLIC_PINATA_CLOUD_API}`, + "Content-Type": "multipart/form-data", + }, + body: formData, + }, + ); + const data = await response.json(); + return data.IpfsHash; + } catch (error) { + console.log(error); + } +}; + +const uploadMetadata = async ( + name: string, + description: string, + imageCID: string, +): Promise => { + try { + const response = await fetch( + "https://api.pinata.cloud/pinning/pinJSONToIPFS", + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.EXPO_PUBLIC_PINATA_CLOUD_API}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + pinataContent: { + name, + description, + image: `${process.env.EXPO_PUBLIC_NFT_PINATA_GATEWAY_URL}/ipfs/${imageCID}`, + }, + pinataOptions: { + cidVersion: 0, + }, + pinataMetadata: { + name, + }, + }), + }, + ); + + const data = await response.json(); + + return data.IpfsHash; + } catch (error) { + console.log(error); + } +}; +``` + +Minting the NFT after the image and metadata have been uploaded is as simple as +calling ` createNft(umi, ...options)`. Below shows the `createNFT` function +tying everything together: + +```tsx +const createNFT = async ( + name: string, + description: string, + fileUri: ImagePickerAsset, +) => { + if (!umi || !account || isLoading) return; + + setIsLoading(true); + try { + const imageCID = await uploadImage(fileUri, name); + if (imageCID) { + const metadataCID = await uploadMetadata(name, description, imageCID); + + const mint = generateSigner(umi); + + await createNft(umi, { + mint, + name, + uri: `${process.env.EXPO_PUBLIC_NFT_PINATA_GATEWAY_URL}/ipfs/${metadataCID}`, + sellerFeeBasisPoints: percentAmount(0), + }).sendAndConfirm(umi, { send: { skipPreflight: true } }); + + const asset = await fetchDigitalAsset(umi, mint.publicKey); + + setNftOfTheDay(asset); + } + } catch (error) { + console.log(error); + } finally { + setIsLoading(false); + } +}; +``` We'll put all of the above into the `NFTProvider.tsx` file. All together, this looks as follows: ```tsx import "react-native-url-polyfill/auto"; -import React, { ReactNode, createContext, useContext, useState } from "react"; -import { - Metaplex, - PublicKey, - Metadata, - Nft, - Sft, - SftWithToken, - NftWithToken, -} from "@metaplex-foundation/js"; +import React, { + ReactNode, + createContext, + useCallback, + useContext, + useState, +} from "react"; import { useConnection } from "./ConnectionProvider"; -import { Connection, clusterApiUrl } from "@solana/web3.js"; import { transact } from "@solana-mobile/mobile-wallet-adapter-protocol"; import { Account, useAuthorization } from "./AuthProvider"; -import RNFetchBlob from "rn-fetch-blob"; import { useMetaplex } from "./MetaplexProvider"; +import { + createNft, + DigitalAsset, + fetchAllDigitalAssetByCreator, + fetchDigitalAsset, +} from "@metaplex-foundation/mpl-token-metadata"; +import { + publicKey as addressToMetaplexPublicKey, + generateSigner, + percentAmount, +} from "@metaplex-foundation/umi"; +import { PublicKey } from "@solana/web3.js"; +import { ImagePickerAsset } from "expo-image-picker"; export interface NFTProviderProps { children: ReactNode; } export interface NFTContextState { - metaplex: Metaplex | null; publicKey: PublicKey | null; isLoading: boolean; - loadedNFTs: (Nft | Sft | SftWithToken | NftWithToken)[] | null; - nftOfTheDay: (Nft | Sft | SftWithToken | NftWithToken) | null; + loadedNFTs: DigitalAsset[] | null; + nftOfTheDay: DigitalAsset | null; connect: () => void; fetchNFTs: () => void; - createNFT: (name: string, description: string, fileUri: string) => void; + createNFT: ( + name: string, + description: string, + fileUri: ImagePickerAsset, + ) => void; + account: Account | null; } const DEFAULT_NFT_CONTEXT_STATE: NFTContextState = { - metaplex: new Metaplex(new Connection(clusterApiUrl("devnet"))), publicKey: null, isLoading: false, loadedNFTs: null, nftOfTheDay: null, connect: () => PublicKey.default, fetchNFTs: () => {}, - createNFT: (name: string, description: string, fileUri: string) => {}, + createNFT: ( + name: string, + description: string, + fileUri: ImagePickerAsset, + ) => {}, + account: null, }; const NFTContext = createContext(DEFAULT_NFT_CONTEXT_STATE); @@ -1129,51 +1415,46 @@ export function NFTProvider(props: NFTProviderProps) { const { authorizeSession } = useAuthorization(); const [account, setAccount] = useState(null); const [isLoading, setIsLoading] = useState(false); - const [nftOfTheDay, setNftOfTheDay] = useState< - (Nft | Sft | SftWithToken | NftWithToken) | null - >(null); - const [loadedNFTs, setLoadedNFTs] = useState< - (Nft | Sft | SftWithToken | NftWithToken)[] | null - >(null); - - const { metaplex } = useMetaplex(connection, account, authorizeSession); + const [nftOfTheDay, setNftOfTheDay] = useState(null); + const [loadedNFTs, setLoadedNFTs] = useState(null); - const connect = () => { - if (isLoading) return; + const { umi } = useMetaplex(connection, account, authorizeSession); - setIsLoading(true); - transact(async wallet => { - const auth = await authorizeSession(wallet); - setAccount(auth); - }).finally(() => { + const connect = useCallback(async (): Promise => { + try { + if (isLoading) return; + setIsLoading(true); + await transact(async wallet => { + const account = await authorizeSession(wallet); + setAccount(account); + }); + } catch (error) { + console.log("error connecting wallet"); + } finally { setIsLoading(false); - }); - }; + } + }, [authorizeSession]); const fetchNFTs = async () => { - if (!metaplex || !account || isLoading) return; + if (!umi || !account || isLoading) return; setIsLoading(true); try { - const nfts = await metaplex.nfts().findAllByCreator({ - creator: account.publicKey, - }); - - const loadedNFTs = await Promise.all( - nfts.map(nft => { - return metaplex.nfts().load({ metadata: nft as Metadata }); - }), + const nfts = await fetchAllDigitalAssetByCreator( + umi, + addressToMetaplexPublicKey(account.publicKey), ); - setLoadedNFTs(loadedNFTs); + + setLoadedNFTs(nfts); // Check if we already took a snapshot today - const nftOfTheDayIndex = loadedNFTs.findIndex(nft => { - return formatDate(new Date(Date.now())) === nft.name; + const nftOfTheDayIndex = nfts.findIndex(nft => { + return formatDate(new Date(Date.now())) === nft.metadata.name; }); - if (nftOfTheDayIndex !== -1) { - setNftOfTheDay(loadedNFTs[nftOfTheDayIndex]); + if (nftOfTheDayIndex) { + setNftOfTheDay(nfts[nftOfTheDayIndex]); } } catch (error) { console.log(error); @@ -1182,71 +1463,112 @@ export function NFTProvider(props: NFTProviderProps) { } }; - // https://nft.storage/api-docs/ - const uploadImage = async (fileUri: string): Promise => { - const imageBytesInBase64: string = await RNFetchBlob.fs.readFile( - fileUri, - "base64", - ); - const bytes = Buffer.from(imageBytesInBase64, "base64"); + // https://docs.pinata.cloud/quickstart + const uploadImage = async ( + file: ImagePickerAsset, + name: string, + ): Promise => { + try { + if (!file) return; - const response = await fetch("https://api.nft.storage/upload", { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.EXPO_PUBLIC_NFT_STORAGE_API}`, - "Content-Type": "image/jpg", - }, - body: bytes, - }); + const formData = new FormData(); + formData.append("file", { + uri: file.uri, + type: "image/jpeg", + name, + }); - const data = await response.json(); - const cid = data.value.cid; + const pinataMetadata = JSON.stringify({ + name, + }); + formData.append("pinataMetadata", pinataMetadata); + const pinataOptions = JSON.stringify({ + cidVersion: 0, + }); + formData.append("pinataOptions", pinataOptions); - return cid as string; + const response = await fetch( + "https://api.pinata.cloud/pinning/pinFileToIPFS", + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.EXPO_PUBLIC_PINATA_CLOUD_API}`, + "Content-Type": "multipart/form-data", + }, + body: formData, + }, + ); + const data = await response.json(); + return data.IpfsHash; + } catch (error) { + console.log(error); + } }; const uploadMetadata = async ( name: string, description: string, imageCID: string, - ): Promise => { - const response = await fetch("https://api.nft.storage/upload", { - method: "POST", - headers: { - Authorization: `Bearer ${process.env.EXPO_PUBLIC_NFT_STORAGE_API}`, - }, - body: JSON.stringify({ - name, - description, - image: `https://ipfs.io/ipfs/${imageCID}`, - }), - }); + ): Promise => { + try { + const response = await fetch( + "https://api.pinata.cloud/pinning/pinJSONToIPFS", + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.EXPO_PUBLIC_PINATA_CLOUD_API}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + pinataContent: { + name, + description, + image: `${process.env.EXPO_PUBLIC_NFT_PINATA_GATEWAY_URL}/ipfs/${imageCID}`, + }, + pinataOptions: { + cidVersion: 0, + }, + pinataMetadata: { + name, + }, + }), + }, + ); - const data = await response.json(); - const cid = data.value.cid; + const data = await response.json(); - return cid; + return data.IpfsHash; + } catch (error) { + console.log(error); + } }; const createNFT = async ( name: string, description: string, - fileUri: string, + fileUri: ImagePickerAsset, ) => { - if (!metaplex || !account || isLoading) return; + if (!umi || !account || isLoading) return; setIsLoading(true); try { - const imageCID = await uploadImage(fileUri); - const metadataCID = await uploadMetadata(name, description, imageCID); + const imageCID = await uploadImage(fileUri, name); + if (imageCID) { + const metadataCID = await uploadMetadata(name, description, imageCID); - const nft = await metaplex.nfts().create({ - uri: `https://ipfs.io/ipfs/${metadataCID}`, - name: name, - sellerFeeBasisPoints: 0, - }); + const mint = generateSigner(umi); + + await createNft(umi, { + mint, + name, + uri: `${process.env.EXPO_PUBLIC_NFT_PINATA_GATEWAY_URL}/ipfs/${metadataCID}`, + sellerFeeBasisPoints: percentAmount(0), + }).sendAndConfirm(umi, { send: { skipPreflight: true } }); - setNftOfTheDay(nft.nft); + const asset = await fetchDigitalAsset(umi, mint.publicKey); + + setNftOfTheDay(asset); + } } catch (error) { console.log(error); } finally { @@ -1258,14 +1580,13 @@ export function NFTProvider(props: NFTProviderProps) { const state = { isLoading, - account, publicKey, - metaplex, nftOfTheDay, loadedNFTs, connect, fetchNFTs, createNFT, + account, }; return {children}; @@ -1295,24 +1616,31 @@ description to our `createNFT` function from `NFTProvider` to mint the NFT. ```tsx const mintNFT = async () => { - const result = await ImagePicker.launchCameraAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - aspect: [1, 1], - quality: 1, - }); - - if (!result.canceled) { - setCurrentImage({ - uri: result.assets[0].uri, - date: todaysDate, + try { + if (!status?.granted) { + await requestPermission(); + } + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 1, }); - createNFT( - formatDate(todaysDate), - `${todaysDate.getTime()}`, - result.assets[0].uri, - ); + if (!result.canceled) { + setCurrentImage({ + uri: result.assets[0].uri, + date: todaysDate, + }); + + createNFT( + formatDate(todaysDate), + `${todaysDate.getTime()}`, + result.assets[0], + ); + } + } catch (error) { + console.log(error); } }; ``` @@ -1379,6 +1707,11 @@ export interface NFTSnapshot { date: Date; } +type NftMetaResponse = { + name: string; + description: string; + image: string; +}; // Placeholder image URL or local source const PLACEHOLDER: NFTSnapshot = { uri: "https://placehold.co/400x400/png", @@ -1401,69 +1734,115 @@ export function MainScreen() { const [previousImages, setPreviousImages] = React.useState(DEFAULT_IMAGES); const todaysDate = new Date(Date.now()); + const [status, requestPermission] = ImagePicker.useCameraPermissions(); + + const fetchMetadata = async (uri: string) => { + try { + const response = await fetch(uri); + const metadata = await response.json(); + return metadata as NftMetaResponse; + } catch (error) { + console.error("Error fetching metadata:", error); + return null; + } + }; useEffect(() => { if (!loadedNFTs) return; - const loadedSnapshots = loadedNFTs.map(loadedNft => { - if (!loadedNft.json) return null; - if (!loadedNft.json.name) return null; - if (!loadedNft.json.description) return null; - if (!loadedNft.json.image) return null; + const loadSnapshots = async () => { + const loadedSnapshots = await Promise.all( + loadedNFTs.map(async loadedNft => { + if (!loadedNft.metadata.name) return null; + if (!loadedNft.metadata.uri) return null; - const uri = loadedNft.json.image; - const unixTime = Number(loadedNft.json.description); + const metadata = await fetchMetadata(loadedNft.metadata.uri); - if (!uri) return null; - if (isNaN(unixTime)) return null; + if (!metadata) return null; - return { - uri: loadedNft.json.image, - date: new Date(unixTime), - } as NFTSnapshot; - }); + const { image, description } = metadata; - // Filter out null values - const cleanedSnapshots = loadedSnapshots.filter(loadedSnapshot => { - return loadedSnapshot !== null; - }) as NFTSnapshot[]; + if (!image || !description) return null; - // Sort by date - cleanedSnapshots.sort((a, b) => { - return b.date.getTime() - a.date.getTime(); - }); + const unixTime = Number(description); + if (isNaN(unixTime)) return null; + + return { + uri: image, + date: new Date(unixTime), + } as NFTSnapshot; + }), + ); + + // Filter out null values + const cleanedSnapshots = loadedSnapshots.filter( + (snapshot): snapshot is NFTSnapshot => snapshot !== null, + ); - setPreviousImages(cleanedSnapshots as NFTSnapshot[]); + // Sort by date + cleanedSnapshots.sort((a, b) => b.date.getTime() - a.date.getTime()); + + setPreviousImages(cleanedSnapshots); + }; + + loadSnapshots(); }, [loadedNFTs]); useEffect(() => { if (!nftOfTheDay) return; - setCurrentImage({ - uri: nftOfTheDay.json?.image ?? "", - date: todaysDate, - }); - }, [nftOfTheDay]); + const fetchNftOfTheDayMetadata = async () => { + try { + if (!nftOfTheDay.metadata.uri) { + console.error("No metadata URI found for nftOfTheDay"); + return; + } - const mintNFT = async () => { - const result = await ImagePicker.launchCameraAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, - allowsEditing: true, - aspect: [1, 1], - quality: 1, - }); + const response = await fetchMetadata(nftOfTheDay.metadata.uri); - if (!result.canceled) { - setCurrentImage({ - uri: result.assets[0].uri, - date: todaysDate, + if (!response?.image) { + console.error("No image found in nftOfTheDay metadata"); + return; + } + + setCurrentImage({ + uri: response.image, + date: todaysDate, + }); + } catch (error) { + console.error("Error fetching nftOfTheDay metadata:", error); + } + }; + + fetchNftOfTheDayMetadata(); + }, [nftOfTheDay, todaysDate]); + + const mintNFT = async () => { + try { + if (!status?.granted) { + await requestPermission(); + } + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + aspect: [1, 1], + quality: 1, }); - createNFT( - formatDate(todaysDate), - `${todaysDate.getTime()}`, - result.assets[0].uri, - ); + if (!result.canceled) { + setCurrentImage({ + uri: result.assets[0].uri, + date: todaysDate, + }); + + createNFT( + formatDate(todaysDate), + `${todaysDate.getTime()}`, + result.assets[0], + ); + } + } catch (error) { + console.log(error); } };