├── assets └── preview.png ├── src ├── app │ ├── favicon.ico │ ├── page.tsx │ ├── layout.tsx │ └── globals.css ├── lib │ ├── types │ │ ├── signature.ts │ │ ├── typed-data.ts │ │ └── order.ts │ ├── utils.ts │ ├── helpers │ │ ├── generateAccount.ts │ │ └── signing.ts │ ├── services │ │ ├── mid-price.ts │ │ ├── token-details.ts │ │ ├── builder-info.ts │ │ ├── balances.ts │ │ ├── approve-agent.ts │ │ ├── approve-builder.ts │ │ └── market-order.ts │ ├── config │ │ ├── axios.ts │ │ ├── wagmi.ts │ │ ├── index.ts │ │ └── chains.ts │ └── store.ts ├── components │ ├── ConnectWallet.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── separator.tsx │ │ ├── toaster.tsx │ │ ├── tabs.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ └── toast.tsx │ ├── Trade.tsx │ ├── UserProvider.tsx │ ├── ContextProvider.tsx │ ├── ApproveBuilderFee.tsx │ ├── ApproveAgent.tsx │ └── TradeForm.tsx └── hooks │ └── use-toast.ts ├── postcss.config.mjs ├── components.json ├── next.config.ts ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── tailwind.config.ts └── README.md /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sovrun/create-builder-codes-dapp/HEAD/assets/preview.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sovrun/create-builder-codes-dapp/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/lib/types/signature.ts: -------------------------------------------------------------------------------- 1 | export type Signature = { 2 | r: string; 3 | s: string; 4 | v: number; 5 | }; 6 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/helpers/generateAccount.ts: -------------------------------------------------------------------------------- 1 | import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; 2 | 3 | export type Agent = { 4 | privateKey: `0x${string}`; 5 | address: `0x${string}`; 6 | }; 7 | 8 | export const generateAgentAccount = (): Agent => { 9 | const privateKey = generatePrivateKey(); 10 | const { address } = privateKeyToAccount(privateKey); 11 | return { privateKey, address }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useAccount } from "wagmi"; 4 | import ConnectWallet from "@/components/ConnectWallet"; 5 | import Trade from "@/components/Trade"; 6 | 7 | export default function Home() { 8 | const { isConnected } = useAccount(); 9 | 10 | return ( 11 |
12 | {!isConnected ? : } 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/services/mid-price.ts: -------------------------------------------------------------------------------- 1 | import axios from "@/lib/config/axios"; 2 | 3 | export async function fetchMidPrice(token: string): Promise { 4 | try { 5 | const response = await axios.post("/info", { 6 | type: "allMids", 7 | }); 8 | 9 | if (!response.data) { 10 | throw new Error("No data received from server"); 11 | } 12 | 13 | return parseFloat(response.data[token]); 14 | } catch (error) { 15 | return Promise.reject(error); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/config/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { isMainnet, MAINNET_API_URL, TESTNET_API_URL } from "@/lib/config"; 3 | 4 | export const BASE_URL = isMainnet ? MAINNET_API_URL : TESTNET_API_URL; 5 | 6 | // Create base axios instance with default config 7 | const api = axios.create({ 8 | baseURL: BASE_URL, 9 | // timeout: 10000, 10 | headers: { 11 | "Content-Type": "application/json", 12 | }, 13 | withCredentials: false, 14 | }); 15 | 16 | export default api; 17 | -------------------------------------------------------------------------------- /src/lib/services/token-details.ts: -------------------------------------------------------------------------------- 1 | import axios from "@/lib/config/axios"; 2 | 3 | export async function fetchTokenDetails(tokenId: string) { 4 | try { 5 | const response = await axios.post("/info", { 6 | type: "tokenDetails", 7 | tokenId: tokenId, 8 | }); 9 | 10 | if (!response.data) { 11 | throw new Error("No data received from server"); 12 | } 13 | 14 | return response.data; 15 | } catch (error) { 16 | return Promise.reject(error); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | webpack: (config) => { 6 | config.externals.push("pino-pretty", "lokijs", "encoding"); 7 | return config; 8 | }, 9 | experimental: { 10 | turbo: { 11 | rules: { 12 | // External modules that need to be excluded 13 | external: { 14 | loaders: ["pino-pretty", "lokijs", "encoding"], 15 | }, 16 | }, 17 | }, 18 | }, 19 | }; 20 | 21 | export default nextConfig; 22 | -------------------------------------------------------------------------------- /src/lib/services/builder-info.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@/lib/config"; 2 | import axios from "@/lib/config/axios"; 3 | 4 | export async function fetchBuilderInfo(user: string): Promise { 5 | try { 6 | const response = await axios.post(`/info`, { 7 | type: "maxBuilderFee", 8 | user, 9 | builder: config.builderAddress, 10 | }); 11 | 12 | if (!response.data) { 13 | throw new Error("No data received from server"); 14 | } 15 | 16 | return response.data; 17 | } catch (error) { 18 | return Promise.reject(error); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | { 15 | rules: { 16 | "@typescript-eslint/no-unused-vars": "warn", 17 | "@typescript-eslint/ban-ts-comment": "warn", 18 | }, 19 | }, 20 | ]; 21 | export default eslintConfig; 22 | -------------------------------------------------------------------------------- /src/components/ConnectWallet.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardContent, 4 | CardDescription, 5 | CardHeader, 6 | CardTitle, 7 | } from "@/components/ui/card"; 8 | 9 | export default function ConnectWallet() { 10 | return ( 11 | 12 | 13 | Hyperliquid Spot Boilerplate 14 | 15 | Connect your wallet to start using the app 16 | 17 | 18 | 19 | {/* @ts-ignore */} 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/lib/config/wagmi.ts: -------------------------------------------------------------------------------- 1 | import { cookieStorage, createStorage } from "wagmi"; 2 | import { WagmiAdapter } from "@reown/appkit-adapter-wagmi"; 3 | import { arbitrumSepolia } from "@reown/appkit/networks"; 4 | import { hypeEvmTestnet } from "./chains"; 5 | 6 | export const projectId = "02b218a0fae412edcdb5e5bff9441a94"; 7 | 8 | export const networks = [hypeEvmTestnet, arbitrumSepolia]; 9 | 10 | //Set up the Wagmi Adapter (Config) 11 | export const wagmiAdapter = new WagmiAdapter({ 12 | storage: createStorage({ 13 | storage: cookieStorage, 14 | }), 15 | ssr: true, 16 | projectId, 17 | networks, 18 | }); 19 | 20 | export const config = wagmiAdapter.wagmiConfig; 21 | -------------------------------------------------------------------------------- /src/lib/services/balances.ts: -------------------------------------------------------------------------------- 1 | import axios from "@/lib/config/axios"; 2 | 3 | interface Balance { 4 | coin: string; 5 | token: number; 6 | total: string; 7 | hold: string; 8 | entryNtl: string; 9 | } 10 | 11 | interface BalanceResponse { 12 | balances: Balance[]; 13 | } 14 | 15 | export async function fetchBalances(user: string): Promise { 16 | try { 17 | const response = await axios.post(`/info`, { 18 | type: "spotClearinghouseState", 19 | user, 20 | }); 21 | 22 | if (!response.data) { 23 | throw new Error("No data received from server"); 24 | } 25 | 26 | return response.data; 27 | } catch (error) { 28 | return Promise.reject(error); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/config/index.ts: -------------------------------------------------------------------------------- 1 | export const MAINNET_API_URL = "https://api.hyperliquid.xyz"; 2 | export const TESTNET_API_URL = "https://api.hyperliquid-testnet.xyz"; 3 | 4 | export const builderPointsToPercent = (points: number): string => { 5 | // 1 point = 0.1 basis point = 0.001% 6 | // So multiply points by 0.001 to get percentage 7 | return (points * 0.01).toString() + "%"; 8 | }; 9 | 10 | interface Config { 11 | env: string; 12 | rpcUrl: string; 13 | builderAddress: `0x${string}`; 14 | builderFee: number; 15 | builderFeePercent: number; 16 | } 17 | 18 | export const config: Config = { 19 | env: process.env.NEXT_PUBLIC_NODE_ENV || "development", 20 | rpcUrl: process.env.NEXT_PUBLIC_RPC_URL || "", 21 | builderAddress: process.env.NEXT_PUBLIC_BUILDER_ADDRESS as `0x${string}`, 22 | builderFee: Number(process.env.NEXT_PUBLIC_BUILDER_FEE), 23 | builderFeePercent: Number(process.env.NEXT_PUBLIC_BUILDER_FEE), 24 | }; 25 | 26 | export const isMainnet = config.env === "production"; 27 | -------------------------------------------------------------------------------- /src/lib/config/chains.ts: -------------------------------------------------------------------------------- 1 | import { defineChain } from "viem"; 2 | 3 | export const hypeEvmTestnet = /*#__PURE__*/ defineChain({ 4 | id: 998, 5 | name: "Hype EVM Testnet", 6 | nativeCurrency: { name: "Hype", symbol: "HYPE", decimals: 18 }, 7 | rpcUrls: { 8 | default: { 9 | http: ["https://api.hyperliquid-testnet.xyz/evm"], 10 | }, 11 | }, 12 | blockExplorers: { 13 | default: { 14 | name: "Etherscan", 15 | url: "https://hyperevm-explorer.vercel.app", 16 | apiUrl: "https://api.hyperliquid-testnet.xyz", 17 | }, 18 | }, 19 | // contracts: { 20 | // ensRegistry: { 21 | // address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', 22 | // }, 23 | // ensUniversalResolver: { 24 | // address: '0xce01f8eee7E479C928F8919abD53E553a36CeF67', 25 | // blockCreated: 19_258_213, 26 | // }, 27 | // multicall3: { 28 | // address: '0xca11bde05977b3631167028862be2a173976ca11', 29 | // blockCreated: 14_353_601, 30 | // }, 31 | // }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/components/Trade.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 2 | import TradeForm from "./TradeForm"; 3 | import useUserStore from "@/lib/store"; 4 | import ApproveBuilderFee from "./ApproveBuilderFee"; 5 | import ApproveAgent from "./ApproveAgent"; 6 | 7 | export default function Trade() { 8 | const user = useUserStore((state) => state.user); 9 | 10 | if (!user?.builderFee) { 11 | return ; 12 | } else if (!user.agent) { 13 | return ; 14 | } else { 15 | return ( 16 | 17 | 18 | Buy 19 | Sell 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present, Sovrun 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/UserProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { useAccount } from "wagmi"; 5 | import useUserStore from "@/lib/store"; 6 | 7 | export default function UserProvider({ 8 | children, 9 | }: { 10 | children: React.ReactNode; 11 | }) { 12 | const { address } = useAccount(); 13 | const user = useUserStore((state) => state.user); 14 | const login = useUserStore((state) => state.login); 15 | const logout = useUserStore((state) => state.logout); 16 | 17 | useEffect(() => { 18 | if (address) { 19 | const user = localStorage.getItem(`test_spot_trader.user_${address}`); 20 | const userData = user ? JSON.parse(user) : null; 21 | 22 | login({ 23 | address, 24 | persistTradingConnection: userData?.persistTradingConnection 25 | ? userData?.persistTradingConnection === "true" 26 | : false, 27 | builderFee: userData?.builderFee ? Number(userData?.builderFee) : 0, 28 | agent: userData?.agent ? userData?.agent : null, 29 | }); 30 | } else { 31 | logout(); 32 | } 33 | }, [address]); 34 | 35 | useEffect(() => { 36 | console.log({ user }); 37 | }, [user]); 38 | 39 | return <>{children}; 40 | } 41 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { headers } from "next/headers"; 3 | import { Geist, Geist_Mono } from "next/font/google"; 4 | import "./globals.css"; 5 | 6 | import UserProvider from "@/components/UserProvider"; 7 | import { ContextProvider } from "@/components/ContextProvider"; 8 | import { Toaster } from "@/components/ui/toaster"; 9 | 10 | const geistSans = Geist({ 11 | variable: "--font-geist-sans", 12 | weight: ["400", "500", "600", "700"], 13 | subsets: ["latin"], 14 | }); 15 | 16 | const geistMono = Geist_Mono({ 17 | variable: "--font-geist-mono", 18 | subsets: ["latin"], 19 | }); 20 | 21 | export const metadata: Metadata = { 22 | title: "Hyperliquid Spot Boilerplate", 23 | description: "Hyperliquid Spot Boilerplate", 24 | }; 25 | 26 | export default async function RootLayout({ 27 | children, 28 | }: Readonly<{ 29 | children: React.ReactNode; 30 | }>) { 31 | const headersObj = await headers(); 32 | const cookies = headersObj.get("cookie"); 33 | 34 | return ( 35 | 36 | 39 | 40 | 41 |
42 | {/* @ts-ignore */} 43 | 44 |
45 | {children} 46 |
47 |
48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@msgpack/msgpack": "3.0.0-beta2", 13 | "@radix-ui/react-dialog": "^1.1.4", 14 | "@radix-ui/react-label": "^2.1.1", 15 | "@radix-ui/react-separator": "^1.1.1", 16 | "@radix-ui/react-slot": "^1.1.1", 17 | "@radix-ui/react-tabs": "^1.1.2", 18 | "@radix-ui/react-toast": "^1.2.4", 19 | "@reown/appkit": "^1.6.4", 20 | "@reown/appkit-adapter-wagmi": "^1.6.4", 21 | "@tanstack/react-query": "^5.64.2", 22 | "@wagmi/core": "^2.16.3", 23 | "axios": "^1.7.9", 24 | "class-variance-authority": "^0.7.1", 25 | "clsx": "^2.1.1", 26 | "decimal.js": "^10.4.3", 27 | "ethers": "^6.13.5", 28 | "lucide-react": "^0.473.0", 29 | "next": "15.1.5", 30 | "react": "^19.0.0", 31 | "react-dom": "^19.0.0", 32 | "tailwind-merge": "^2.6.0", 33 | "tailwindcss-animate": "^1.0.7", 34 | "viem": "^2.22.10", 35 | "wagmi": "^2.14.8", 36 | "zustand": "^5.0.3" 37 | }, 38 | "devDependencies": { 39 | "@eslint/eslintrc": "^3", 40 | "@types/node": "^20", 41 | "@types/react": "^19", 42 | "@types/react-dom": "^19", 43 | "eslint": "^9", 44 | "eslint-config-next": "15.1.5", 45 | "postcss": "^8", 46 | "tailwindcss": "^3.4.1", 47 | "typescript": "^5" 48 | }, 49 | "packageManager": "pnpm@9.7.1+sha256.46f1bbc8f8020aa9869568c387198f1a813f21fb44c82f400e7d1dbde6c70b40" 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/types/typed-data.ts: -------------------------------------------------------------------------------- 1 | import type { TypedData } from "viem"; 2 | 3 | export const APPROVE_BUILDER_FEE = "HyperliquidTransaction:ApproveBuilderFee"; 4 | export const AGENT = "Agent"; 5 | 6 | export const approveBuilderFeeTypedData = { 7 | EIP712Domain: [ 8 | { name: "name", type: "string" }, 9 | { name: "version", type: "string" }, 10 | { name: "chainId", type: "uint256" }, 11 | { name: "verifyingContract", type: "address" }, 12 | ], 13 | [APPROVE_BUILDER_FEE]: [ 14 | { name: "hyperliquidChain", type: "string" }, 15 | { name: "maxFeeRate", type: "string" }, 16 | { name: "builder", type: "address" }, 17 | { name: "nonce", type: "uint64" }, 18 | ], 19 | } as const satisfies TypedData; 20 | 21 | export const approveAgentTypedData = { 22 | EIP712Domain: [ 23 | { name: "name", type: "string" }, 24 | { name: "version", type: "string" }, 25 | { name: "chainId", type: "uint256" }, 26 | { name: "verifyingContract", type: "address" }, 27 | ], 28 | [APPROVE_BUILDER_FEE]: [ 29 | { name: "hyperliquidChain", type: "string" }, 30 | { name: "maxFeeRate", type: "string" }, 31 | { name: "builder", type: "address" }, 32 | { name: "nonce", type: "uint64" }, 33 | ], 34 | } as const satisfies TypedData; 35 | 36 | export const orderTypedData = { 37 | EIP712Domain: [ 38 | { name: "name", type: "string" }, 39 | { name: "version", type: "string" }, 40 | { name: "chainId", type: "uint256" }, 41 | { name: "verifyingContract", type: "address" }, 42 | ], 43 | Agent: [ 44 | { name: "source", type: "string" }, 45 | { name: "connectionId", type: "bytes32" }, 46 | ], 47 | } as const satisfies TypedData; 48 | -------------------------------------------------------------------------------- /src/components/ContextProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type ReactNode } from "react"; 4 | import { createAppKit } from "@reown/appkit/react"; 5 | import { arbitrumSepolia } from "@reown/appkit/networks"; 6 | import { cookieToInitialState, WagmiProvider, type Config } from "wagmi"; 7 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 8 | 9 | import { wagmiAdapter, projectId } from "@/lib/config/wagmi"; 10 | import { hypeEvmTestnet } from "@/lib/config/chains"; 11 | 12 | const queryClient = new QueryClient(); 13 | 14 | // Set up metadata 15 | const metadata = { 16 | name: "appkit-example", 17 | description: "AppKit Example", 18 | url: "https://appkitexampleapp.com", // origin must match your domain & subdomain 19 | icons: ["https://avatars.githubusercontent.com/u/179229932"], 20 | }; 21 | 22 | // Create the modal 23 | export const modal = createAppKit({ 24 | adapters: [wagmiAdapter], 25 | projectId, 26 | networks: [hypeEvmTestnet, arbitrumSepolia], 27 | defaultNetwork: hypeEvmTestnet, 28 | metadata: metadata, 29 | features: { 30 | analytics: true, // Optional - defaults to your Cloud configuration 31 | }, 32 | }); 33 | 34 | export const ContextProvider = ({ 35 | children, 36 | cookies, 37 | }: { 38 | children: ReactNode; 39 | cookies: string | null; 40 | }) => { 41 | const initialState = cookieToInitialState( 42 | wagmiAdapter.wagmiConfig as Config, 43 | cookies 44 | ); 45 | 46 | return ( 47 | 51 | {children} 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/ApproveBuilderFee.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { approveBuilder } from "@/lib/services/approve-builder"; 12 | import { fetchBuilderInfo } from "@/lib/services/builder-info"; 13 | import { useAccount } from "wagmi"; 14 | import useUserStore from "@/lib/store"; 15 | import { useMutation } from "@tanstack/react-query"; 16 | 17 | export default function ApproveBuilderFee() { 18 | const { address, chain } = useAccount(); 19 | const updateBuilderFee = useUserStore((state) => state.updateBuilderFee); 20 | 21 | const { mutate: approveBuilderFee, isPending } = useMutation({ 22 | mutationFn: async () => { 23 | if (!address || !chain) { 24 | throw new Error("Missing address or chain"); 25 | } 26 | 27 | const result = await approveBuilder({ address, chain }); 28 | 29 | if (result?.status !== "ok") { 30 | throw new Error("Failed to sign builder fee"); 31 | } 32 | 33 | const builderFee = await fetchBuilderInfo(address); 34 | return builderFee; 35 | }, 36 | onSuccess: (builderFee) => { 37 | if (builderFee) { 38 | updateBuilderFee(builderFee); 39 | } 40 | }, 41 | onError: (error) => { 42 | console.error("signBuilderFee error", error); 43 | }, 44 | }); 45 | 46 | return ( 47 | 48 | 49 | Approve Builder Fee 50 | 51 | This exchange will take a 0.05% fee for every transaction 52 | 53 | 54 | 55 | 58 | 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 0 0% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 0 0% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 0 0% 3.9%; 17 | --primary: 0 0% 9%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | --muted: 0 0% 96.1%; 22 | --muted-foreground: 0 0% 45.1%; 23 | --accent: 0 0% 96.1%; 24 | --accent-foreground: 0 0% 9%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 0 0% 98%; 27 | --border: 0 0% 89.8%; 28 | --input: 0 0% 89.8%; 29 | --ring: 0 0% 3.9%; 30 | --chart-1: 12 76% 61%; 31 | --chart-2: 173 58% 39%; 32 | --chart-3: 197 37% 24%; 33 | --chart-4: 43 74% 66%; 34 | --chart-5: 27 87% 67%; 35 | --radius: 0.5rem; 36 | } 37 | .dark { 38 | --background: 0 0% 3.9%; 39 | --foreground: 0 0% 98%; 40 | --card: 0 0% 3.9%; 41 | --card-foreground: 0 0% 98%; 42 | --popover: 0 0% 3.9%; 43 | --popover-foreground: 0 0% 98%; 44 | --primary: 0 0% 98%; 45 | --primary-foreground: 0 0% 9%; 46 | --secondary: 0 0% 14.9%; 47 | --secondary-foreground: 0 0% 98%; 48 | --muted: 0 0% 14.9%; 49 | --muted-foreground: 0 0% 63.9%; 50 | --accent: 0 0% 14.9%; 51 | --accent-foreground: 0 0% 98%; 52 | --destructive: 0 62.8% 30.6%; 53 | --destructive-foreground: 0 0% 98%; 54 | --border: 0 0% 14.9%; 55 | --input: 0 0% 14.9%; 56 | --ring: 0 0% 83.1%; 57 | --chart-1: 220 70% 50%; 58 | --chart-2: 160 60% 45%; 59 | --chart-3: 30 80% 55%; 60 | --chart-4: 280 65% 60%; 61 | --chart-5: 340 75% 55%; 62 | } 63 | } 64 | 65 | @layer base { 66 | * { 67 | @apply border-border; 68 | } 69 | body { 70 | @apply bg-background text-foreground; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | import type { Config } from "tailwindcss"; 3 | 4 | export default { 5 | darkMode: ["class"], 6 | content: [ 7 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 9 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 10 | ], 11 | theme: { 12 | extend: { 13 | colors: { 14 | background: "hsl(var(--background))", 15 | foreground: "hsl(var(--foreground))", 16 | card: { 17 | DEFAULT: "hsl(var(--card))", 18 | foreground: "hsl(var(--card-foreground))", 19 | }, 20 | popover: { 21 | DEFAULT: "hsl(var(--popover))", 22 | foreground: "hsl(var(--popover-foreground))", 23 | }, 24 | primary: { 25 | DEFAULT: "hsl(var(--primary))", 26 | foreground: "hsl(var(--primary-foreground))", 27 | }, 28 | secondary: { 29 | DEFAULT: "hsl(var(--secondary))", 30 | foreground: "hsl(var(--secondary-foreground))", 31 | }, 32 | muted: { 33 | DEFAULT: "hsl(var(--muted))", 34 | foreground: "hsl(var(--muted-foreground))", 35 | }, 36 | accent: { 37 | DEFAULT: "hsl(var(--accent))", 38 | foreground: "hsl(var(--accent-foreground))", 39 | }, 40 | destructive: { 41 | DEFAULT: "hsl(var(--destructive))", 42 | foreground: "hsl(var(--destructive-foreground))", 43 | }, 44 | border: "hsl(var(--border))", 45 | input: "hsl(var(--input))", 46 | ring: "hsl(var(--ring))", 47 | chart: { 48 | "1": "hsl(var(--chart-1))", 49 | "2": "hsl(var(--chart-2))", 50 | "3": "hsl(var(--chart-3))", 51 | "4": "hsl(var(--chart-4))", 52 | "5": "hsl(var(--chart-5))", 53 | }, 54 | }, 55 | borderRadius: { 56 | lg: "var(--radius)", 57 | md: "calc(var(--radius) - 2px)", 58 | sm: "calc(var(--radius) - 4px)", 59 | }, 60 | }, 61 | }, 62 | plugins: [require("tailwindcss-animate")], 63 | } satisfies Config; 64 | -------------------------------------------------------------------------------- /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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ApproveAgent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Separator } from "./ui/separator"; 12 | import { generateAgentAccount } from "@/lib/helpers/generateAccount"; 13 | import { useMutation } from "@tanstack/react-query"; 14 | import { approveAgent } from "@/lib/services/approve-agent"; 15 | import { useAccount } from "wagmi"; 16 | import useUserStore from "@/lib/store"; 17 | 18 | export default function ApproveAgent() { 19 | const { chain } = useAccount(); 20 | const updateAgent = useUserStore((state) => state.updateAgent); 21 | 22 | const { mutate: approveAgentMutation, isPending } = useMutation({ 23 | mutationFn: async () => { 24 | const agent = generateAgentAccount(); 25 | 26 | if (!agent.address || !agent.privateKey || !chain) { 27 | throw new Error("Missing address or chain"); 28 | } 29 | 30 | const result = await approveAgent({ agentAddress: agent.address, chain }); 31 | 32 | console.log("result", result); 33 | 34 | if (result?.status !== "ok") { 35 | throw new Error("Failed to sign builder fee"); 36 | } 37 | 38 | updateAgent({ 39 | address: agent.address, 40 | privateKey: agent.privateKey, 41 | }); 42 | 43 | return result; 44 | }, 45 | onError: (error) => { 46 | console.error("signBuilderFee error", error); 47 | }, 48 | }); 49 | 50 | return ( 51 | 52 | 53 | Establish Connection with Agent 54 | 55 | This signature is gas-free to send. It opens a decentralized channel 56 | for gas-free and instantaneous trading. 57 | 58 | 59 | 60 | 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )); 18 | Card.displayName = "Card"; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )); 30 | CardHeader.displayName = "CardHeader"; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )); 42 | CardTitle.displayName = "CardTitle"; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )); 54 | CardDescription.displayName = "CardDescription"; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )); 62 | CardContent.displayName = "CardContent"; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )); 74 | CardFooter.displayName = "CardFooter"; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /src/lib/store.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface User { 4 | address: string; 5 | persistTradingConnection: boolean; 6 | builderFee: number; 7 | agent: Agent | null; 8 | } 9 | 10 | interface Agent { 11 | privateKey: `0x${string}`; 12 | address: `0x${string}`; 13 | } 14 | 15 | interface UserStoreState { 16 | user: User | null; 17 | login: (user: User) => void; 18 | updateBuilderFee: (builderFee: number) => void; 19 | updatePersistTradingConnection: (persistTradingConnection: boolean) => void; 20 | updateAgent: (agent: Agent) => void; 21 | // reset state 22 | logout: () => void; 23 | } 24 | 25 | const useUserStore = create()((set) => ({ 26 | user: null, 27 | login: (user) => { 28 | localStorage.setItem( 29 | `test_spot_trader.user_${user.address}`, 30 | JSON.stringify(user) 31 | ); 32 | set({ user }); 33 | }, 34 | updateBuilderFee: (builderFee) => 35 | set((state) => { 36 | if (!state.user) return { user: null }; 37 | const updatedUser = { 38 | ...state.user, 39 | builderFee, 40 | }; 41 | localStorage.setItem( 42 | `test_spot_trader.user_${state.user.address}`, 43 | JSON.stringify(updatedUser) 44 | ); 45 | return { user: updatedUser }; 46 | }), 47 | updatePersistTradingConnection: (persistTradingConnection) => 48 | set((state) => { 49 | if (!state.user) return { user: null }; 50 | const updatedUser = { 51 | ...state.user, 52 | persistTradingConnection, 53 | }; 54 | localStorage.setItem( 55 | `test_spot_trader.user_${state.user.address}`, 56 | JSON.stringify(updatedUser) 57 | ); 58 | return { user: updatedUser }; 59 | }), 60 | updateAgent: (agent) => 61 | set((state) => { 62 | if (!state.user) return { user: null }; 63 | const updatedUser = { 64 | ...state.user, 65 | agent, 66 | }; 67 | localStorage.setItem( 68 | `test_spot_trader.user_${state.user.address}`, 69 | JSON.stringify(updatedUser) 70 | ); 71 | return { user: updatedUser }; 72 | }), 73 | logout: () => { 74 | set({ user: null }); 75 | }, 76 | })); 77 | 78 | export default useUserStore; 79 | -------------------------------------------------------------------------------- /src/lib/services/approve-agent.ts: -------------------------------------------------------------------------------- 1 | import { toHex } from "viem"; 2 | import { signTypedData } from "@wagmi/core"; 3 | import { Signature } from "ethers"; 4 | import { wagmiAdapter } from "@/lib/config/wagmi"; 5 | import { isMainnet } from "@/lib/config"; 6 | import { Signature as SplitSignature } from "@/lib/types/signature"; 7 | import type { SignTypedDataReturnType } from "@wagmi/core"; 8 | import axios from "@/lib/config/axios"; 9 | 10 | type ApproveAgentAction = { 11 | type: "approveAgent"; 12 | signatureChainId: `0x${string}`; 13 | hyperliquidChain: "Mainnet" | "Testnet"; 14 | agentAddress: `0x${string}`; 15 | agentName: string; 16 | nonce: number; 17 | }; 18 | 19 | export async function approveAgent({ 20 | chain, 21 | agentAddress, 22 | agentName = "test_spot_trader", 23 | }: { 24 | chain: { id: number }; 25 | agentAddress: `0x${string}`; 26 | agentName?: string; 27 | }) { 28 | if (!chain) { 29 | throw new Error("Missing chain"); 30 | } 31 | 32 | const nonce = Date.now(); 33 | const signatureChainId = toHex(chain?.id || 421614); 34 | const hyperliquidChain = isMainnet ? "Mainnet" : "Testnet"; 35 | 36 | const action: ApproveAgentAction = { 37 | type: "approveAgent", 38 | signatureChainId, 39 | hyperliquidChain, 40 | agentAddress, 41 | agentName, 42 | nonce, 43 | }; 44 | 45 | const data = { 46 | domain: { 47 | name: "HyperliquidSignTransaction", 48 | version: "1", 49 | chainId: chain?.id || 421614, 50 | verifyingContract: "0x0000000000000000000000000000000000000000", 51 | }, 52 | types: { 53 | "HyperliquidTransaction:ApproveAgent": [ 54 | { name: "hyperliquidChain", type: "string" }, 55 | { name: "agentAddress", type: "address" }, 56 | { name: "agentName", type: "string" }, 57 | { name: "nonce", type: "uint64" }, 58 | ], 59 | EIP712Domain: [ 60 | { name: "name", type: "string" }, 61 | { name: "version", type: "string" }, 62 | { name: "chainId", type: "uint256" }, 63 | { name: "verifyingContract", type: "address" }, 64 | ], 65 | }, 66 | primaryType: "HyperliquidTransaction:ApproveAgent", 67 | message: action, 68 | }; 69 | 70 | const eip712Signature: SignTypedDataReturnType = await signTypedData( 71 | wagmiAdapter.wagmiConfig, 72 | // @ts-expect-error ignore type 73 | data 74 | ); 75 | 76 | const signature: SplitSignature = Signature.from(eip712Signature); 77 | 78 | const response = await axios.post("/exchange", { 79 | action: action, 80 | nonce: nonce, 81 | signature, 82 | }); 83 | 84 | return response.data; 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/services/approve-builder.ts: -------------------------------------------------------------------------------- 1 | import { toHex } from "viem"; 2 | import { signTypedData } from "@wagmi/core"; 3 | import { Signature } from "ethers"; 4 | import { wagmiAdapter } from "@/lib/config/wagmi"; 5 | import { config, isMainnet, builderPointsToPercent } from "@/lib/config"; 6 | import { Signature as SplitSignature } from "@/lib/types/signature"; 7 | import { 8 | approveBuilderFeeTypedData, 9 | APPROVE_BUILDER_FEE as PRIMARY_TYPE, 10 | } from "@/lib/types/typed-data"; 11 | import type { SignTypedDataReturnType } from "@wagmi/core"; 12 | import axios from "@/lib/config/axios"; 13 | 14 | /** Chain options for Hyperliquid */ 15 | export type HyperliquidChain = "Mainnet" | "Testnet"; 16 | 17 | /** Type definition for approve builder fee action */ 18 | export type ApproveBuilderFeeAction = { 19 | /** Maximum fee rate as percentage string (e.g. '0.01%') */ 20 | maxFeeRate: string; 21 | /** Builder's address in hex format */ 22 | builder: `0x${string}`; 23 | /** Timestamp in milliseconds */ 24 | nonce: number; 25 | /** Action type identifier */ 26 | type: "approveBuilderFee"; 27 | /** Chain ID in hex format (e.g. '0x66eee' for Arbitrum Sepolia) */ 28 | signatureChainId: `0x${string}`; 29 | /** Target Hyperliquid chain */ 30 | hyperliquidChain: HyperliquidChain; 31 | }; 32 | 33 | export async function approveBuilder({ 34 | chain, 35 | }: { 36 | address: string; 37 | chain: { id: number }; 38 | }) { 39 | if (!chain) { 40 | throw new Error("Missing chain"); 41 | } 42 | 43 | const nonce = Date.now(); 44 | const builder = config.builderAddress; 45 | const signatureChainId = toHex(chain?.id || 421614); 46 | const hyperliquidChain = isMainnet ? "Mainnet" : "Testnet"; 47 | const maxFeeRate = builderPointsToPercent(config.builderFeePercent); 48 | 49 | const action: ApproveBuilderFeeAction = { 50 | type: "approveBuilderFee", 51 | nonce, 52 | maxFeeRate, 53 | builder, 54 | signatureChainId, 55 | hyperliquidChain, 56 | }; 57 | 58 | const data = { 59 | domain: { 60 | name: "HyperliquidSignTransaction", 61 | version: "1", 62 | chainId: chain?.id || 421614, 63 | verifyingContract: "0x0000000000000000000000000000000000000000", 64 | }, 65 | types: approveBuilderFeeTypedData, 66 | primaryType: PRIMARY_TYPE, 67 | message: action, 68 | }; 69 | 70 | const eip712Signature: SignTypedDataReturnType = await signTypedData( 71 | wagmiAdapter.wagmiConfig, 72 | // @ts-expect-error ignore lint 73 | data 74 | ); 75 | 76 | const signature: SplitSignature = Signature.from(eip712Signature); 77 | 78 | const approveBuilderResult = await axios.post("/exchange", { 79 | action: action, 80 | nonce: nonce, 81 | signature, 82 | }); 83 | 84 | return approveBuilderResult.data; 85 | } 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sovrun create-builder-codes-dapp boilerplate 2 | 3 | ![Preview](./assets/preview.png) 4 | 5 | A Next.js-based boilerplate for building decentralized spot trading applications on Hyperliquid utilizing [Builder Codes](https://hyperliquid.gitbook.io/hyperliquid-docs/trading/builder-codes). This project provides a foundation for creating web3 trading interfaces with essential features like wallet connection, builder fee approval, agent creation, and gas-free order execution. The sample dapp provided allows for basic swap routing on Hyperliquid L1. 6 | 7 | ## Features 8 | 9 | - 🔐 Secure wallet connection using AppKit 10 | - 💱 Spot trading interface for buying and selling tokens 11 | - 🤝 Builder fee approval system 12 | - 🔑 Agent-based trading system 13 | - 🎨 Modern UI using shadcn/ui components 14 | - 🌙 Dark mode support 15 | - 🔄 Real-time price and balance updates 16 | 17 | ## Tech Stack 18 | 19 | - **Framework**: Next.js 15 20 | - **Language**: TypeScript 21 | - **Styling**: Tailwind CSS 22 | - **Web3**: 23 | - Wagmi 24 | - Viem 25 | - Ethers.js 26 | - **State Management**: Zustand 27 | - **Data Fetching**: TanStack Query 28 | - **UI Components**: shadcn/ui 29 | - **Package Manager**: pnpm 30 | 31 | ## Getting Started 32 | 33 | 1. Clone the repository 34 | 2. Install dependencies: 35 | 36 | ```bash 37 | pnpm install 38 | ``` 39 | 40 | 3. Set up environment variables: 41 | 42 | ```env 43 | # The environment mode (development/production) 44 | NEXT_PUBLIC_NODE_ENV=development 45 | 46 | # The RPC URL for connecting to the Hyperliquid testnet 47 | NEXT_PUBLIC_RPC_URL=your_rpc_url 48 | 49 | # The builder's wallet address for fee collection 50 | NEXT_PUBLIC_BUILDER_ADDRESS=your_builder_address 51 | 52 | # The builder fee percentage (in basis points) 53 | NEXT_PUBLIC_BUILDER_FEE=10 54 | 55 | # The WalletConnect project ID for wallet connections 56 | NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=your_wallet_connect_project_id 57 | ``` 58 | 59 | 4. Run the development server: 60 | 61 | ```bash 62 | pnpm dev 63 | ``` 64 | 65 | ## Core Components 66 | 67 | ### Wallet Connection 68 | 69 | The wallet connection is handled through AppKit integration, providing a seamless connection experience 70 | 71 | ### Trading Interface 72 | 73 | The trading interface supports both buying and selling with features like: 74 | 75 | - Current token price 76 | - Balance checking 77 | - Slippage control 78 | - Order execution 79 | 80 | ### Builder Fee Approval 81 | 82 | Users need to approve builder fees before trading. 83 | 84 | ### Agent System 85 | 86 | The platform uses an agent-based system for gas-free trading. 87 | 88 | ## Contributing 89 | 90 | Contributions are welcome! Guidelines currently being drafted, please feel free to coordinate with us for reviewing PRs. 91 | 92 | ## License 93 | 94 | This project is licensed under the MIT License - see the LICENSE file for details. 95 | 96 | ## Support 97 | 98 | For support, please open an issue in the GitHub repository, reach out to the Hyperliquid community, or visit the Sovrun discord. 99 | -------------------------------------------------------------------------------- /src/lib/helpers/signing.ts: -------------------------------------------------------------------------------- 1 | import { encode } from "@msgpack/msgpack"; 2 | import { PrivateKeyAccount } from "viem/accounts"; 3 | import { Hex, keccak256 } from "viem"; 4 | import { Signature as SplitSignature } from "ethers"; 5 | 6 | import { isMainnet } from "@/lib/config"; 7 | 8 | import { Signature } from "@/lib/types/signature"; 9 | import { AGENT } from "@/lib/types/typed-data"; 10 | 11 | export const signStandardL1Action = async ( 12 | action: unknown, 13 | wallet: PrivateKeyAccount, 14 | vaultAddress: string | null, 15 | nonce: number 16 | ): Promise => { 17 | console.log("signStandardL1Action", action, vaultAddress, nonce); 18 | const phantomAgent = { 19 | source: isMainnet ? "a" : "b", 20 | connectionId: hashAction(action, vaultAddress, nonce), 21 | }; 22 | const payloadToSign = { 23 | domain: { 24 | name: "Exchange", 25 | version: "1", 26 | chainId: 1337, 27 | verifyingContract: "0x0000000000000000000000000000000000000000" as const, 28 | }, 29 | types: { 30 | Agent: [ 31 | { name: "source", type: "string" }, 32 | { name: "connectionId", type: "bytes32" }, 33 | ], 34 | }, 35 | primaryType: AGENT, 36 | message: phantomAgent, 37 | } as const; 38 | 39 | const signedAgent = await wallet.signTypedData(payloadToSign); 40 | 41 | // const signature = SplitSignature.from(signedAgent) 42 | // const signatureHex = 43 | // signature.r + signature.s.slice(2) + signature.v.toString(16) 44 | // 45 | // const recoveredAddress = await recoverTypedDataAddress({ 46 | // domain: phantomDomain, 47 | // types: orderTypedData, 48 | // primaryType: AGENT, 49 | // message: phantomAgent, 50 | // signature: signatureHex as `0x${string}`, 51 | // }) 52 | // console.log('recoveredAddress', recoveredAddress) 53 | 54 | return SplitSignature.from(signedAgent); 55 | }; 56 | 57 | export const hashAction = ( 58 | action: unknown, 59 | vaultAddress: string | null, 60 | nonce: number 61 | ): Hex => { 62 | const msgPackBytes = encode(action); 63 | const additionalBytesLength = vaultAddress === null ? 9 : 29; 64 | const data = new Uint8Array(msgPackBytes.length + additionalBytesLength); 65 | data.set(msgPackBytes); 66 | const view = new DataView(data.buffer, data.byteOffset, data.byteLength); 67 | view.setBigUint64(msgPackBytes.length, BigInt(nonce)); 68 | if (vaultAddress === null) { 69 | view.setUint8(msgPackBytes.length + 8, 0); 70 | } else { 71 | view.setUint8(msgPackBytes.length + 8, 1); 72 | data.set(addressToBytes(vaultAddress), msgPackBytes.length + 9); 73 | } 74 | return keccak256(data); 75 | }; 76 | 77 | export const addressToBytes = (address: string): Uint8Array => { 78 | const hex = address.startsWith("0x") ? address.substring(2) : address; 79 | return Uint8Array.from(Buffer.from(hex, "hex")); 80 | }; 81 | 82 | export const splitSig = (sig: string): Signature => { 83 | sig = sig.slice(2); 84 | if (sig.length !== 130) { 85 | throw new Error(`bad sig length: ${sig.length}`); 86 | } 87 | const vv = sig.slice(-2); 88 | 89 | // Ledger returns 0/1 instead of 27/28, so we accept both 90 | if (vv !== "1c" && vv !== "1b" && vv !== "00" && vv !== "01") { 91 | throw new Error(`bad sig v ${vv}`); 92 | } 93 | const v = vv === "1b" || vv === "00" ? 27 : 28; 94 | const r = "0x" + sig.slice(0, 64); 95 | const s = "0x" + sig.slice(64, 128); 96 | return { r, s, v }; 97 | }; 98 | -------------------------------------------------------------------------------- /src/lib/types/order.ts: -------------------------------------------------------------------------------- 1 | import { config } from "@/lib/config"; 2 | import { Decimal } from "decimal.js"; 3 | 4 | type Tif = "Alo" | "Ioc" | "Gtc"; 5 | 6 | type Tpsl = "tp" | "sl"; 7 | 8 | export type LimitOrderType = { 9 | tif: Tif; 10 | }; 11 | 12 | export type TriggerOrderTypeWire = { 13 | triggerPx: string; 14 | isMarket: boolean; 15 | tpsl: Tpsl; 16 | }; 17 | 18 | export type OrderTypeWire = { 19 | limit?: LimitOrderType; 20 | trigger?: TriggerOrderTypeWire; 21 | }; 22 | 23 | export type OrderWire = { 24 | a: number; 25 | b: boolean; 26 | p: string; 27 | s: string; 28 | r: boolean; 29 | t: OrderTypeWire; 30 | c?: string; 31 | }; 32 | 33 | export type TriggerOrderType = { 34 | triggerPx: number; 35 | isMarket: boolean; 36 | tpsl: Tpsl; 37 | }; 38 | 39 | export type OrderType = { 40 | limit?: LimitOrderType; 41 | trigger?: TriggerOrderType; 42 | }; 43 | 44 | class Cloid { 45 | private _rawCloid: string; 46 | 47 | constructor(rawCloid: string) { 48 | this._rawCloid = rawCloid; 49 | this._validate(); 50 | } 51 | 52 | private _validate(): void { 53 | if (!this._rawCloid.startsWith("0x")) { 54 | throw new Error("cloid is not a hex string"); 55 | } 56 | if (this._rawCloid.slice(2).length !== 32) { 57 | throw new Error("cloid is not 16 bytes"); 58 | } 59 | } 60 | 61 | static fromInt(cloid: number): Cloid { 62 | return new Cloid(`0x${cloid.toString(16).padStart(32, "0")}`); 63 | } 64 | 65 | static fromStr(cloid: string): Cloid { 66 | return new Cloid(cloid); 67 | } 68 | 69 | toRaw(): string { 70 | return this._rawCloid; 71 | } 72 | } 73 | 74 | export type OrderRequest = { 75 | coin: string; 76 | is_buy: boolean; 77 | sz: number; 78 | limit_px: number; 79 | order_type: OrderType; 80 | reduce_only: boolean; 81 | cloid?: Cloid | null; 82 | }; 83 | 84 | export const floatToWire = (x: number): string => { 85 | const rounded = x.toFixed(8); 86 | if (Math.abs(parseFloat(rounded) - x) >= 1e-12) { 87 | throw new Error("floatToWire causes rounding"); 88 | } 89 | if (rounded === "-0") { 90 | return "0"; 91 | } 92 | return new Decimal(rounded).toString(); 93 | }; 94 | 95 | function orderTypeToWire(orderType: OrderType): OrderTypeWire { 96 | if ("limit" in orderType) { 97 | return { limit: orderType.limit }; 98 | } else if ("trigger" in orderType && orderType.trigger) { 99 | return { 100 | trigger: { 101 | isMarket: orderType.trigger.isMarket, 102 | triggerPx: floatToWire(orderType.trigger.triggerPx), 103 | tpsl: orderType.trigger.tpsl, 104 | }, 105 | }; 106 | } 107 | throw new Error("Invalid order type"); 108 | } 109 | 110 | export const orderRequestToOrderWire = ( 111 | order: OrderRequest, 112 | asset: number 113 | ): OrderWire => { 114 | const orderWire: OrderWire = { 115 | a: asset, 116 | b: order.is_buy, 117 | p: floatToWire(order.limit_px), 118 | s: floatToWire(order.sz), 119 | r: order.reduce_only, 120 | t: orderTypeToWire(order.order_type), 121 | }; 122 | 123 | if (order.cloid) { 124 | orderWire.c = order.cloid.toRaw(); 125 | } 126 | 127 | return orderWire; 128 | }; 129 | 130 | export const orderWiresToOrderAction = (orderWires: OrderWire[]) => { 131 | return { 132 | type: "order", 133 | orders: orderWires, 134 | grouping: "na" as const, 135 | builder: { 136 | b: config.builderAddress.toLowerCase(), 137 | f: 1, 138 | }, 139 | }; 140 | }; 141 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react" 5 | 6 | import type { 7 | ToastActionElement, 8 | ToastProps, 9 | } from "@/components/ui/toast" 10 | 11 | const TOAST_LIMIT = 1 12 | const TOAST_REMOVE_DELAY = 1000000 13 | 14 | type ToasterToast = ToastProps & { 15 | id: string 16 | title?: React.ReactNode 17 | description?: React.ReactNode 18 | action?: ToastActionElement 19 | } 20 | 21 | const actionTypes = { 22 | ADD_TOAST: "ADD_TOAST", 23 | UPDATE_TOAST: "UPDATE_TOAST", 24 | DISMISS_TOAST: "DISMISS_TOAST", 25 | REMOVE_TOAST: "REMOVE_TOAST", 26 | } as const 27 | 28 | let count = 0 29 | 30 | function genId() { 31 | count = (count + 1) % Number.MAX_SAFE_INTEGER 32 | return count.toString() 33 | } 34 | 35 | type ActionType = typeof actionTypes 36 | 37 | type Action = 38 | | { 39 | type: ActionType["ADD_TOAST"] 40 | toast: ToasterToast 41 | } 42 | | { 43 | type: ActionType["UPDATE_TOAST"] 44 | toast: Partial 45 | } 46 | | { 47 | type: ActionType["DISMISS_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | | { 51 | type: ActionType["REMOVE_TOAST"] 52 | toastId?: ToasterToast["id"] 53 | } 54 | 55 | interface State { 56 | toasts: ToasterToast[] 57 | } 58 | 59 | const toastTimeouts = new Map>() 60 | 61 | const addToRemoveQueue = (toastId: string) => { 62 | if (toastTimeouts.has(toastId)) { 63 | return 64 | } 65 | 66 | const timeout = setTimeout(() => { 67 | toastTimeouts.delete(toastId) 68 | dispatch({ 69 | type: "REMOVE_TOAST", 70 | toastId: toastId, 71 | }) 72 | }, TOAST_REMOVE_DELAY) 73 | 74 | toastTimeouts.set(toastId, timeout) 75 | } 76 | 77 | export const reducer = (state: State, action: Action): State => { 78 | switch (action.type) { 79 | case "ADD_TOAST": 80 | return { 81 | ...state, 82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 83 | } 84 | 85 | case "UPDATE_TOAST": 86 | return { 87 | ...state, 88 | toasts: state.toasts.map((t) => 89 | t.id === action.toast.id ? { ...t, ...action.toast } : t 90 | ), 91 | } 92 | 93 | case "DISMISS_TOAST": { 94 | const { toastId } = action 95 | 96 | // ! Side effects ! - This could be extracted into a dismissToast() action, 97 | // but I'll keep it here for simplicity 98 | if (toastId) { 99 | addToRemoveQueue(toastId) 100 | } else { 101 | state.toasts.forEach((toast) => { 102 | addToRemoveQueue(toast.id) 103 | }) 104 | } 105 | 106 | return { 107 | ...state, 108 | toasts: state.toasts.map((t) => 109 | t.id === toastId || toastId === undefined 110 | ? { 111 | ...t, 112 | open: false, 113 | } 114 | : t 115 | ), 116 | } 117 | } 118 | case "REMOVE_TOAST": 119 | if (action.toastId === undefined) { 120 | return { 121 | ...state, 122 | toasts: [], 123 | } 124 | } 125 | return { 126 | ...state, 127 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 128 | } 129 | } 130 | } 131 | 132 | const listeners: Array<(state: State) => void> = [] 133 | 134 | let memoryState: State = { toasts: [] } 135 | 136 | function dispatch(action: Action) { 137 | memoryState = reducer(memoryState, action) 138 | listeners.forEach((listener) => { 139 | listener(memoryState) 140 | }) 141 | } 142 | 143 | type Toast = Omit 144 | 145 | function toast({ ...props }: Toast) { 146 | const id = genId() 147 | 148 | const update = (props: ToasterToast) => 149 | dispatch({ 150 | type: "UPDATE_TOAST", 151 | toast: { ...props, id }, 152 | }) 153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 154 | 155 | dispatch({ 156 | type: "ADD_TOAST", 157 | toast: { 158 | ...props, 159 | id, 160 | open: true, 161 | onOpenChange: (open) => { 162 | if (!open) dismiss() 163 | }, 164 | }, 165 | }) 166 | 167 | return { 168 | id: id, 169 | dismiss, 170 | update, 171 | } 172 | } 173 | 174 | function useToast() { 175 | const [state, setState] = React.useState(memoryState) 176 | 177 | React.useEffect(() => { 178 | listeners.push(setState) 179 | return () => { 180 | const index = listeners.indexOf(setState) 181 | if (index > -1) { 182 | listeners.splice(index, 1) 183 | } 184 | } 185 | }, [state]) 186 | 187 | return { 188 | ...state, 189 | toast, 190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 191 | } 192 | } 193 | 194 | export { useToast, toast } 195 | -------------------------------------------------------------------------------- /src/components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ToastPrimitives from "@radix-ui/react-toast" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const ToastProvider = ToastPrimitives.Provider 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: 34 | "destructive group border-destructive bg-destructive text-destructive-foreground", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | } 41 | ) 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & 46 | VariantProps 47 | >(({ className, variant, ...props }, ref) => { 48 | return ( 49 | 54 | ) 55 | }) 56 | Toast.displayName = ToastPrimitives.Root.displayName 57 | 58 | const ToastAction = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )) 71 | ToastAction.displayName = ToastPrimitives.Action.displayName 72 | 73 | const ToastClose = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, ...props }, ref) => ( 77 | 86 | 87 | 88 | )) 89 | ToastClose.displayName = ToastPrimitives.Close.displayName 90 | 91 | const ToastTitle = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName 102 | 103 | const ToastDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName 114 | 115 | type ToastProps = React.ComponentPropsWithoutRef 116 | 117 | type ToastActionElement = React.ReactElement 118 | 119 | export { 120 | type ToastProps, 121 | type ToastActionElement, 122 | ToastProvider, 123 | ToastViewport, 124 | Toast, 125 | ToastTitle, 126 | ToastDescription, 127 | ToastClose, 128 | ToastAction, 129 | } 130 | -------------------------------------------------------------------------------- /src/lib/services/market-order.ts: -------------------------------------------------------------------------------- 1 | import axios from "@/lib/config/axios"; 2 | import { privateKeyToAccount } from "viem/accounts"; 3 | import { signStandardL1Action } from "@/lib/helpers/signing"; 4 | import { config } from "@/lib/config"; 5 | import { 6 | OrderRequest, 7 | orderWiresToOrderAction, 8 | orderRequestToOrderWire, 9 | } from "@/lib/types/order"; 10 | import { Signature } from "@/lib/types/signature"; 11 | 12 | type Order = { 13 | baseToken: string; 14 | quoteToken: string; 15 | order: OrderRequest; 16 | }; 17 | 18 | export async function submitMarketOrder(orderDto: Order, pk: `0x${string}`) { 19 | try { 20 | const pairIndex = await requestAssetId( 21 | orderDto.baseToken, 22 | orderDto.quoteToken 23 | ); 24 | 25 | if (pairIndex > -1) { 26 | const nonce = Date.now(); 27 | const account = privateKeyToAccount(pk); 28 | 29 | const calculatedAssetId = 10000 + pairIndex; 30 | const orderWire = orderRequestToOrderWire( 31 | orderDto.order, 32 | calculatedAssetId 33 | ); 34 | const orderAction = orderWiresToOrderAction([orderWire]); 35 | 36 | const signature: Signature = await signStandardL1Action( 37 | orderAction, 38 | account, 39 | null, 40 | nonce 41 | ); 42 | 43 | const orderRequest = { 44 | action: { 45 | type: "order", 46 | orders: [ 47 | { 48 | a: calculatedAssetId, 49 | b: orderDto.order.is_buy, 50 | p: orderDto.order.limit_px.toString(), 51 | s: orderDto.order.sz.toString(), 52 | r: false, 53 | t: { 54 | limit: { 55 | tif: "Ioc", 56 | }, 57 | }, 58 | }, 59 | ], 60 | grouping: "na", 61 | builder: { 62 | b: config.builderAddress.toLowerCase(), 63 | f: 1, 64 | }, 65 | }, 66 | nonce: nonce, 67 | signature: signature, 68 | vaultAddress: null, 69 | }; 70 | 71 | const result = await axios.post("/exchange", orderRequest); 72 | 73 | if (result.data) { 74 | return result.data; 75 | } 76 | } 77 | throw new Error("Failed to get valid pair index"); 78 | } catch (e) { 79 | console.error("Error submitting market order:", e); 80 | throw e; 81 | } 82 | } 83 | 84 | type SpotMetaResponse = { 85 | universe: Array<{ 86 | tokens: [number, number]; 87 | name: string; 88 | index: number; 89 | isCanonical: boolean; 90 | }>; 91 | tokens: Array<{ 92 | name: string; 93 | szDecimals: number; 94 | weiDecimals: number; 95 | index: number; 96 | tokenId: string; 97 | isCanonical: boolean; 98 | }>; 99 | }; 100 | 101 | async function requestAssetId( 102 | baseToken: string, 103 | quoteToken: string 104 | ): Promise { 105 | const response = await axios.post(`/info`, { 106 | type: "spotMeta", 107 | }); 108 | 109 | if (response.data) { 110 | const data: SpotMetaResponse = response.data; 111 | const base = data.tokens.find((token) => token.name == baseToken); 112 | const quote = data.tokens.find((token) => token.name == quoteToken); 113 | 114 | if (base && quote) { 115 | const pair = data.universe.find((pair) => { 116 | const [baseIndex, quoteIndex] = pair.tokens; 117 | return baseIndex === base.index && quoteIndex === quote.index; 118 | }); 119 | 120 | if (pair) { 121 | return pair.index; 122 | } 123 | } 124 | } 125 | 126 | return -1; 127 | } 128 | 129 | interface ConstructOrderParams { 130 | amount: string; 131 | isBuy: boolean; 132 | slippage: number; 133 | midPrice: number; 134 | tokenDecimals: number; 135 | } 136 | 137 | export function constructOrder({ 138 | amount, 139 | isBuy, 140 | slippage, 141 | midPrice, 142 | tokenDecimals, 143 | }: ConstructOrderParams) { 144 | const priceWithSlippage = calculateSlippagePrice( 145 | midPrice, 146 | isBuy, 147 | slippage, 148 | true 149 | ); 150 | 151 | console.log({ midPrice, priceWithSlippage }); 152 | 153 | // Calculate size based on direction 154 | let size = isBuy ? parseFloat(amount) / midPrice : parseFloat(amount); 155 | 156 | // Adjust size precision based on token decimals 157 | size = 158 | Math.floor(size * Math.pow(10, tokenDecimals)) / 159 | Math.pow(10, tokenDecimals); 160 | 161 | const orderRequest: OrderRequest = { 162 | coin: "HYPE", 163 | is_buy: isBuy, 164 | sz: size, 165 | limit_px: parseFloat(priceWithSlippage), 166 | reduce_only: false, 167 | order_type: { 168 | limit: { tif: "Ioc" }, 169 | }, 170 | }; 171 | 172 | return { 173 | baseToken: "HYPE", 174 | quoteToken: "USDC", 175 | order: orderRequest, 176 | }; 177 | } 178 | 179 | export const calculateSlippagePrice = ( 180 | price: number, 181 | isBuy: boolean, 182 | slippage: number, 183 | isSpot: boolean 184 | ): string => { 185 | // Calculate price with slippage 186 | const adjustedPrice = price * (isBuy ? 1 + slippage : 1 - slippage); 187 | 188 | // Convert to string with 5 significant figures 189 | const roundedPrice = Number(adjustedPrice.toPrecision(5)); 190 | 191 | // Round to appropriate decimal places (8 for spot, 6 for perps) 192 | const decimalPlaces = isSpot ? 8 : 6; 193 | return roundedPrice.toFixed(decimalPlaces); 194 | }; 195 | -------------------------------------------------------------------------------- /src/components/TradeForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useRef } from "react"; 4 | import { useAccount } from "wagmi"; 5 | import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; 6 | import { Label } from "./ui/label"; 7 | import { Input } from "./ui/input"; 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardHeader, 13 | CardTitle, 14 | } from "./ui/card"; 15 | import { Button } from "./ui/button"; 16 | import { Separator } from "./ui/separator"; 17 | import { fetchTokenDetails } from "@/lib/services/token-details"; 18 | import { fetchMidPrice } from "@/lib/services/mid-price"; 19 | import { fetchBalances } from "@/lib/services/balances"; 20 | import { constructOrder, submitMarketOrder } from "@/lib/services/market-order"; 21 | import useUserStore from "@/lib/store"; 22 | import { toast } from "@/hooks/use-toast"; 23 | 24 | interface TradeFormProps { 25 | type: "buy" | "sell"; 26 | } 27 | 28 | export default function TradeForm({ type }: TradeFormProps) { 29 | // Constants 30 | const isBuy = type === "buy"; 31 | const actionText = isBuy ? "Buy" : "Sell"; 32 | const DEFAULT_SLIPPAGE = 0; 33 | 34 | // Hooks 35 | const { address } = useAccount(); 36 | const user = useUserStore((state) => state.user); 37 | const formRef = useRef(null); 38 | const queryClient = useQueryClient(); 39 | 40 | // Local State 41 | const [formState, setFormState] = useState<{ 42 | amount: string; 43 | slippage: number; 44 | }>({ 45 | amount: "", 46 | slippage: DEFAULT_SLIPPAGE, 47 | }); 48 | const [totalAmount, setTotalAmount] = useState(0); 49 | 50 | // Queries 51 | const { data: balanceData } = useQuery({ 52 | queryKey: ["balances", address], 53 | queryFn: () => (address ? fetchBalances(address) : null), 54 | enabled: !!address, 55 | }); 56 | 57 | const { data: midPriceData = 0 } = useQuery({ 58 | queryKey: ["midPrice", "@1035"], 59 | queryFn: () => fetchMidPrice("@1035"), 60 | }); 61 | 62 | const { data: tokenDetails } = useQuery({ 63 | queryKey: ["tokenDetails", "0x7317beb7cceed72ef0b346074cc8e7ab"], 64 | queryFn: () => fetchTokenDetails("0x7317beb7cceed72ef0b346074cc8e7ab"), 65 | }); 66 | 67 | // Mutation 68 | const { mutate: submitOrder, isPending } = useMutation({ 69 | mutationFn: async (formData: FormData) => { 70 | if (!midPriceData || !user?.agent?.privateKey || !tokenDetails) { 71 | throw new Error("Missing required data"); 72 | } 73 | 74 | const amount = formData.get("amount") as string; 75 | const slippage = 76 | parseFloat(formData.get("slippage") as string) / 100 || 0; 77 | 78 | const order = constructOrder({ 79 | amount, 80 | isBuy, 81 | slippage, 82 | midPrice: midPriceData, 83 | tokenDecimals: tokenDetails.szDecimals, 84 | }); 85 | 86 | return submitMarketOrder(order, user.agent.privateKey); 87 | }, 88 | onSuccess: async (res) => { 89 | if (res?.status === "ok") { 90 | const statuses: { [key: string]: string }[] = 91 | res?.response?.data?.statuses; 92 | 93 | statuses.forEach(async (status) => { 94 | if (status?.error) { 95 | toast({ 96 | title: "Something went wrong", 97 | description: status.error, 98 | }); 99 | } else if (status?.filled) { 100 | toast({ 101 | title: "Success", 102 | description: "Order submitted successfully", 103 | }); 104 | 105 | // Reset form 106 | formRef.current?.reset(); 107 | setFormState({ amount: "", slippage: DEFAULT_SLIPPAGE }); 108 | setTotalAmount(0); 109 | 110 | // Revalidate queries 111 | await Promise.all([ 112 | queryClient.invalidateQueries({ 113 | queryKey: ["balances", address], 114 | }), 115 | queryClient.invalidateQueries({ 116 | queryKey: ["midPrice", "@1035"], 117 | }), 118 | queryClient.invalidateQueries({ 119 | queryKey: [ 120 | "tokenDetails", 121 | "0x7317beb7cceed72ef0b346074cc8e7ab", 122 | ], 123 | }), 124 | ]); 125 | } 126 | }); 127 | } else { 128 | console.log("res", res); 129 | toast({ 130 | title: "Error", 131 | description: "Failed to submit order", 132 | }); 133 | } 134 | }, 135 | onError: (error) => { 136 | console.error("Error submitting order:", error); 137 | toast({ 138 | title: "Error", 139 | description: "Failed to submit order", 140 | }); 141 | }, 142 | }); 143 | 144 | // Derived State 145 | const userBalances = { 146 | usdc: 147 | balanceData?.balances?.find((balance) => balance.coin === "USDC") 148 | ?.total ?? 0, 149 | hype: 150 | balanceData?.balances?.find((balance) => balance.coin === "HYPE") 151 | ?.total ?? 0, 152 | }; 153 | 154 | // Handlers 155 | const calculateTotalAmount = (amount: string) => { 156 | if (!midPriceData || !amount) return 0; 157 | return isBuy 158 | ? Math.floor( 159 | (parseFloat(amount) / midPriceData) * 160 | Math.pow(10, tokenDetails?.szDecimals || 6) 161 | ) / Math.pow(10, tokenDetails?.szDecimals || 6) 162 | : parseFloat(amount); 163 | }; 164 | 165 | const handleAmountChange = (e: React.ChangeEvent) => { 166 | const amount = e.target.value; 167 | setFormState((prev) => ({ ...prev, amount })); 168 | setTotalAmount(calculateTotalAmount(amount)); 169 | }; 170 | 171 | const orderValue = midPriceData 172 | ? `${(totalAmount * midPriceData).toFixed(2)} USDC` 173 | : "Calculating..."; 174 | 175 | const handleSubmit = async (e: React.FormEvent) => { 176 | e.preventDefault(); 177 | 178 | if (!address || !user?.agent?.privateKey) { 179 | toast({ 180 | title: "Error", 181 | description: "Please connect your wallet", 182 | }); 183 | return; 184 | } 185 | 186 | submitOrder(new FormData(e.currentTarget)); 187 | }; 188 | 189 | return ( 190 | 191 | 192 | 193 | {isBuy ? "Buy $HYPE" : "Sell $HYPE"} 194 | 195 | 196 |
197 |
USDC Balance
198 |
199 | {userBalances.usdc} USDC 200 |
201 |
HYPE Balance
202 |
203 | {userBalances.hype} HYPE 204 |
205 |
206 |
207 |
208 | 209 | 210 |
211 |
212 |
213 | 216 | 222 |
223 | 224 |
225 | 226 | 231 |
232 |
233 | 234 |
235 |
Market Value
236 |
237 | 1 HYPE = {midPriceData} USDC 238 |
239 | 240 |
241 | Estimated {isBuy ? "HYPE to buy" : "USDC to receive"} 242 |
243 |
244 | {isBuy 245 | ? totalAmount.toFixed(tokenDetails?.szDecimals || 6) 246 | : (totalAmount * midPriceData).toFixed(2)} 247 |
248 | 249 |
Order Value
250 |
251 | {orderValue} 252 |
253 |
254 | 255 | 256 | 257 | 260 | 261 |
262 |
263 | ); 264 | } 265 | --------------------------------------------------------------------------------