diff --git a/apps/next/lib/NavLayout.tsx b/apps/next/lib/NavLayout.tsx index 1822c86..91f805f 100644 --- a/apps/next/lib/NavLayout.tsx +++ b/apps/next/lib/NavLayout.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { NavBar } from 'app/ui/navbar'; import { useRouter } from 'next/router'; import { BottomTabs } from 'app/ui/navbar/BottomTabs'; import { Box } from 'app/ui/layout/Box'; +import { wagmiClient } from 'app/provider/web3/connectKit'; type NavLayoutProps = { children: React.ReactNode; @@ -17,6 +18,10 @@ export const NavLayout: React.FC = ({ children }) => { setIsOpen(false); }, [pathname]); + useEffect(() => { + wagmiClient.autoConnect(); + }, []); + const links = [ { href: '/shop', diff --git a/apps/next/package.json b/apps/next/package.json index 92b0ea4..e6d223e 100644 --- a/apps/next/package.json +++ b/apps/next/package.json @@ -15,7 +15,7 @@ "@expo/next-adapter": "^4.0.13", "@mf/api": "*", "app": "*", - "connectkit-next-siwe": "^0.0.2", + "connectkit-next-siwe": "0.1.0", "next": "^13.0.6", "next-auth": "^4.16.4", "raf": "^3.4.1", diff --git a/apps/next/pages/_app.tsx b/apps/next/pages/_app.tsx index dab6095..eb89b8a 100644 --- a/apps/next/pages/_app.tsx +++ b/apps/next/pages/_app.tsx @@ -1,6 +1,6 @@ import { Provider } from 'app/provider'; import Head from 'next/head'; -import React from 'react'; +import React, { useEffect } from 'react'; import type { SolitoAppProps } from 'solito'; import 'raf/polyfill'; import '../global.css'; diff --git a/apps/next/pages/api/siwe/[...route].ts b/apps/next/pages/api/siwe/[...route].ts index 9a5c7d3..83202e1 100644 --- a/apps/next/pages/api/siwe/[...route].ts +++ b/apps/next/pages/api/siwe/[...route].ts @@ -1,2 +1,2 @@ -import { siwe } from 'shared/auth/siwe'; -export default siwe.apiRouteHandler; +import { siweServer } from 'shared/auth/siweServer'; +export default siweServer.apiRouteHandler; diff --git a/apps/next/pages/inventory.tsx b/apps/next/pages/inventory.tsx index 36720a3..56f9fc6 100644 --- a/apps/next/pages/inventory.tsx +++ b/apps/next/pages/inventory.tsx @@ -1,9 +1,9 @@ -import { NavLayout } from '../lib/NavLayout'; +import { NavLayout } from '~/lib/NavLayout'; import type { SolitoPage } from 'solito'; -import { SecondScreen } from 'app/features/home/SecondScreen'; +import { Inventory } from 'app/features/inventory/Inventory'; -const Settings: SolitoPage = () => ; +const InventoryPage: SolitoPage = () => ; -Settings.getLayout = (page) => {page}; +InventoryPage.getLayout = (page) => {page}; -export default Settings; +export default InventoryPage; diff --git a/packages/api/package.json b/packages/api/package.json index bccb0f6..c7cc149 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -12,6 +12,7 @@ "dependencies": { "@trpc/client": "10.8.2", "@trpc/server": "10.8.2", + "@wagmi/core": "0.10.8", "graphql-request": "^5.1.0", "services": "*", "shared": "*", diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index c0fcf0c..da5e50d 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,10 +1,12 @@ import { createTRPCRouter } from './trpc'; import { productRouter } from './products/router'; import { authRouter } from './auth/router'; +import { wearablesRouter } from './wearables/router'; export const appRouter = createTRPCRouter({ product: productRouter, auth: authRouter, + wearables: wearablesRouter, }); // export type definition of API diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index ff8020a..6abfd3a 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -17,12 +17,16 @@ * */ import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; +import { Chain } from 'wagmi'; -import { siwe, SiweSession } from 'shared/auth/siwe'; +import { siweServer, SiweSession } from 'shared/auth/siweServer'; import { mfosClient } from 'services/mfos/client'; +import { hasuraClient } from 'services/graphql/client'; +import { ChainsById } from 'shared/config/chains'; interface CreateInnerContextOptions { session: SiweSession | null; + chain: Chain; } /** * This helper generates the "internals" for a tRPC context. If you need to use @@ -36,7 +40,9 @@ interface CreateInnerContextOptions { const createInnerTRPCContext = (opts: CreateInnerContextOptions) => { return { session: opts.session, + chain: opts.chain, mfosClient, + hasuraClient, }; }; @@ -48,11 +54,11 @@ const createInnerTRPCContext = (opts: CreateInnerContextOptions) => { export const createTRPCContext = async (opts: CreateNextContextOptions) => { const { req, res } = opts; - // Get the session from the server using the unstable_getServerSession wrapper function - const session = await siwe.getSession(req, res); + const session = await siweServer.getSession(req, res); return createInnerTRPCContext({ session, + chain: ChainsById[session?.chainId || 1], }); }; diff --git a/packages/api/src/utils/wagmiClient.ts b/packages/api/src/utils/wagmiClient.ts new file mode 100644 index 0000000..214a662 --- /dev/null +++ b/packages/api/src/utils/wagmiClient.ts @@ -0,0 +1,7 @@ +import { createClient } from '@wagmi/core'; +import { provider, webSocketProvider } from 'shared/config/chains'; + +export const wagmiCoreClient = createClient({ + provider, + webSocketProvider, +}); diff --git a/packages/api/src/wearables/router.ts b/packages/api/src/wearables/router.ts new file mode 100644 index 0000000..2a82d3e --- /dev/null +++ b/packages/api/src/wearables/router.ts @@ -0,0 +1,100 @@ +import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; +import { z } from 'zod'; +import { readContract } from '@wagmi/core'; +import { BigNumber } from '@ethersproject/bignumber'; + +import { + NftWearablesAbi, + NftWearablesAddress, +} from 'contracts/abis/NftWearables'; +import { NftClaim } from './types'; +import { productNftMetadataSelector } from 'services/mfos/products/selectors'; +import { getMetadataForProduct } from 'shared/utils/wearableMetadata'; + +export const wearablesRouter = createTRPCRouter({ + merkleClaims: protectedProcedure.query(async ({ ctx }) => { + const res = await ctx.hasuraClient.query({ + robot_merkle_claims: [ + { + where: { + merkle_root: { network: { _eq: ctx.chain.network } }, + recipient_eth_address: { _eq: ctx.session.address }, + }, + }, + { + claim_json: [{}, true], + merkle_root_hash: true, + }, + ], + }); + + return res.robot_merkle_claims as NftClaim[]; + // return nftClaimArray.map((nftClaim) => { + // const claim_count = nftClaim.claim_json.erc1155[0].ids.length; + // + // return { + // ...nftClaim, + // claim_json: { + // ...nftClaim.claim_json, + // }, + // }; + // }); + }), + byAddress: publicProcedure + .input(z.string().optional()) + .query(async ({ ctx, input }) => { + const address = input || ctx.session?.address; + if (!address) return null; + + try { + const tokenIdRes = await ctx.mfosClient('query')({ + products: [ + { filter: { nft_token_id: { _nnull: true } } }, + { nft_token_id: true, id: true }, + ], + }); + + const allTokenIds = tokenIdRes.products.map((p) => + // Query filters non nulls + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + BigNumber.from(p.nft_token_id!), + ); + + const balances = await readContract({ + address: NftWearablesAddress[ctx.chain.id], + abi: NftWearablesAbi, + functionName: 'balanceOfBatch', + args: [Array(allTokenIds.length).fill(address), allTokenIds], + chainId: ctx.chain.id, + }); + const tokenIdsForUser = balances.reduce( + (ids: number[], balance, index) => { + if (balance.isZero()) return ids; + + const tokenId = allTokenIds[index].toNumber(); + return [...ids, tokenId]; + }, + [], + ); + + if (!tokenIdsForUser.length) return []; + + const nftMetadataRes = await ctx.mfosClient('query')({ + products: [ + { filter: { nft_token_id: { _in: tokenIdsForUser } } }, + productNftMetadataSelector, + ], + }); + + return (nftMetadataRes.products || []).map((p) => ({ + id: p.id, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + nft_token_id: p.nft_token_id!, + nft_metadata: getMetadataForProduct(p), + })); + } catch (e) { + console.log(e); + return []; + } + }), +}); diff --git a/packages/api/src/wearables/types.ts b/packages/api/src/wearables/types.ts new file mode 100644 index 0000000..ec23a4c --- /dev/null +++ b/packages/api/src/wearables/types.ts @@ -0,0 +1,34 @@ +import type { HexString } from 'shared/utils/stringHelpers'; + +export type NftItem = { + nft_token_id: number; + id: number; + nft_metadata: { + name: string; + image: string; + files: { uri: string; mimeType: string }[]; + properties: { + brand: string; + images: string[]; + }; + }; +}; + +export type NftClaim = { + claim_json: { + to: HexString; + erc1155: { + contractAddress: HexString; + ids: string[]; + values: number[]; + }[]; + erc721: never[]; + erc20: { + contractAddresses: never[]; + amounts: never[]; + }; + salt: HexString; + proof: HexString[]; + }; + merkle_root_hash: HexString; +}; diff --git a/packages/app/features/inventory/Inventory.tsx b/packages/app/features/inventory/Inventory.tsx new file mode 100644 index 0000000..d900c76 --- /dev/null +++ b/packages/app/features/inventory/Inventory.tsx @@ -0,0 +1,44 @@ +import { H3 } from 'app/ui/typography'; +import { Box } from 'app/ui/layout/Box'; +import { api } from 'app/lib/api'; +import { WearableCard } from 'app/ui/components/WearableCard'; +import { useAccount, useEnsName } from 'wagmi'; +import { formatAddress } from 'shared/utils/addressHelpers'; + +type InventoryProps = { + // address?: string; +}; + +export const Inventory: React.FC = () => { + const { address } = useAccount(); + const { data, isLoading } = api.wearables.byAddress.useQuery(address); + + const ensNameQuery = useEnsName({ + address, + }); + + return ( + +

{`${formatAddress(address, ensNameQuery.data)}'s Inventory`}

+ + {data ? ( + data.map((wearable) => { + return ( + + ); + }) + ) : ( +

{isLoading ? 'Loading' : 'No Wearables'}

+ )} +
+
+ ); +}; diff --git a/packages/app/features/inventory/useClaimWearables.ts b/packages/app/features/inventory/useClaimWearables.ts new file mode 100644 index 0000000..56e44ce --- /dev/null +++ b/packages/app/features/inventory/useClaimWearables.ts @@ -0,0 +1,73 @@ +import _ from 'lodash'; +import { + useContractRead, + usePrepareContractWrite, + useContractWrite, + mainnet, +} from 'wagmi'; +import { api } from 'app/lib/api'; +import { NftGiveawayAddress, NftGiveawayAbi } from 'contracts/abis/NftGiveaway'; +import { BigNumber } from 'ethers'; + +export const useClaimWearables = ({ address }: { address: `0x${string}` }) => { + const { data: wearableClaims, isLoading: wearableClaimsLoading } = + api.wearables.merkleClaims.useQuery(); + + const rootHashes = + wearableClaims?.map((nftClaim) => nftClaim.merkle_root_hash) || []; + const { data: claimedStatuses, isLoading: claimStatusLoading } = + useContractRead({ + abi: NftGiveawayAbi, + address: NftGiveawayAddress[mainnet.id], + functionName: 'getClaimedStatus', + args: [address, rootHashes], + enabled: Boolean(address && rootHashes.length), + }); + + const unclaimedWearableClaims = _.reduce( + claimedStatuses as boolean[], + ( + unclaimed: NonNullable, + currentValue: boolean, + currentIndex: number, + ) => { + if (currentValue || !wearableClaims) return unclaimed; + + const unclaimedNftClaim = wearableClaims[currentIndex]; + + if (unclaimedNftClaim) unclaimed.push(unclaimedNftClaim); + + return unclaimed; + }, + [], + ); + + const claimsJSON = _.map(wearableClaims, (nftClaim) => ({ + ...nftClaim.claim_json, + erc1155: nftClaim.claim_json.erc1155.map((nft) => ({ + ...nft, + // TODO: test if it works without converting to BigNumber + ids: nft.ids.map(BigNumber.from), + values: nft.values.map(BigNumber.from), + })), + })); + const merkleProofs = _.map( + wearableClaims, + (nftClaim) => nftClaim.claim_json.proof, + ); + + const { config } = usePrepareContractWrite({ + address: NftGiveawayAddress[mainnet.id], + abi: NftGiveawayAbi, + functionName: 'claimMultipleTokensFromMultipleMerkleTree', + args: [rootHashes, claimsJSON, merkleProofs], + }); + + const claimWearablesWrite = useContractWrite(config); + + return { + claimWearablesWrite, + unclaimedWearableClaims, + isLoading: claimStatusLoading || wearableClaimsLoading, + }; +}; diff --git a/packages/app/features/rewards/useClaims.ts b/packages/app/features/rewards/useClaims.ts index 169fff4..dbf4003 100644 --- a/packages/app/features/rewards/useClaims.ts +++ b/packages/app/features/rewards/useClaims.ts @@ -12,6 +12,8 @@ import { useContractRead, usePrepareContractWrite, useContractWrite, + mainnet, + useNetwork, } from 'wagmi'; import { BigNumber } from 'ethers'; import { @@ -22,7 +24,7 @@ import { formatNumber } from 'shared/utils/numberHelpers'; const useClaims = () => { const { address } = useAccount(); - + const { chain } = useNetwork(); const { data: claimWeeksData } = useQuery(['claimWeeks'], async () => { const claimWeeks = await getClaimWeeks(); @@ -37,7 +39,7 @@ const useClaims = () => { const { data: unclaimedWeeks } = useContractRead({ abi: MerkleRedeemABI, - address: MerkleRedeemAddress.mainnet, + address: MerkleRedeemAddress[chain?.id || mainnet.id], functionName: 'claimStatus', args: [ address || '0x', @@ -97,9 +99,9 @@ const useClaims = () => { }; }, [claimWeeksData, unclaimedWeeks, address]); - const { config, ...res } = usePrepareContractWrite({ + const { config, ...rest } = usePrepareContractWrite({ abi: MerkleRedeemABI, - address: MerkleRedeemAddress.mainnet, + address: MerkleRedeemAddress[mainnet.id], functionName: 'claimWeeks', args: [address || '0x', claimWeeksProofs], }); @@ -111,6 +113,7 @@ const useClaims = () => { claimedWeeksValues, unclaimedWeeksValues, claimRewardWrite, + ...rest, }; }; diff --git a/packages/app/features/rewards/useContributorRewards.tsx b/packages/app/features/rewards/useContributorRewards.ts similarity index 97% rename from packages/app/features/rewards/useContributorRewards.tsx rename to packages/app/features/rewards/useContributorRewards.ts index c9def66..6d7a3fc 100644 --- a/packages/app/features/rewards/useContributorRewards.tsx +++ b/packages/app/features/rewards/useContributorRewards.ts @@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { hasuraClient } from 'services/graphql/client'; import { order_by } from 'services/graphql/__generated__/zeus'; -import { formatNumber } from './utils/format'; +import { formatNumber } from 'shared/utils/numberHelpers'; export type DesignerReward = { robot_reward: number; diff --git a/packages/app/lib/ConnectWalletButton.native.tsx b/packages/app/lib/ConnectWalletButton.native.tsx deleted file mode 100644 index 67d24ff..0000000 --- a/packages/app/lib/ConnectWalletButton.native.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useWalletConnect } from 'app/lib/walletconnect'; -import { useAccount, useBalance, useConnect, useDisconnect } from 'wagmi'; -import { useEffect } from 'react'; -import { WalletConnectConnector } from 'wagmi/connectors/walletConnect'; -import { Button } from 'app/ui/input/Button'; -import { Text } from 'app/ui/typography'; - -export const ConnectWalletButton = () => { - const connector = useWalletConnect(); - - const { connect } = useConnect({ - connector: new WalletConnectConnector({ - options: { - qrcode: false, - connector, - }, - }), - }); - const { disconnect } = useDisconnect(); - - const { address } = useAccount(); - const { data: balance } = useBalance({ address }); - - useEffect(() => { - if (connector?.accounts?.length && !address) { - connect(); - } else { - disconnect(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [connector]); - - if (address) { - return ( - <> -