From e3dc165bc53fcfa7d8d3e42e99d0c7cf8ff405b5 Mon Sep 17 00:00:00 2001 From: Rohan Thomare Date: Mon, 5 Jun 2023 20:48:42 -0400 Subject: [PATCH] feat: add smart contract for nft for onboarding (#10) * feat: clean up components for profile and nft fetching * feat: add smart contract for nft --- .../alchemy-daapp/src/clients/appState.ts | 74 ++++++++++++ examples/alchemy-daapp/src/clients/nfts.ts | 17 +-- .../alchemy-daapp/src/clients/onboarding.ts | 2 +- .../src/components/NavigationBar.tsx | 10 +- .../src/components/connect/ConnectPage.tsx | 18 +++ .../src/components/profile/NFTs.tsx | 14 +-- .../src/components/profile/ProfilePage.tsx | 19 ++- examples/alchemy-daapp/src/context/Wallet.tsx | 17 ++- .../src/screens/ProfileScreen.tsx | 14 --- .../alchemy-daapp/src/screens/RootScreen.tsx | 22 +++- examples/contracts/DAAppNFT/foundry.toml | 7 ++ examples/contracts/DAAppNFT/migrations.md | 5 + examples/contracts/DAAppNFT/remappings.txt | 2 + examples/contracts/DAAppNFT/src/Contract.sol | 56 +++++++++ .../DAAppNFT/src/test/Contract.t.sol | 110 ++++++++++++++++++ examples/contracts/README.md | 47 ++++++++ 16 files changed, 388 insertions(+), 46 deletions(-) create mode 100644 examples/alchemy-daapp/src/clients/appState.ts create mode 100644 examples/alchemy-daapp/src/components/connect/ConnectPage.tsx delete mode 100644 examples/alchemy-daapp/src/screens/ProfileScreen.tsx create mode 100644 examples/contracts/DAAppNFT/foundry.toml create mode 100644 examples/contracts/DAAppNFT/migrations.md create mode 100644 examples/contracts/DAAppNFT/remappings.txt create mode 100644 examples/contracts/DAAppNFT/src/Contract.sol create mode 100644 examples/contracts/DAAppNFT/src/test/Contract.t.sol create mode 100644 examples/contracts/README.md diff --git a/examples/alchemy-daapp/src/clients/appState.ts b/examples/alchemy-daapp/src/clients/appState.ts new file mode 100644 index 0000000000..d07abd1b5a --- /dev/null +++ b/examples/alchemy-daapp/src/clients/appState.ts @@ -0,0 +1,74 @@ +import { useAccount } from "wagmi"; +import { useNFTsQuery } from "./nfts"; +import { useEffect, useState } from "react"; + +export type AppState = + | { + state: "LOADING"; + eoaAddress: undefined; + scwAddress: undefined; + } + | { + state: "UNCONNECTED"; + eoaAddress: undefined; + scwAddress: undefined; + } + | { + state: "NO_SCW"; + eoaAddress: string; + scwAddress: undefined; + } + | { + state: "HAS_SCW"; + eoaAddress: string; + scwAddress: string; + }; + +export function useAppState(): AppState { + const { address, isConnected } = useAccount(); + const nfts = useNFTsQuery(address); + const [state, setState] = useState({ + state: "UNCONNECTED", + eoaAddress: undefined, + scwAddress: undefined, + }); + useEffect(() => { + if (!isConnected || !address) { + setState({ + state: "UNCONNECTED", + eoaAddress: undefined, + scwAddress: undefined, + }); + return; + } + + if (nfts.isLoading) { + setState({ + state: "LOADING", + eoaAddress: undefined, + scwAddress: undefined, + }); + return; + } + const scwNFT = nfts?.data?.ownedNfts.find((value) => { + return value.contract.address === "0x0000000000000000"; + }); + const scwAttribute = scwNFT?.metadata.attributes?.find((attribute) => { + attribute.trait_type === "SCW"; + }); + if (!scwNFT || !scwAttribute) { + setState({ + state: "NO_SCW", + eoaAddress: address as `0x${string}`, + scwAddress: undefined, + }); + } else { + setState({ + state: "HAS_SCW", + eoaAddress: address as `0x${string}`, + scwAddress: scwAttribute.value!, + }); + } + }, [address, isConnected, nfts]); + return state; +} diff --git a/examples/alchemy-daapp/src/clients/nfts.ts b/examples/alchemy-daapp/src/clients/nfts.ts index 23cd6dc94e..7575e92207 100644 --- a/examples/alchemy-daapp/src/clients/nfts.ts +++ b/examples/alchemy-daapp/src/clients/nfts.ts @@ -1,13 +1,14 @@ -import {useQuery} from "@tanstack/react-query"; -import {getNFTs} from "../http/endpoints"; -import {OwnedNFTsResponse} from "../declarations/api"; +import { useQuery } from "@tanstack/react-query"; +import { getNFTs } from "../http/endpoints"; export function useNFTsQuery(ethAddress?: string) { return useQuery(["nfts", ethAddress], () => { - if (ethAddress) { - return getNFTs(ethAddress); - } else { - return Promise.resolve({data: []} as unknown as OwnedNFTsResponse); - } + return ethAddress + ? getNFTs(ethAddress) + : Promise.resolve({ + ownedNfts: [], + totalCount: 0, + blockHash: "0x000000000000", + }); }); } diff --git a/examples/alchemy-daapp/src/clients/onboarding.ts b/examples/alchemy-daapp/src/clients/onboarding.ts index 3247f139ef..056179fd99 100644 --- a/examples/alchemy-daapp/src/clients/onboarding.ts +++ b/examples/alchemy-daapp/src/clients/onboarding.ts @@ -1,4 +1,4 @@ -import {useCallback, useState} from "react"; +import { useCallback, useState } from "react"; export interface OnboardingContext { accountAddress?: string; diff --git a/examples/alchemy-daapp/src/components/NavigationBar.tsx b/examples/alchemy-daapp/src/components/NavigationBar.tsx index f7f2e83c4d..2d07177d03 100644 --- a/examples/alchemy-daapp/src/components/NavigationBar.tsx +++ b/examples/alchemy-daapp/src/components/NavigationBar.tsx @@ -2,8 +2,10 @@ import { Box, Flex } from "@chakra-ui/react"; import Link from "next/link"; import Image from "next/image"; import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { useAppState } from "~/clients/appState"; export default function NavigationBar() { + const { state } = useAppState(); return ( logo - - - + {state !== "UNCONNECTED" && ( + + + + )} ); } diff --git a/examples/alchemy-daapp/src/components/connect/ConnectPage.tsx b/examples/alchemy-daapp/src/components/connect/ConnectPage.tsx new file mode 100644 index 0000000000..c9bfdf6a62 --- /dev/null +++ b/examples/alchemy-daapp/src/components/connect/ConnectPage.tsx @@ -0,0 +1,18 @@ +import { Center, Heading, Text, VStack } from "@chakra-ui/react"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; + +export function ConnectPage() { + return ( +
+ + Welcome to the Alchemy exmple D🅰️🅰️pp! + + We're excited for you to start using account abstraction!!
+ Click below to connect your wallet, and create your own account + abstrated smart contract wallet. +
+ +
+
+ ); +} diff --git a/examples/alchemy-daapp/src/components/profile/NFTs.tsx b/examples/alchemy-daapp/src/components/profile/NFTs.tsx index eb59719823..3c1c48efe7 100644 --- a/examples/alchemy-daapp/src/components/profile/NFTs.tsx +++ b/examples/alchemy-daapp/src/components/profile/NFTs.tsx @@ -1,15 +1,15 @@ -import {memo} from "react"; +import { memo } from "react"; import NFT from "./NFT"; -import {useNFTsQuery} from "../../clients/nfts"; -import {LoadingScreen} from "../../screens/LoadingScreen"; -import {ErrorScreen} from "../../screens/ErrorScreen"; -import {BoxProps, Grid, Text} from "@chakra-ui/react"; +import { useNFTsQuery } from "../../clients/nfts"; +import { LoadingScreen } from "../../screens/LoadingScreen"; +import { ErrorScreen } from "../../screens/ErrorScreen"; +import { BoxProps, Grid, Text } from "@chakra-ui/react"; interface NFTsProps extends BoxProps { - address?: string; + address: string; } -const NFTs = memo(function Achievements({address, ...boxProps}: NFTsProps) { +const NFTs = memo(function Achievements({ address, ...boxProps }: NFTsProps) { const ownedNFTsQuery = useNFTsQuery(address); if (!address) { return No Address to Assoicate Achievements; diff --git a/examples/alchemy-daapp/src/components/profile/ProfilePage.tsx b/examples/alchemy-daapp/src/components/profile/ProfilePage.tsx index d715ee9dcf..5e5e7c6069 100644 --- a/examples/alchemy-daapp/src/components/profile/ProfilePage.tsx +++ b/examples/alchemy-daapp/src/components/profile/ProfilePage.tsx @@ -8,6 +8,7 @@ import { } from "@chakra-ui/react"; import { memo, useCallback } from "react"; import NFTs from "./NFTs"; +import { useAppState } from "../../clients/appState"; const ProfileAttribute = ({ label, @@ -52,7 +53,8 @@ const ProfileDetailCard = ({ ); -function UnMemoProfilePage({ address }: { address: string }) { +function UnMemoProfilePage() { + const { state, eoaAddress, scwAddress } = useAppState(); const copyAddressTextToClipboard = useCallback((address: string) => { return async () => { if ("clipboard" in navigator && address) { @@ -63,14 +65,21 @@ function UnMemoProfilePage({ address }: { address: string }) { }; }, []); + if (state !== "HAS_SCW") { + return null; + } + return ( - + @@ -80,7 +89,7 @@ function UnMemoProfilePage({ address }: { address: string }) { > @@ -90,7 +99,7 @@ function UnMemoProfilePage({ address }: { address: string }) { NFTs - + diff --git a/examples/alchemy-daapp/src/context/Wallet.tsx b/examples/alchemy-daapp/src/context/Wallet.tsx index ca9536c60d..16075d690e 100644 --- a/examples/alchemy-daapp/src/context/Wallet.tsx +++ b/examples/alchemy-daapp/src/context/Wallet.tsx @@ -1,9 +1,13 @@ import "@rainbow-me/rainbowkit/styles.css"; -import { getDefaultWallets, RainbowKitProvider } from "@rainbow-me/rainbowkit"; +import { + connectorsForWallets, + RainbowKitProvider, +} from "@rainbow-me/rainbowkit"; import { configureChains, createConfig, WagmiConfig } from "wagmi"; import { polygonMumbai } from "wagmi/chains"; import { jsonRpcProvider } from "wagmi/providers/jsonRpc"; +import { injectedWallet } from "@rainbow-me/rainbowkit/wallets"; const { chains, publicClient } = configureChains( [polygonMumbai], @@ -18,11 +22,12 @@ const { chains, publicClient } = configureChains( ] ); -const { connectors } = getDefaultWallets({ - appName: "Alchemy DAApp", - projectId: "alchemy-daapp", - chains, -}); +const connectors = connectorsForWallets([ + { + groupName: "Recommended", + wallets: [injectedWallet({ chains })], + }, +]); const wagmiConfig = createConfig({ autoConnect: true, diff --git a/examples/alchemy-daapp/src/screens/ProfileScreen.tsx b/examples/alchemy-daapp/src/screens/ProfileScreen.tsx deleted file mode 100644 index b85f7e0b56..0000000000 --- a/examples/alchemy-daapp/src/screens/ProfileScreen.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { ProfilePage } from "../components/profile/ProfilePage"; -import { OnboardingPage } from "../components/onboarding/OnboardingPage"; -import { useAccount } from "wagmi"; - -export default function ProfileScreen() { - const { address, isConnected } = useAccount(); - - // Show onboarding if is current user - if (!isConnected) { - return ; - } else { - return ; - } -} diff --git a/examples/alchemy-daapp/src/screens/RootScreen.tsx b/examples/alchemy-daapp/src/screens/RootScreen.tsx index 15df0ea894..dec148d482 100644 --- a/examples/alchemy-daapp/src/screens/RootScreen.tsx +++ b/examples/alchemy-daapp/src/screens/RootScreen.tsx @@ -1,10 +1,28 @@ -import ProfileScreen from "./ProfileScreen"; import NavigationBar from "../components/NavigationBar"; import { ChakraProvider } from "@chakra-ui/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "../clients/query"; import { ToastContainer } from "~/utils/toast"; import { WalletContext } from "~/context/Wallet"; +import { OnboardingPage } from "../components/onboarding/OnboardingPage"; +import { useAppState } from "~/clients/appState"; +import { ProfilePage } from "~/components/profile/ProfilePage"; +import { ConnectPage } from "~/components/connect/ConnectPage"; +import { LoadingScreen } from "./LoadingScreen"; + +function LandingScreen() { + const { state } = useAppState(); + switch (state) { + case "HAS_SCW": + return ; + case "NO_SCW": + return ; + case "UNCONNECTED": + return ; + case "LOADING": + return ; + } +} export default function RootScreen() { return ( @@ -12,7 +30,7 @@ export default function RootScreen() { - + diff --git a/examples/contracts/DAAppNFT/foundry.toml b/examples/contracts/DAAppNFT/foundry.toml new file mode 100644 index 0000000000..19903e0b24 --- /dev/null +++ b/examples/contracts/DAAppNFT/foundry.toml @@ -0,0 +1,7 @@ +[default] +src = 'src' +out = 'out' +libs = ['lib'] +remappings = ['ds-test/=lib/ds-test/src/'] + +# See more config options https://github.com/gakonst/foundry/tree/master/config \ No newline at end of file diff --git a/examples/contracts/DAAppNFT/migrations.md b/examples/contracts/DAAppNFT/migrations.md new file mode 100644 index 0000000000..6514b568fd --- /dev/null +++ b/examples/contracts/DAAppNFT/migrations.md @@ -0,0 +1,5 @@ +## Version 1 + +Deployer: 0x15a411507e901f26965f0ebcd3155834b058a6b2 +Deployed to: 0xb7b9424ef3d1b9086b7e53276c4aad68a1dd971c +Transaction hash: 0x46f95ea67e2b103f607884a87fd25d53d619eaa7d67437f1d7cb51f7c99b6de2 diff --git a/examples/contracts/DAAppNFT/remappings.txt b/examples/contracts/DAAppNFT/remappings.txt new file mode 100644 index 0000000000..6f991130fa --- /dev/null +++ b/examples/contracts/DAAppNFT/remappings.txt @@ -0,0 +1,2 @@ +openzeppelin-contracts/=lib/openzeppelin-contracts/ +forge-std/=lib/forge-std/src/ \ No newline at end of file diff --git a/examples/contracts/DAAppNFT/src/Contract.sol b/examples/contracts/DAAppNFT/src/Contract.sol new file mode 100644 index 0000000000..5358bd2d85 --- /dev/null +++ b/examples/contracts/DAAppNFT/src/Contract.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.10; + +import "solmate/tokens/ERC721.sol"; +import "openzeppelin-contracts/contracts/utils/Strings.sol"; +import "openzeppelin-contracts/contracts/access/Ownable.sol"; + +error MintPriceNotPaid(); +error MaxSupply(); +error NonExistentTokenURI(); +error WithdrawTransfer(); + +contract NFT is ERC721, Ownable { + + using Strings for uint256; + string public baseURI; + uint256 public currentTokenId; + + constructor( + string memory _name, + string memory _symbol, + string memory _baseURI + ) ERC721(_name, _symbol) { + baseURI = _baseURI; + } + + function mintTo(address recipient) public payable returns (uint256) { + uint256 newTokenId = ++currentTokenId; + _safeMint(recipient, newTokenId); + return newTokenId; + } + + function tokenURI(uint256 tokenId) + public + view + virtual + override + returns (string memory) + { + if (ownerOf(tokenId) == address(0)) { + revert NonExistentTokenURI(); + } + return + bytes(baseURI).length > 0 + ? baseURI + : ""; + } + + function withdrawPayments(address payable payee) external onlyOwner { + uint256 balance = address(this).balance; + (bool transferTx, ) = payee.call{value: balance}(""); + if (!transferTx) { + revert WithdrawTransfer(); + } + } +} \ No newline at end of file diff --git a/examples/contracts/DAAppNFT/src/test/Contract.t.sol b/examples/contracts/DAAppNFT/src/test/Contract.t.sol new file mode 100644 index 0000000000..0a9b1f0000 --- /dev/null +++ b/examples/contracts/DAAppNFT/src/test/Contract.t.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.10; + +import "forge-std/Test.sol"; +import "../Contract.sol"; + +contract NFTTest is Test { + using stdStorage for StdStorage; + + NFT private nft; + + function setUp() public { + // Deploy NFT contract + nft = new NFT("NFT_tutorial", "TUT", "baseUri"); + } + + function test_RevertMintToZeroAddress() public { + vm.expectRevert("INVALID_RECIPIENT"); + nft.mintTo(address(0)); + } + + function test_NewMintOwnerRegistered() public { + nft.mintTo(address(1)); + uint256 slotOfNewOwner = stdstore + .target(address(nft)) + .sig(nft.ownerOf.selector) + .with_key(1) + .find(); + + uint160 ownerOfTokenIdOne = uint160( + uint256( + (vm.load(address(nft), bytes32(abi.encode(slotOfNewOwner)))) + ) + ); + assertEq(address(ownerOfTokenIdOne), address(1)); + } + + function test_BalanceIncremented() public { + nft.mintTo(address(1)); + uint256 slotBalance = stdstore + .target(address(nft)) + .sig(nft.balanceOf.selector) + .with_key(address(1)) + .find(); + + uint256 balanceFirstMint = uint256( + vm.load(address(nft), bytes32(slotBalance)) + ); + assertEq(balanceFirstMint, 1); + + nft.mintTo(address(1)); + uint256 balanceSecondMint = uint256( + vm.load(address(nft), bytes32(slotBalance)) + ); + assertEq(balanceSecondMint, 2); + } + + function test_SafeContractReceiver() public { + Receiver receiver = new Receiver(); + nft.mintTo(address(receiver)); + uint256 slotBalance = stdstore + .target(address(nft)) + .sig(nft.balanceOf.selector) + .with_key(address(receiver)) + .find(); + + uint256 balance = uint256(vm.load(address(nft), bytes32(slotBalance))); + assertEq(balance, 1); + } + + function test_RevertUnSafeContractReceiver() public { + vm.etch(address(1), bytes("mock code")); + vm.expectRevert(bytes("")); + nft.mintTo(address(1)); + } + + function test_WithdrawalWorksAsOwner() public { + // Mint an NFT, sending eth to the contract + Receiver receiver = new Receiver(); + address payable payee = payable(address(0x1337)); + uint256 priorPayeeBalance = payee.balance; + nft.mintTo(address(receiver)); + uint256 nftBalance = address(nft).balance; + // Withdraw the balance and assert it was transferred + nft.withdrawPayments(payee); + assertEq(payee.balance, priorPayeeBalance + nftBalance); + } + + function test_WithdrawalFailsAsNotOwner() public { + // Mint an NFT, sending eth to the contract + Receiver receiver = new Receiver(); + nft.mintTo(address(receiver)); + // Confirm that a non-owner cannot withdraw + vm.expectRevert("Ownable: caller is not the owner"); + vm.startPrank(address(0xd3ad)); + nft.withdrawPayments(payable(address(0xd3ad))); + vm.stopPrank(); + } +} + +contract Receiver is ERC721TokenReceiver { + function onERC721Received( + address operator, + address from, + uint256 id, + bytes calldata data + ) external override returns (bytes4) { + return this.onERC721Received.selector; + } +} diff --git a/examples/contracts/README.md b/examples/contracts/README.md new file mode 100644 index 0000000000..79d165c427 --- /dev/null +++ b/examples/contracts/README.md @@ -0,0 +1,47 @@ +# Contracts + +Contracts Used for AA SDK + +Built using foundry + Alchemy + +- Foundry Getting Started: https://book.getfoundry.sh/getting-started/installation +- Alchemy Getting Started: https://docs.alchemy.com/reference/api-overview + +## Index + +1. `./DAAppNFT` - A simple NFT based off the tutorial: https://book.getfoundry.sh/tutorials/solmate-nft + +## Development + +In one of the Subfolder Projects. + +1. Set your environment variables by running: + +``` +export RPC_URL= +export PRIVATE_KEY= +``` + +2. Once set, you can deploy your with Forge by running the below command while and adding the relevant constructor arguments: + +``` +forge create --rpc-url=$RPC_URL --private-key=$PRIVATE_KEY --constructor-args +``` + +If successfully deployed, you will see the deploying wallet's address, the contract's address as well as the transaction hash printed to your terminal. + +3. Calling functions on your contract. For example to send an execution transaction + +``` +cast send --rpc-url=$RPC_URL "exampleMintFunction(address)"
--private-key=$PRIVATE_KEY +``` + +Given that you already set your RPC and private key env variables during deployment. + +4. Or if you want to execute a call on the contract + +``` +cast call --rpc-url=$RPC_URL --private-key=$PRIVATE_KEY "ownerOf(uint256)" +``` + +5. If you'd like you can also run a node locally by using anvil. See more here: https://book.getfoundry.sh/reference/anvil/