From 02e23560c509fd9fb09aad3d8ec56d8c675fe26b Mon Sep 17 00:00:00 2001 From: ink-victor <171172553+ink-victor@users.noreply.github.com> Date: Tue, 4 Mar 2025 16:58:23 -0500 Subject: [PATCH] feat: kraken verification module --- messages/en-US.json | 5 +- package.json | 1 + .../[locale]/verify/_components/VerifyCta.tsx | 106 ++++++++++++++++++ src/app/[locale]/verify/page.tsx | 18 +-- src/app/api/auth/callback/kraken/route.ts | 17 +++ src/app/api/auth/challenge/new/route.ts | 29 +++++ src/app/api/auth/challenge/solve/route.ts | 29 +++++ .../api/auth/verification/[address]/route.ts | 40 +++++++ src/env.ts | 1 + src/hooks/useAddressVerificationStatus.ts | 42 +++++++ src/hooks/useCreateChallenge.ts | 31 +++++ src/hooks/useSolveChallenge.ts | 44 ++++++++ 12 files changed, 348 insertions(+), 15 deletions(-) create mode 100644 src/app/[locale]/verify/_components/VerifyCta.tsx create mode 100644 src/app/api/auth/callback/kraken/route.ts create mode 100644 src/app/api/auth/challenge/new/route.ts create mode 100644 src/app/api/auth/challenge/solve/route.ts create mode 100644 src/app/api/auth/verification/[address]/route.ts create mode 100644 src/hooks/useAddressVerificationStatus.ts create mode 100644 src/hooks/useCreateChallenge.ts create mode 100644 src/hooks/useSolveChallenge.ts diff --git a/messages/en-US.json b/messages/en-US.json index 421fdec..25deb79 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -88,9 +88,10 @@ } }, "Verify": { - "title": "Get Verified and Start Your On-Chain Journey Today", - "description": "Ink Verify — Seamlessly authenticate your activity, unlock rewards, and explore the limitless potential of Ink.", + "title": "Onchain Verification", + "description": "Securely verify your Kraken identity on-chain to access exclusive benefits across the Ink ecosystem", "cta": "Connect to verify", + "proveIdentity": "Prove Identity", "whatIsVerify": { "title": "What is Ink Verify?", "description": "Ink Verify is your gateway to effortless cross chain attestations. With Ink Verify, you can easily validate your DeFi activity, NFT mints and Kraken account all with zero gas fees. Just connect any wallet, including Kraken Wallet, Rainbow, or MetaMask to get started." diff --git a/package.json b/package.json index 5e11a6f..4fe1be3 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "scripts": { "dev": "next dev --turbo", + "dev:https": "next dev --turbo --experimental-https", "build": "NODE_OPTIONS='--max_old_space_size=8192' next build", "start": "next start", "lint": "next lint", diff --git a/src/app/[locale]/verify/_components/VerifyCta.tsx b/src/app/[locale]/verify/_components/VerifyCta.tsx new file mode 100644 index 0000000..510166c --- /dev/null +++ b/src/app/[locale]/verify/_components/VerifyCta.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { ConnectWalletButton } from "@/components/ConnectWalletButton"; +import { useAddressVerificationStatus } from "@/hooks/useAddressVerificationStatus"; +import { useCreateChallenge } from "@/hooks/useCreateChallenge"; +import { useSolveChallenge } from "@/hooks/useSolveChallenge"; +import { Button } from "@inkonchain/ink-kit"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import type { FC } from "react"; +import { useAccount, useSignMessage } from "wagmi"; +import { useRouter } from "next/navigation"; + +interface VerifyCtaProps { + className?: string; +} + +export const VerifyCta: FC = ({ className }) => { + const t = useTranslations("Verify"); + const [isProving, setIsProving] = useState(false); + const { isConnected, address, isConnecting, isReconnecting } = useAccount(); + const { data: verificationStatus, isLoading: isCheckingVerification } = + useAddressVerificationStatus(address); + const createChallenge = useCreateChallenge(); + const { signMessageAsync } = useSignMessage(); + const solveChallenge = useSolveChallenge(); + const router = useRouter(); + + const isLoading = + isConnecting || + isReconnecting || + isCheckingVerification || + isProving || + createChallenge.isPending || + solveChallenge.isPending; + + const handleProveIdentity = async () => { + if (!address) return; + + try { + setIsProving(true); + + // Create challenge + const challenge = await createChallenge.mutateAsync({ + user_address: address, + }); + + // Sign the challenge message + const signature = await signMessageAsync({ + message: challenge.message, + }); + + // Solve challenge + const solution = await solveChallenge.mutateAsync({ + challenge_id: challenge.id, + challenge_signature: signature, + }); + + // Redirect to Kraken OAuth if challenge was solved + if (solution.solved) { + router.push(solution.oauth_url); + } + } catch (error) { + console.error("Error during verification:", error); + setIsProving(false); + } + }; + + // Loading skeleton with same height as final content + if (isLoading) { + return ( +
+
+
+ ); + } + + // Show verified state + if (verificationStatus?.isVerified) { + return ( +

+ ✓ Your address is verified +

+ ); + } + + // Show action buttons + return ( +
+ {isConnected ? ( + + ) : ( + + )} +
+ ); +}; + +VerifyCta.displayName = "VerifyCta"; diff --git a/src/app/[locale]/verify/page.tsx b/src/app/[locale]/verify/page.tsx index 2f87a6c..51969eb 100644 --- a/src/app/[locale]/verify/page.tsx +++ b/src/app/[locale]/verify/page.tsx @@ -1,14 +1,10 @@ import { useTranslations } from "next-intl"; -import { ConnectWalletButton } from "@/components/ConnectWalletButton"; import { OnlyWithFeatureFlag } from "@/components/OnlyWithFeatureFlag"; import { newLayoutContainerClasses } from "@/components/styles/container"; import { PageHeader } from "../_components/PageHeader"; - -import { Verifications } from "./_components/Verifications"; -import { VerifyContent } from "./_components/VerifyContent"; -import { VerifyHaveASuggestion } from "./_components/VerifyHaveASuggestion"; +import { VerifyCta } from "./_components/VerifyCta"; export default function VerifyPage() { const t = useTranslations("Verify"); @@ -18,15 +14,11 @@ export default function VerifyPage() { - -
- } + cta={} /> - - - + {/* */} + {/* */} + {/* */} ); diff --git a/src/app/api/auth/callback/kraken/route.ts b/src/app/api/auth/callback/kraken/route.ts new file mode 100644 index 0000000..5d339af --- /dev/null +++ b/src/app/api/auth/callback/kraken/route.ts @@ -0,0 +1,17 @@ +import { env } from "@/env"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + try { + const targetUrl = new URL( + `${env.INK_VERIFY_API_BASE_URL}/v1/auth/callback/kraken` + ); + targetUrl.search = new URL(request.url).searchParams.toString(); + + await fetch(targetUrl); + } catch (error) { + console.error("OAuth callback error:", error); + } + + return NextResponse.redirect(new URL("/verify?verifyPage=true", request.url)); +} diff --git a/src/app/api/auth/challenge/new/route.ts b/src/app/api/auth/challenge/new/route.ts new file mode 100644 index 0000000..aacdf3b --- /dev/null +++ b/src/app/api/auth/challenge/new/route.ts @@ -0,0 +1,29 @@ +import { env } from "@/env"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + + const response = await fetch( + `${env.INK_VERIFY_API_BASE_URL}/v1/auth/challenge/new`, + { + method: request.method, + headers: request.headers, + body: JSON.stringify(body), + } + ); + + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (error) { + console.error("Proxy error:", error); + return NextResponse.json( + { error: "Failed to proxy request" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/challenge/solve/route.ts b/src/app/api/auth/challenge/solve/route.ts new file mode 100644 index 0000000..9f2deb6 --- /dev/null +++ b/src/app/api/auth/challenge/solve/route.ts @@ -0,0 +1,29 @@ +import { env } from "@/env"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + + const response = await fetch( + `${env.INK_VERIFY_API_BASE_URL}/v1/auth/challenge/solve`, + { + method: request.method, + headers: request.headers, + body: JSON.stringify(body), + } + ); + + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (error) { + console.error("Proxy error:", error); + return NextResponse.json( + { error: "Failed to proxy request" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/verification/[address]/route.ts b/src/app/api/auth/verification/[address]/route.ts new file mode 100644 index 0000000..7083e20 --- /dev/null +++ b/src/app/api/auth/verification/[address]/route.ts @@ -0,0 +1,40 @@ +import { env } from "@/env"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +// We keep minimal validation just to ensure the address parameter exists +const RequestParamsSchema = z.object({ + address: z.string().min(1), +}); + +export async function GET( + request: Request, + { params }: { params: Promise<{ address: string }> } +) { + try { + // Minimal validation of the address parameter + const { address } = RequestParamsSchema.parse(await params); + + // Construct the target URL + const targetUrl = `${env.INK_VERIFY_API_BASE_URL}/v1/auth/verifications/${address}`; + + // Forward the request with all its original headers + const response = await fetch(targetUrl, { + method: request.method, + headers: request.headers, + }); + + // Return the response as-is + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } catch (error) { + console.error("Proxy error:", error); + return NextResponse.json( + { error: "Failed to proxy request" }, + { status: 500 } + ); + } +} diff --git a/src/env.ts b/src/env.ts index 62b4c05..ec7bed1 100644 --- a/src/env.ts +++ b/src/env.ts @@ -6,6 +6,7 @@ export const env = createEnv({ CI: z.string().optional(), SENTRY_AUTH_TOKEN: z.string().optional(), ORIGIN: z.string().min(1).default("inkonchain.com"), + INK_VERIFY_API_BASE_URL: z.string().url().default("http://localhost:8002"), BRAZE_INSTANCE_URL: z.string().url(), BRAZE_API_KEY: z.string().min(1), BRAZE_GENERAL_WAITLIST_GROUP_ID: z.string().min(1), diff --git a/src/hooks/useAddressVerificationStatus.ts b/src/hooks/useAddressVerificationStatus.ts new file mode 100644 index 0000000..21019ef --- /dev/null +++ b/src/hooks/useAddressVerificationStatus.ts @@ -0,0 +1,42 @@ +import { useQuery } from "@tanstack/react-query"; +import { z } from "zod"; + +const ResponseSchema = z.object({ + is_verified: z.boolean(), +}); + +type VerificationResponse = { + isVerified: boolean; +}; + +export function useAddressVerificationStatus(address: string | undefined) { + return useQuery({ + queryKey: ["verification-status", address], + queryFn: async () => { + if (!address) { + throw new Error("No address provided"); + } + + const response = await fetch(`/api/auth/verification/${address}`); + + if (!response.ok) { + throw new Error( + `Verification check failed: ${response.status} ${response.statusText}` + ); + } + + const data = await response.json(); + + // Validate and transform the response + const validated = ResponseSchema.parse(data); + return { + isVerified: validated.is_verified, + }; + }, + enabled: Boolean(address), + staleTime: 60 * 1000, + retry: 3, + refetchOnWindowFocus: true, + refetchOnMount: true, + }); +} diff --git a/src/hooks/useCreateChallenge.ts b/src/hooks/useCreateChallenge.ts new file mode 100644 index 0000000..faf0358 --- /dev/null +++ b/src/hooks/useCreateChallenge.ts @@ -0,0 +1,31 @@ +import { useMutation } from "@tanstack/react-query"; +import { z } from "zod"; + +export const ChallengeResponseSchema = z.object({ + id: z.string(), + user_address: z.string(), + message: z.string(), +}); + +export type Challenge = z.infer; + +export const useCreateChallenge = () => { + return useMutation({ + mutationFn: async (variables) => { + const response = await fetch("/api/auth/challenge/new", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(variables), + }); + + if (!response.ok) { + throw new Error("Failed to create challenge"); + } + + const data = await response.json(); + return ChallengeResponseSchema.parse(data); + }, + }); +}; diff --git a/src/hooks/useSolveChallenge.ts b/src/hooks/useSolveChallenge.ts new file mode 100644 index 0000000..225b67f --- /dev/null +++ b/src/hooks/useSolveChallenge.ts @@ -0,0 +1,44 @@ +import { useMutation } from "@tanstack/react-query"; +import { z } from "zod"; +import { parseSignature, type Hex } from "viem"; + +export const ChallengeSolutionResponseSchema = z.object({ + solved: z.boolean(), + oauth_url: z.string().url(), +}); + +export type ChallengeSolution = z.infer; + +export const useSolveChallenge = () => { + return useMutation< + ChallengeSolution, + Error, + { challenge_id: string; challenge_signature: Hex } + >({ + mutationFn: async (variables) => { + const { r, s, v } = parseSignature(variables.challenge_signature); + + const response = await fetch("/api/auth/challenge/solve", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + challenge_id: variables.challenge_id, + challenge_signature: { + r: r.toString(), + s: s.toString(), + v: v?.toString(), + }, + }), + }); + + if (!response.ok) { + throw new Error("Failed to solve challenge"); + } + + const data = await response.json(); + return ChallengeSolutionResponseSchema.parse(data); + }, + }); +};