├── .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 | 7 | 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 | Github logo 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 | Xaman QR code 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 |
149 |

150 | Your XRP address is:{" "} 151 | 157 | {xrpAddress.slice(0, 3)}...{xrpAddress.slice(-3)} 158 | 159 |

160 | {isRetrieved && ( 161 |

162 | 163 | (Retrieved from cookies) 164 | 165 |

166 | )} 167 | 173 |
174 | )} 175 |
176 | 177 |
178 |

179 | Have a suggestion or found a bug? Open an issue on the{" "} 180 | 186 | GitHub repository 187 | 188 |

189 |
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 | --------------------------------------------------------------------------------