├── 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 | 
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 |
261 |
262 |
263 | );
264 | }
265 |
--------------------------------------------------------------------------------