├── .eslintrc.json
├── .env.template
├── public
├── favicon.ico
├── vercel.svg
└── next.svg
├── postcss.config.js
├── src
├── lib
│ └── utils.ts
├── pages
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── api
│ │ ├── hello.ts
│ │ └── auth
│ │ │ ├── crossmark
│ │ │ ├── hash.ts
│ │ │ └── checksign.ts
│ │ │ ├── xumm
│ │ │ ├── createpayload.ts
│ │ │ ├── getpayload.ts
│ │ │ └── checksign.ts
│ │ │ ├── gem
│ │ │ ├── nonce.ts
│ │ │ └── checksign.ts
│ │ │ └── index.ts
│ └── index.tsx
├── components
│ └── ui
│ │ ├── skeleton.tsx
│ │ ├── button.tsx
│ │ └── drawer.tsx
├── styles
│ └── globals.css
└── hooks
│ └── useWallet.ts
├── next.config.mjs
├── components.json
├── .gitignore
├── tsconfig.json
├── package.json
├── tailwind.config.ts
└── README.md
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | XUMM_KEY=""
2 | XUMM_KEY_SECRET=""
3 | ENC_KEY="SomeSuperSecretKey"
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Aaditya-T/xrpl-wallet-connect/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import "@/styles/globals.css";
2 | import type { AppProps } from "next/app";
3 |
4 | export default function App({ Component, pageProps }: AppProps) {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | images: {
5 | domains: ['github.githubassets.com','xumm.app'],
6 | },
7 | };
8 |
9 | export default nextConfig;
10 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from "next/document";
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/pages/api/hello.ts:
--------------------------------------------------------------------------------
1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
2 | import type { NextApiRequest, NextApiResponse } from "next";
3 |
4 | type Data = {
5 | name: string;
6 | };
7 |
8 | export default function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse,
11 | ) {
12 | res.status(200).json({ name: "John Doe" });
13 | }
14 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/styles/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | .env
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "paths": {
16 | "@/*": ["./src/*"]
17 | }
18 | },
19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/api/auth/crossmark/hash.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import crypto from "crypto";
3 |
4 | interface HashResponse {
5 | hash?: string;
6 | error?: string;
7 | }
8 |
9 | function generateSecureRandomHash(): string {
10 | // Generate a random buffer (16 bytes)
11 | const randomBuffer = crypto.randomBytes(16);
12 | // Create a SHA-256 hash of the random buffer
13 | const sha256Hash = crypto.createHash("sha256").update(randomBuffer as unknown as crypto.BinaryLike).digest("hex");
14 | return sha256Hash;
15 | }
16 |
17 | export default async function handler(
18 | req: NextApiRequest,
19 | res: NextApiResponse
20 | ) {
21 | try {
22 | const hash = generateSecureRandomHash();
23 | return res.status(200).json({ hash });
24 | } catch (error) {
25 | console.error("Hash generation error:", error);
26 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
27 | return res.status(400).json({ error: errorMessage });
28 | }
29 | }
30 |
31 |
--------------------------------------------------------------------------------
/src/pages/api/auth/xumm/createpayload.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import { XummSdk } from "xumm-sdk";
3 |
4 | interface CreatePayloadResponse {
5 | payload?: any;
6 | error?: string;
7 | }
8 |
9 | export default async function handler(
10 | req: NextApiRequest,
11 | res: NextApiResponse
12 | ) {
13 | try {
14 | if (!process.env.XUMM_KEY || !process.env.XUMM_KEY_SECRET) {
15 | return res.status(500).json({ error: "XUMM API keys not configured" });
16 | }
17 |
18 | const xumm = new XummSdk(
19 | process.env.XUMM_KEY,
20 | process.env.XUMM_KEY_SECRET
21 | );
22 |
23 | const signInPayload = {
24 | TransactionType: "SignIn" as const,
25 | };
26 |
27 | const payload = await xumm.payload.create(signInPayload, true);
28 | return res.status(200).json({ payload });
29 | } catch (error) {
30 | console.error("XUMM payload creation error:", error);
31 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
32 | return res.status(400).json({ error: errorMessage });
33 | }
34 | }
35 |
36 |
--------------------------------------------------------------------------------
/src/pages/api/auth/xumm/getpayload.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import { XummSdk } from "xumm-sdk";
3 |
4 | interface GetPayloadResponse {
5 | payload?: any;
6 | error?: string;
7 | }
8 |
9 | export default async function handler(
10 | req: NextApiRequest,
11 | res: NextApiResponse
12 | ) {
13 | try {
14 | const payloadId = req.query.payloadId as string;
15 |
16 | if (!payloadId) {
17 | return res.status(400).json({ error: "payloadId is required" });
18 | }
19 |
20 | if (!process.env.XUMM_KEY || !process.env.XUMM_KEY_SECRET) {
21 | return res.status(500).json({ error: "XUMM API keys not configured" });
22 | }
23 |
24 | const xumm = new XummSdk(
25 | process.env.XUMM_KEY,
26 | process.env.XUMM_KEY_SECRET
27 | );
28 |
29 | const payload = await xumm.payload.get(payloadId);
30 | return res.status(200).json({ payload });
31 | } catch (error) {
32 | console.error("XUMM payload retrieval error:", error);
33 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
34 | return res.status(400).json({ error: errorMessage });
35 | }
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/src/pages/api/auth/gem/nonce.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import jwt from "jsonwebtoken";
3 |
4 | interface NonceResponse {
5 | token?: string;
6 | error?: string;
7 | }
8 |
9 | export default async function handler(
10 | req: NextApiRequest,
11 | res: NextApiResponse
12 | ) {
13 | try {
14 | const pubkey = req.query.pubkey as string;
15 | const address = req.query.address as string;
16 |
17 | if (!pubkey || !address) {
18 | return res.status(400).json({ error: "pubkey and address are required" });
19 | }
20 |
21 | if (!process.env.ENC_KEY) {
22 | return res.status(500).json({ error: "Server configuration error" });
23 | }
24 |
25 | // Generate nonce token with 1 hour expiration
26 | const token = jwt.sign(
27 | { public_key: pubkey, address },
28 | process.env.ENC_KEY,
29 | { expiresIn: "1h" }
30 | );
31 |
32 | return res.status(200).json({ token });
33 | } catch (error) {
34 | console.error("Nonce generation error:", error);
35 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
36 | return res.status(400).json({ error: errorMessage });
37 | }
38 | }
39 |
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "xrpl-template",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@crossmarkio/sdk": "^0.3.7",
13 | "@gemwallet/api": "^3.7.0",
14 | "@radix-ui/react-dialog": "^1.0.5",
15 | "@radix-ui/react-slot": "^1.0.2",
16 | "class-variance-authority": "^0.7.0",
17 | "clsx": "^2.1.0",
18 | "jsonwebtoken": "^9.0.2",
19 | "lucide-react": "^0.344.0",
20 | "next": "14.1.1",
21 | "react": "^18",
22 | "react-cookie": "^7.1.0",
23 | "react-dom": "^18",
24 | "ripple-keypairs": "^2.0.0",
25 | "tailwind-merge": "^2.2.1",
26 | "tailwindcss-animate": "^1.0.7",
27 | "vaul": "^0.9.0",
28 | "verify-xrpl-signature": "^4.1.5",
29 | "xumm-sdk": "^1.11.1"
30 | },
31 | "devDependencies": {
32 | "@types/jsonwebtoken": "^9.0.10",
33 | "@types/node": "^20",
34 | "@types/react": "^18",
35 | "@types/react-dom": "^18",
36 | "autoprefixer": "^10.0.1",
37 | "eslint": "^8",
38 | "eslint-config-next": "14.1.1",
39 | "postcss": "^8",
40 | "tailwindcss": "^3.3.0",
41 | "typescript": "^5"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/pages/api/auth/index.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import jwt from "jsonwebtoken";
3 |
4 | interface AuthResponse {
5 | xrpAddress?: string;
6 | error?: string;
7 | }
8 |
9 | export default async function handler(
10 | req: NextApiRequest,
11 | res: NextApiResponse
12 | ) {
13 | try {
14 | if (req.method !== "POST") {
15 | return res.status(405).json({ error: "Only POST requests allowed" });
16 | }
17 |
18 | const token = req.body?.token;
19 | if (!token) {
20 | return res.status(400).json({ error: "Token is required" });
21 | }
22 |
23 | if (!process.env.ENC_KEY) {
24 | return res.status(500).json({ error: "Server configuration error" });
25 | }
26 |
27 | const decoded = jwt.verify(token, process.env.ENC_KEY) as { xrpAddress?: string };
28 |
29 | if (!decoded.xrpAddress) {
30 | return res.status(400).json({ error: "Invalid token payload" });
31 | }
32 |
33 | return res.status(200).json({ xrpAddress: decoded.xrpAddress });
34 | } catch (error) {
35 | if (error instanceof jwt.JsonWebTokenError) {
36 | return res.status(401).json({ error: "Invalid token" });
37 | }
38 | if (error instanceof jwt.TokenExpiredError) {
39 | return res.status(401).json({ error: "Token expired" });
40 | }
41 |
42 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
43 | return res.status(400).json({ error: errorMessage });
44 | }
45 | }
46 |
47 |
--------------------------------------------------------------------------------
/src/pages/api/auth/xumm/checksign.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import { verifySignature } from "verify-xrpl-signature";
3 | import jwt from "jsonwebtoken";
4 |
5 | interface CheckSignResponse {
6 | xrpAddress?: string;
7 | token?: string;
8 | error?: string;
9 | }
10 |
11 | export default function handler(
12 | req: NextApiRequest,
13 | res: NextApiResponse
14 | ) {
15 | try {
16 | const hex = req.query.hex as string;
17 |
18 | if (!hex) {
19 | return res.status(400).json({ error: "hex parameter is required" });
20 | }
21 |
22 | if (!process.env.ENC_KEY) {
23 | return res.status(500).json({ error: "Server configuration error" });
24 | }
25 |
26 | const resp = verifySignature(hex);
27 |
28 | if (resp.signatureValid !== true) {
29 | return res.status(400).json({ error: "Invalid signature" });
30 | }
31 |
32 | const xrpAddress = resp.signedBy;
33 | if (!xrpAddress) {
34 | return res.status(400).json({ error: "Could not extract address from signature" });
35 | }
36 |
37 | // Add expiration to JWT token (7 days)
38 | const token = jwt.sign(
39 | { xrpAddress },
40 | process.env.ENC_KEY,
41 | { expiresIn: "7d" }
42 | );
43 |
44 | return res.status(200).json({ xrpAddress, token });
45 | } catch (error) {
46 | console.error("Signature verification error:", error);
47 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
48 | return res.status(400).json({ error: errorMessage });
49 | }
50 | }
51 |
52 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 222.2 84% 4.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 222.2 84% 4.9%;
15 |
16 | --primary: 222.2 47.4% 11.2%;
17 | --primary-foreground: 210 40% 98%;
18 |
19 | --secondary: 210 40% 96.1%;
20 | --secondary-foreground: 222.2 47.4% 11.2%;
21 |
22 | --muted: 210 40% 96.1%;
23 | --muted-foreground: 215.4 16.3% 46.9%;
24 |
25 | --accent: 210 40% 96.1%;
26 | --accent-foreground: 222.2 47.4% 11.2%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 210 40% 98%;
30 |
31 | --border: 214.3 31.8% 91.4%;
32 | --input: 214.3 31.8% 91.4%;
33 | --ring: 222.2 84% 4.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 222.2 84% 4.9%;
40 | --foreground: 210 40% 98%;
41 |
42 | --card: 222.2 84% 4.9%;
43 | --card-foreground: 210 40% 98%;
44 |
45 | --popover: 222.2 84% 4.9%;
46 | --popover-foreground: 210 40% 98%;
47 |
48 | --primary: 210 40% 98%;
49 | --primary-foreground: 222.2 47.4% 11.2%;
50 |
51 | --secondary: 217.2 32.6% 17.5%;
52 | --secondary-foreground: 210 40% 98%;
53 |
54 | --muted: 217.2 32.6% 17.5%;
55 | --muted-foreground: 215 20.2% 65.1%;
56 |
57 | --accent: 217.2 32.6% 17.5%;
58 | --accent-foreground: 210 40% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 210 40% 98%;
62 |
63 | --border: 217.2 32.6% 17.5%;
64 | --input: 217.2 32.6% 17.5%;
65 | --ring: 212.7 26.8% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/pages/api/auth/crossmark/checksign.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import jwt from "jsonwebtoken";
3 | import * as rippleKP from "ripple-keypairs";
4 |
5 | interface CheckSignResponse {
6 | token?: string;
7 | address?: string;
8 | error?: string;
9 | }
10 |
11 | interface RequestBody {
12 | pubkey?: string;
13 | address?: string;
14 | }
15 |
16 | export default async function handler(
17 | req: NextApiRequest,
18 | res: NextApiResponse
19 | ) {
20 | try {
21 | if (req.method !== "POST") {
22 | return res.status(405).json({ error: "Only POST requests allowed" });
23 | }
24 |
25 | const authHeader = req.headers.authorization;
26 | const hash = authHeader?.split(" ")[1];
27 |
28 | if (!hash) {
29 | return res.status(401).json({ error: "Unauthorized" });
30 | }
31 |
32 | const signature = req.query.signature as string;
33 | if (!signature) {
34 | return res.status(400).json({ error: "signature parameter is required" });
35 | }
36 |
37 | const body = req.body as RequestBody;
38 | const { pubkey: public_key, address } = body;
39 |
40 | if (!public_key || !address) {
41 | return res.status(400).json({ error: "pubkey and address are required in request body" });
42 | }
43 |
44 | if (!process.env.ENC_KEY) {
45 | return res.status(500).json({ error: "Server configuration error" });
46 | }
47 |
48 | const isVerified = rippleKP.verify(hash, signature, public_key);
49 |
50 | if (!isVerified) {
51 | return res.status(400).json({ error: "Signature not verified" });
52 | }
53 |
54 | // Generate JWT with expiration (7 days)
55 | const token = jwt.sign(
56 | { xrpAddress: address },
57 | process.env.ENC_KEY,
58 | { expiresIn: "7d" }
59 | );
60 |
61 | return res.status(200).json({ token, address });
62 | } catch (error) {
63 | console.error("Crossmark signature verification error:", error);
64 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
65 | return res.status(400).json({ error: errorMessage });
66 | }
67 | }
68 |
69 |
--------------------------------------------------------------------------------
/src/pages/api/auth/gem/checksign.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from "next";
2 | import jwt from "jsonwebtoken";
3 | import * as rippleKP from "ripple-keypairs";
4 |
5 | interface CheckSignResponse {
6 | token?: string;
7 | address?: string;
8 | error?: string;
9 | }
10 |
11 | export default async function handler(
12 | req: NextApiRequest,
13 | res: NextApiResponse
14 | ) {
15 | try {
16 | const authHeader = req.headers.authorization;
17 | const token = authHeader?.split(" ")[1];
18 |
19 | if (!token) {
20 | return res.status(401).json({ error: "Unauthorized" });
21 | }
22 |
23 | if (!process.env.ENC_KEY) {
24 | return res.status(500).json({ error: "Server configuration error" });
25 | }
26 |
27 | const decoded = jwt.verify(token, process.env.ENC_KEY) as {
28 | public_key?: string;
29 | address?: string;
30 | };
31 |
32 | if (!decoded.public_key || !decoded.address) {
33 | return res.status(400).json({ error: "Invalid token payload" });
34 | }
35 |
36 | const signature = req.query.signature as string;
37 | if (!signature) {
38 | return res.status(400).json({ error: "signature parameter is required" });
39 | }
40 |
41 | const tokenHex = Buffer.from(token, "utf8").toString("hex");
42 | const isVerified = rippleKP.verify(
43 | tokenHex,
44 | signature,
45 | decoded.public_key
46 | );
47 |
48 | if (!isVerified) {
49 | return res.status(400).json({ error: "Signature not verified" });
50 | }
51 |
52 | // Generate JWT with expiration (7 days)
53 | const authToken = jwt.sign(
54 | { xrpAddress: decoded.address },
55 | process.env.ENC_KEY,
56 | { expiresIn: "7d" }
57 | );
58 |
59 | return res.status(200).json({ token: authToken, address: decoded.address });
60 | } catch (error) {
61 | if (error instanceof jwt.JsonWebTokenError) {
62 | return res.status(401).json({ error: "Invalid token" });
63 | }
64 | if (error instanceof jwt.TokenExpiredError) {
65 | return res.status(401).json({ error: "Token expired" });
66 | }
67 |
68 | console.error("GEM signature verification error:", error);
69 | const errorMessage = error instanceof Error ? error.message : "Unknown error";
70 | return res.status(400).json({ error: errorMessage });
71 | }
72 | }
73 |
74 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | # Welcome to wallet connect template for xrpl!
4 |
5 | This is a template for a wallet connect app for the xrpl. It is built with next.js, shadcn and tailwind css!
6 |
7 | ## Which wallets are supported?
8 |
9 | This template supports the following wallets:
10 |
11 | - [XUMM](https://xumm.app/)
12 | - [GEM](https://gemwallet.app/)
13 | - [CROSSMARK](https://crossmark.io/)
14 |
15 | ## How to use this template?
16 | > Note: This template comes with JWT authentication.
17 |
18 | To use this template, you can clone this repository and start building your app. You can also use this template to create a new starter project on xrpl!
19 |
20 | To get started, first head over to xumm dev portal [here](https://apps.xumm.dev/) and get your api keys and use the `.env.template` file as a reference to create a `.env` file with your api keys.
21 |
22 | Dont forget to also pass in a `ENC_KEY` in your `.env` file. This is the key that will be used to encrypt the user's address to store in cookies.
23 |
24 | # Future update plans!
25 |
26 | - [ ] Add support for wallet connect.
27 | - [ ] Integrate next-auth for authentication.
28 | - [ ] Make it an npm package!
29 |
30 |
31 | # Next.js readme
32 |
33 | ## Getting Started
34 |
35 | First, run the development server:
36 |
37 | ```bash
38 | npm run dev
39 | # or
40 | yarn dev
41 | # or
42 | pnpm dev
43 | # or
44 | bun dev
45 | ```
46 |
47 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
48 |
49 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
50 |
51 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
52 |
53 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
54 |
55 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
56 |
57 | ## Learn More
58 |
59 | To learn more about Next.js, take a look at the following resources:
60 |
61 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
62 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
63 |
64 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
65 |
66 | ## Deploy on Vercel
67 |
68 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
69 |
70 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
71 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Drawer as DrawerPrimitive } from "vaul"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Drawer = ({
7 | shouldScaleBackground = true,
8 | ...props
9 | }: React.ComponentProps) => (
10 |
14 | )
15 | Drawer.displayName = "Drawer"
16 |
17 | const DrawerTrigger = DrawerPrimitive.Trigger
18 |
19 | const DrawerPortal = DrawerPrimitive.Portal
20 |
21 | const DrawerClose = DrawerPrimitive.Close
22 |
23 | const DrawerOverlay = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
34 |
35 | const DrawerContent = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, children, ...props }, ref) => (
39 |
40 |
41 |
49 |
50 | {children}
51 |
52 |
53 | ))
54 | DrawerContent.displayName = "DrawerContent"
55 |
56 | const DrawerHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
64 | )
65 | DrawerHeader.displayName = "DrawerHeader"
66 |
67 | const DrawerFooter = ({
68 | className,
69 | ...props
70 | }: React.HTMLAttributes) => (
71 |
75 | )
76 | DrawerFooter.displayName = "DrawerFooter"
77 |
78 | const DrawerTitle = React.forwardRef<
79 | React.ElementRef,
80 | React.ComponentPropsWithoutRef
81 | >(({ className, ...props }, ref) => (
82 |
90 | ))
91 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
92 |
93 | const DrawerDescription = React.forwardRef<
94 | React.ElementRef,
95 | React.ComponentPropsWithoutRef
96 | >(({ className, ...props }, ref) => (
97 |
102 | ))
103 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
104 |
105 | export {
106 | Drawer,
107 | DrawerPortal,
108 | DrawerOverlay,
109 | DrawerTrigger,
110 | DrawerClose,
111 | DrawerContent,
112 | DrawerHeader,
113 | DrawerFooter,
114 | DrawerTitle,
115 | DrawerDescription,
116 | }
117 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { Inter } from "next/font/google";
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Drawer,
6 | DrawerContent,
7 | DrawerDescription,
8 | DrawerHeader,
9 | DrawerTitle,
10 | DrawerTrigger,
11 | } from "@/components/ui/drawer";
12 | import { Skeleton } from "@/components/ui/skeleton";
13 | import { useWallet } from "@/hooks/useWallet";
14 | import { useState, useEffect } from "react";
15 |
16 | const inter = Inter({ subsets: ["latin"] });
17 |
18 | export default function Home() {
19 | const [enableJwt, setEnableJwt] = useState(false);
20 | const [isMobile, setIsMobile] = useState(false);
21 |
22 | const {
23 | xrpAddress,
24 | isLoading,
25 | error,
26 | xummQrCode,
27 | xummJumpLink,
28 | connectXUMM,
29 | connectGEM,
30 | connectCrossmark,
31 | disconnect,
32 | isRetrieved,
33 | } = useWallet(enableJwt);
34 |
35 | useEffect(() => {
36 | const checkMobile = () => {
37 | setIsMobile(window.innerWidth < 768);
38 | };
39 | checkMobile();
40 | window.addEventListener("resize", checkMobile);
41 | return () => window.removeEventListener("resize", checkMobile);
42 | }, []);
43 |
44 | return (
45 |
48 |
49 |
50 | Welcome to XRPL wallet connect template!
51 |
52 |
53 | This is a template for creating a wallet connect app with XRPL. Includes basic JWT authentication and 3 different wallet types.
54 |
55 |
61 |
68 | Crafted by Aaditya (A.K.A Ghost!)
69 |
70 | {error && (
71 |
72 |
Error:
73 |
{error}
74 |
75 | )}
76 |
77 |
78 |
83 | {isLoading ? "Connecting..." : "Connect with XAMAN"}
84 |
85 |
86 |
87 | Scan this QR code to sign in with Xaman!
88 |
89 |
90 | {xummQrCode !== "" ? (
91 |
98 | ) : (
99 |
100 |
101 |
102 | )}
103 | {xummJumpLink !== "" && (
104 |
112 | )}
113 |
114 |
115 |
116 |
117 |
124 |
125 |
132 |
133 |
134 | setEnableJwt(!enableJwt)}
140 | />
141 |
144 |
145 |
146 |
147 | {xrpAddress !== "" && (
148 |
174 | )}
175 |
176 |
177 |
190 |
191 |
192 |
193 |
194 | );
195 | }
196 |
--------------------------------------------------------------------------------
/src/hooks/useWallet.ts:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useRef, useEffect } from "react";
2 | import { useCookies } from "react-cookie";
3 | import { isInstalled, getPublicKey, signMessage } from "@gemwallet/api";
4 | import sdk from "@crossmarkio/sdk";
5 |
6 | interface UseWalletReturn {
7 | xrpAddress: string;
8 | isLoading: boolean;
9 | error: string | null;
10 | xummQrCode: string;
11 | xummJumpLink: string;
12 | connectXUMM: () => Promise;
13 | connectGEM: () => Promise;
14 | connectCrossmark: () => Promise;
15 | disconnect: () => void;
16 | isRetrieved: boolean;
17 | }
18 |
19 | export function useWallet(enableJwt: boolean = false): UseWalletReturn {
20 | const [xrpAddress, setXrpAddress] = useState("");
21 | const [isLoading, setIsLoading] = useState(false);
22 | const [error, setError] = useState(null);
23 | const [isRetrieved, setIsRetrieved] = useState(false);
24 | const [xummQrCode, setXummQrCode] = useState("");
25 | const [xummJumpLink, setXummJumpLink] = useState("");
26 | const [cookies, setCookie, removeCookie] = useCookies(["jwt"]);
27 | const wsRef = useRef(null);
28 |
29 | // Cleanup WebSocket on unmount
30 | useEffect(() => {
31 | return () => {
32 | if (wsRef.current) {
33 | wsRef.current.close();
34 | }
35 | };
36 | }, []);
37 |
38 | // Check for existing JWT on mount
39 | useEffect(() => {
40 | if (cookies.jwt) {
41 | fetch("/api/auth", {
42 | method: "POST",
43 | headers: { "Content-Type": "application/json" },
44 | body: JSON.stringify({ token: cookies.jwt }),
45 | })
46 | .then((response) => response.json())
47 | .then((data) => {
48 | if (data.xrpAddress) {
49 | setXrpAddress(data.xrpAddress);
50 | setIsRetrieved(true);
51 | }
52 | })
53 | .catch(() => {
54 | // Silently fail if token is invalid
55 | removeCookie("jwt");
56 | });
57 | }
58 | }, []); // eslint-disable-line react-hooks/exhaustive-deps
59 |
60 | const connectXUMM = useCallback(async () => {
61 | setIsLoading(true);
62 | setError(null);
63 |
64 | try {
65 | const payload = await fetch("/api/auth/xumm/createpayload");
66 | const data = await payload.json();
67 |
68 | if (data.error) {
69 | throw new Error(data.error);
70 | }
71 |
72 | const qrCode = data.payload.refs.qr_png;
73 | const jumpLink = data.payload.next.always;
74 |
75 | setXummQrCode(qrCode);
76 | setXummJumpLink(jumpLink);
77 |
78 | // Open in new tab on mobile
79 | if (window.innerWidth < 768) {
80 | window.open(jumpLink, "_blank");
81 | }
82 |
83 | // Set up WebSocket connection
84 | const ws = new WebSocket(data.payload.refs.websocket_status);
85 | wsRef.current = ws;
86 |
87 | ws.onmessage = async (e) => {
88 | try {
89 | const responseObj = JSON.parse(e.data);
90 | if (responseObj.signed) {
91 | const payloadResponse = await fetch(
92 | `/api/auth/xumm/getpayload?payloadId=${responseObj.payload_uuidv4}`
93 | );
94 | const payloadJson = await payloadResponse.json();
95 |
96 | if (payloadJson.error) {
97 | throw new Error(payloadJson.error);
98 | }
99 |
100 | const hex = payloadJson.payload.response.hex;
101 | const checkSign = await fetch(`/api/auth/xumm/checksign?hex=${hex}`);
102 | const checkSignJson = await checkSign.json();
103 |
104 | if (checkSignJson.error) {
105 | throw new Error(checkSignJson.error);
106 | }
107 |
108 | setXrpAddress(checkSignJson.xrpAddress);
109 | if (enableJwt && checkSignJson.token) {
110 | setCookie("jwt", checkSignJson.token, { path: "/" });
111 | }
112 | setIsLoading(false);
113 | ws.close();
114 | }
115 | } catch (err) {
116 | setError(err instanceof Error ? err.message : "Failed to verify signature");
117 | setIsLoading(false);
118 | ws.close();
119 | }
120 | };
121 |
122 | ws.onerror = () => {
123 | setError("WebSocket connection error");
124 | setIsLoading(false);
125 | };
126 |
127 | ws.onclose = () => {
128 | wsRef.current = null;
129 | };
130 | } catch (err) {
131 | setError(err instanceof Error ? err.message : "Failed to create XUMM payload");
132 | setIsLoading(false);
133 | }
134 | }, [enableJwt, setCookie]);
135 |
136 | const connectGEM = useCallback(async () => {
137 | setIsLoading(true);
138 | setError(null);
139 |
140 | try {
141 | const installed = await isInstalled();
142 | if (!installed.result.isInstalled) {
143 | throw new Error("GEM wallet is not installed");
144 | }
145 |
146 | const publicKeyResponse = await getPublicKey();
147 | const pubkey = publicKeyResponse.result?.publicKey;
148 | const address = publicKeyResponse.result?.address;
149 |
150 | if (!pubkey || !address) {
151 | throw new Error("Failed to get public key from GEM wallet");
152 | }
153 |
154 | // Get nonce
155 | const nonceResponse = await fetch(
156 | `/api/auth/gem/nonce?pubkey=${pubkey}&address=${address}`
157 | );
158 | const nonceData = await nonceResponse.json();
159 |
160 | if (nonceData.error) {
161 | throw new Error(nonceData.error);
162 | }
163 |
164 | const nonceToken = nonceData.token;
165 |
166 | // Sign message
167 | const signResponse = await signMessage(nonceToken);
168 | const signedMessage = signResponse.result?.signedMessage;
169 |
170 | if (!signedMessage) {
171 | throw new Error("Failed to sign message");
172 | }
173 |
174 | // Verify signature
175 | const checkSignResponse = await fetch(
176 | `/api/auth/gem/checksign?signature=${signedMessage}`,
177 | {
178 | method: "POST",
179 | headers: {
180 | "Content-Type": "application/json",
181 | Authorization: `Bearer ${nonceToken}`,
182 | },
183 | }
184 | );
185 |
186 | const checkSignData = await checkSignResponse.json();
187 |
188 | if (checkSignData.error) {
189 | throw new Error(checkSignData.error);
190 | }
191 |
192 | if (!checkSignData.token || !checkSignData.address) {
193 | throw new Error("Invalid response from server");
194 | }
195 |
196 | setXrpAddress(checkSignData.address);
197 | if (enableJwt) {
198 | setCookie("jwt", checkSignData.token, { path: "/" });
199 | }
200 | setIsLoading(false);
201 | } catch (err) {
202 | setError(err instanceof Error ? err.message : "Failed to connect to GEM wallet");
203 | setIsLoading(false);
204 | }
205 | }, [enableJwt, setCookie]);
206 |
207 | const connectCrossmark = useCallback(async () => {
208 | setIsLoading(true);
209 | setError(null);
210 |
211 | try {
212 | // Get hash
213 | const hashResponse = await fetch("/api/auth/crossmark/hash");
214 | const hashJson = await hashResponse.json();
215 |
216 | if (hashJson.error) {
217 | throw new Error(hashJson.error);
218 | }
219 |
220 | const hash = hashJson.hash;
221 |
222 | // Sign in with Crossmark
223 | const signInResponse = await sdk.methods.signInAndWait(hash);
224 | const address = signInResponse.response.data.address;
225 | const pubkey = signInResponse.response.data.publicKey;
226 | const signature = signInResponse.response.data.signature;
227 |
228 | // Verify signature
229 | const checkSignResponse = await fetch(
230 | `/api/auth/crossmark/checksign?signature=${signature}`,
231 | {
232 | method: "POST",
233 | headers: {
234 | "Content-Type": "application/json",
235 | Authorization: `Bearer ${hash}`,
236 | },
237 | body: JSON.stringify({
238 | pubkey,
239 | address,
240 | }),
241 | }
242 | );
243 |
244 | const checkSignJson = await checkSignResponse.json();
245 |
246 | if (checkSignJson.error) {
247 | throw new Error(checkSignJson.error);
248 | }
249 |
250 | if (!checkSignJson.token) {
251 | throw new Error("Invalid response from server");
252 | }
253 |
254 | setXrpAddress(address);
255 | if (enableJwt) {
256 | setCookie("jwt", checkSignJson.token, { path: "/" });
257 | }
258 | setIsLoading(false);
259 | } catch (err) {
260 | setError(err instanceof Error ? err.message : "Failed to connect to Crossmark");
261 | setIsLoading(false);
262 | }
263 | }, [enableJwt, setCookie]);
264 |
265 | const disconnect = useCallback(() => {
266 | setXrpAddress("");
267 | setIsRetrieved(false);
268 | setXummQrCode("");
269 | setXummJumpLink("");
270 | removeCookie("jwt");
271 | if (wsRef.current) {
272 | wsRef.current.close();
273 | wsRef.current = null;
274 | }
275 | }, [removeCookie]);
276 |
277 | return {
278 | xrpAddress,
279 | isLoading,
280 | error,
281 | xummQrCode,
282 | xummJumpLink,
283 | connectXUMM,
284 | connectGEM,
285 | connectCrossmark,
286 | disconnect,
287 | isRetrieved,
288 | };
289 | }
290 |
291 |
--------------------------------------------------------------------------------