Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web3 wallet connection #23

Merged
merged 2 commits into from Feb 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Expand Up @@ -11,4 +11,8 @@ POSTMARK_MARKETING_API_SECRET=""
PROTECTED_API_ROUTE_KEY=""
NEXT_PUBLIC_AXIOM_INGEST_ENDPOINT=""
AXIOM_TOKEN=""
AXIOM_DATASET="vercel"
AXIOM_DATASET="vercel"
NEXT_PUBLIC_SUPABASE_URL=""
NEXT_PUBLIC_SUPABASE_KEY=""
NEXT_PUBLIC_INFURA_KEY=""
NEXT_PUBLIC_ENABLE_TESTNETS="true"
13 changes: 13 additions & 0 deletions app/(dashboard)/dashboard/settings/wallet/loading.tsx
@@ -0,0 +1,13 @@
import { DashboardHeader } from "@/components/dashboard/header"
import { DashboardShell } from "@/components/dashboard/shell"
import { Card } from "@/ui/card"

export default function DashboardSettingsLoading() {
return (
<DashboardShell>
<div className="grid gap-10">
<Card.Skeleton />
</div>
</DashboardShell>
)
}
125 changes: 125 additions & 0 deletions app/(dashboard)/dashboard/settings/wallet/page.tsx
@@ -0,0 +1,125 @@
import { DashboardShell } from "@/components/dashboard/shell"
import { AddWalletForm } from "@/components/dashboard/settings/wallet/add-wallet-form"
import { notFound } from "next/navigation"
import { getCurrentUser } from "@/lib/session"
import { db } from "@/lib/db"
import { EmptyPlaceholder } from "@/components/dashboard/empty-placeholder"
import { WalletOperations } from "@/components/dashboard/settings/wallet/wallet-operations"

