Skip to content

Commit

Permalink
feat: kraken verification module
Browse files Browse the repository at this point in the history
  • Loading branch information
ink-victor committed Mar 5, 2025
1 parent 6a05f6f commit 02e2356
Show file tree
Hide file tree
Showing 12 changed files with 348 additions and 15 deletions.
5 changes: 3 additions & 2 deletions messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions src/app/[locale]/verify/_components/VerifyCta.tsx
Original file line number Diff line number Diff line change
@@ -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<VerifyCtaProps> = ({ 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 (
<div className={`relative w-80 ${className ?? ""}`}>
<div className="h-12 animate-pulse rounded-full bg-whiteMagic dark:bg-gray-800" />
</div>
);
}

// Show verified state
if (verificationStatus?.isVerified) {
return (
<p className="text-center text-lg font-medium text-green-600">
✓ Your address is verified
</p>
);
}

// Show action buttons
return (
<div className={`relative w-full ${className ?? ""}`}>
{isConnected ? (
<Button
size="lg"
variant="primary"
onClick={handleProveIdentity}
disabled={isLoading}
>
{t("proveIdentity")}
</Button>
) : (
<ConnectWalletButton connectLabel={t("cta")} size="lg" />
)}
</div>
);
};

VerifyCta.displayName = "VerifyCta";
18 changes: 5 additions & 13 deletions src/app/[locale]/verify/page.tsx
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -18,15 +14,11 @@ export default function VerifyPage() {
<PageHeader
title={t("title")}
description={t("description")}
cta={
<div className="relative w-full">
<ConnectWalletButton connectLabel={t("cta")} size="lg" />
</div>
}
cta={<VerifyCta />}
/>
<VerifyContent />
<Verifications />
<VerifyHaveASuggestion />
{/* <VerifyContent /> */}
{/* <Verifications /> */}
{/* <VerifyHaveASuggestion /> */}
</div>
</OnlyWithFeatureFlag>
);
Expand Down
17 changes: 17 additions & 0 deletions src/app/api/auth/callback/kraken/route.ts
Original file line number Diff line number Diff line change
@@ -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));
}
29 changes: 29 additions & 0 deletions src/app/api/auth/challenge/new/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
29 changes: 29 additions & 0 deletions src/app/api/auth/challenge/solve/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
40 changes: 40 additions & 0 deletions src/app/api/auth/verification/[address]/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
1 change: 1 addition & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
42 changes: 42 additions & 0 deletions src/hooks/useAddressVerificationStatus.ts
Original file line number Diff line number Diff line change
@@ -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<VerificationResponse, Error>({
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,
});
}
31 changes: 31 additions & 0 deletions src/hooks/useCreateChallenge.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ChallengeResponseSchema>;

export const useCreateChallenge = () => {
return useMutation<Challenge, Error, { user_address: string }>({
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);
},
});
};
Loading

0 comments on commit 02e2356

Please sign in to comment.