export default async function NotificationsPage() {
const user = await getCurrentUser()

if (!user) {
return notFound()
}

const dbUser = await db.user.findUnique({
where: {
id: user.id,
},
include: {
wallets: true,
},
})

if (!dbUser) return null

return (
<DashboardShell>
<div>
<AddWalletForm
user={{
id: dbUser.id,
}}
/>
<div className="mt-12 sm:flex sm:items-center">
<div className="sm:flex-auto">
<h3 className="text-xl font-semibold text-brandtext-500">
Wallets
</h3>
<p className="mt-2 text-sm text-brandtext-600">
You can add up to 10 wallets to your account. Set a
default wallet to be used to receive ERC-20 utility
token rewards for platform activity.
</p>
{dbUser.wallets?.length ? (
<div className="mt-4 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className=" min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-raised ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-raised-border">
<thead className="bg-raised">
<tr>
<th
scope="col"
className="whitespace-nowrap py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-brandtext-500 sm:pl-6"
>
Default
</th>
<th
scope="col"
className="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-brandtext-500"
>
Wallet
</th>
<th
scope="col"
className="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-brandtext-500"
></th>
</tr>
</thead>
<tbody className="divide-y divide-raised-border bg-raised">
{dbUser?.wallets?.map(
(wallet) => (
<tr key={wallet.id}>
<td className="inline-flex items-center gap-2 whitespace-nowrap py-3 pl-4 pr-3 text-sm text-brandtext-600 sm:pl-6">
{wallet.default
? "True"
: "False"}
</td>
<td className="whitespace-nowrap px-2 py-3 text-sm font-medium text-brandtext-600">
{
wallet.address
}
</td>
<td>
<WalletOperations
user={{
id: user.id,
}}
userWallet={{
id: wallet.id,
default:
wallet.default,
}}
/>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
) : (
<>
<EmptyPlaceholder className="mt-4 min-h-[200px]">
<EmptyPlaceholder.Icon name="eth" />
<EmptyPlaceholder.Title>
No wallets added
</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description className="mb-0">
There are no wallets associated with
this account.
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
</>
)}
</div>
</div>
</div>
</DashboardShell>
)
}
13 changes: 9 additions & 4 deletions app/layout.tsx
Expand Up @@ -7,6 +7,9 @@ import { SiteFooter } from "@/components/site-footer"
import { AnalyticsWrapper } from "@/components/analytics"
import { Providers } from "./providers"
export { reportWebVitals } from "next-axiom"
import { Web3Providers } from "./web3Providers"
import "@rainbow-me/rainbowkit/styles.css"
import Script from "next/script"

const satoshi = localFont({
src: "./Satoshi-Variable.woff2",
Expand Down Expand Up @@ -42,10 +45,12 @@ export default function RootLayout({
*/}
<head />
<body className="min-h-screen">
{children}
<Toaster position="bottom-right" />
<SiteFooter />
<AnalyticsWrapper />
<Web3Providers>
{children}
<Toaster position="bottom-right" />
<SiteFooter />
<AnalyticsWrapper />
</Web3Providers>
</body>
</html>
</Providers>
Expand Down
14 changes: 14 additions & 0 deletions app/web3Providers.tsx
@@ -0,0 +1,14 @@
"use client"

import { chains, wagmiClient } from "@/lib/wagmiClient"
import { RainbowKitProvider } from "@rainbow-me/rainbowkit"
import { PropsWithChildren } from "react"
import { WagmiConfig } from "wagmi"

export function Web3Providers({ children }: PropsWithChildren) {
return (
<WagmiConfig client={wagmiClient}>
<RainbowKitProvider chains={chains}>{children}</RainbowKitProvider>
</WagmiConfig>
)
}
186 changes: 186 additions & 0 deletions components/dashboard/settings/wallet/add-wallet-form.tsx
@@ -0,0 +1,186 @@
"use client"

import * as React from "react"
import { SiweMessage } from "siwe"
import { trpc } from "@/client/trpcClient"
import { Icons } from "@/components/icons"
import { cn } from "@/lib/utils"
import { userNotificationsSchema } from "@/lib/validations/user"
import { Button } from "@/ui/button"
import { Card } from "@/ui/card"
import { Checkbox } from "@/ui/checkbox"
import { toast } from "@/ui/toast"
import { zodResolver } from "@hookform/resolvers/zod"
import { User } from "@prisma/client"
import { useRouter } from "next/navigation"
import { FormProvider, useForm } from "react-hook-form"
import { z } from "zod"
import { ConnectWallet } from "./conect-wallet"
import { useAccount } from "wagmi"
import { Chip } from "@/ui/chip"
import { useFetchNonce } from "@/hooks/use-fetch-nonce"
import { useNetwork } from "wagmi"
import { useSignMessage } from "wagmi"

type FormData = z.infer<typeof userNotificationsSchema>

interface AddWalletFormProps extends React.HTMLAttributes<HTMLFormElement> {
user: Pick<User, "id">
}

export function AddWalletForm({
className,
user,
...props
}: AddWalletFormProps) {
const { isConnected, address } = useAccount()
const { chain } = useNetwork()
const { signMessageAsync } = useSignMessage()

const saveNotificationPreferences =
trpc.user.saveNotificationPreferences.useMutation()

const router = useRouter()

const methods = useForm<FormData>({
resolver: zodResolver(userNotificationsSchema),
})

const [state, setState] = React.useState<{
loading?: boolean
nonce?: string
}>({})

const fetchNonce = async () => {
try {
const nonceRes = await fetch("/api/nonce")
const nonce = await nonceRes.text()
setState((x) => ({ ...x, nonce }))
} catch (error) {
setState((x) => ({ ...x, error: error as Error }))
}
}

// Pre-fetch random nonce when button is rendered
// to ensure deep linking works for WalletConnect
// users on iOS when signing the SIWE message
React.useEffect(() => {
fetchNonce()
}, [])

async function onSubmit(data: FormData) {
try {
await saveNotificationPreferences.mutateAsync({
submissionAccepted: data.submissionAccepted,
newSubmission: data.newSubmission,
})
toast({
title: "Notification preferences updated",
message: "",
type: "success",
})
router.refresh()
} catch (e) {
return toast({
title: "Something went wrong.",
message: "Please refresh the page and try again.",
type: "error",
})
}
}

const signIn = async () => {
try {
const chainId = chain?.id

if (!address || !chainId) return

// Create SIWE message with pre-fetched nonce and sign with wallet
const message = new SiweMessage({
domain: window.location.host,
address,
statement: "Sign in with Ethereum to the app.",
uri: window.location.origin,
version: "1",
chainId,
nonce: state.nonce,
})

const signature = await signMessageAsync({
message: message.prepareMessage(),
})

// Verify signature
const verifyRes = await fetch(`/api/verify?userId=${user.id}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ message, signature }),
})
if (!verifyRes.ok) throw new Error("Error verifying message")

router.refresh()
return toast({
title: "Wallet linked",
message:
"You can now interact with all the blockchain elements on our platform!",
type: "success",
})
} catch (error) {
return toast({
title: "Something went wrong.",
message: "Please refresh the page and try again.",
type: "error",
})
}
}

return (
<FormProvider {...methods}>
<form
className={cn(className)}
onSubmit={methods.handleSubmit(onSubmit)}
{...props}
>
<Card>
<Card.Header>
<Card.Title className="flex items-center gap-2">
<Icons.eth />
Crypto Wallet{" "}
{isConnected ? (
<Chip size="small" intent="green">
Connected
</Chip>
) : (
<Chip size="small" intent="rose">
Not connected
</Chip>
)}
</Card.Title>
<Card.Description>
Connect your wallet to your Vamp account. This lets
you participate in our on-chain, subsidized
blockchain game on the Polygon network.
</Card.Description>
</Card.Header>
<Card.Content className="flex flex-col items-start gap-8 pb-4">
{isConnected ? (
<ConnectWallet />
) : (
"Connect your wallet"
)}
</Card.Content>
<Card.Footer className="flex flex-col items-start space-y-2 md:flex-row md:justify-between md:space-x-0">
{!isConnected && <ConnectWallet />}
{isConnected && (
<Button onClick={signIn}>
Sign message to link Wallet
</Button>
)}
</Card.Footer>
</Card>
</form>
</FormProvider>
)
}