├── .prettierrc ├── .prettierignore ├── readme-resources └── fills.png ├── vercel.json ├── vite.config.js ├── .gitignore ├── index.html ├── postcss.config.cjs ├── .github └── workflows │ └── prettier.yml ├── src ├── query-client.ts ├── routes │ ├── root.tsx │ ├── fetch-usd-prices.tsx │ ├── home.tsx │ ├── trades-csv.ts │ ├── orders.tsx │ └── trades.tsx ├── number-display.ts ├── mint-data.ts ├── main.tsx ├── types.ts ├── jupiter-api.ts └── token-prices.ts ├── tsconfig.json ├── README.md ├── LICENSE ├── eslint.config.js ├── public └── vite.svg └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json -------------------------------------------------------------------------------- /readme-resources/fills.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcintyre94/Jupalyse/HEAD/readme-resources/fills.png -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Jupalyse 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-preset-mantine": {}, 4 | "postcss-simple-vars": { 5 | variables: { 6 | "mantine-breakpoint-xs": "36em", 7 | "mantine-breakpoint-sm": "48em", 8 | "mantine-breakpoint-md": "62em", 9 | "mantine-breakpoint-lg": "75em", 10 | "mantine-breakpoint-xl": "88em", 11 | }, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Prettier Check 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | prettier: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: "lts/*" 20 | cache: "npm" 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Check formatting 26 | run: npx prettier --check . 27 | -------------------------------------------------------------------------------- /src/query-client.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | import { persistQueryClient } from "@tanstack/query-persist-client-core"; 3 | import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; 4 | 5 | export const queryClient = new QueryClient(); 6 | queryClient.setDefaultOptions({ 7 | // used for restored data 8 | queries: { 9 | staleTime: Infinity, 10 | gcTime: Infinity, 11 | }, 12 | }); 13 | 14 | const persister = createSyncStoragePersister({ 15 | storage: window.localStorage, 16 | key: "react-query-cache", 17 | }); 18 | 19 | persistQueryClient({ 20 | queryClient, 21 | persister, 22 | }); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/routes/root.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Stack } from "@mantine/core"; 2 | import { Outlet } from "react-router-dom"; 3 | 4 | import { Anchor, Group, Text } from "@mantine/core"; 5 | 6 | export default function Root() { 7 | return ( 8 | 18 | 19 | 20 | 21 | 22 | 23 | Built by{" "} 24 | 25 | @callum_codes 26 | 27 | 28 | 29 | View source 30 | 31 | Not associated with Jupiter 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jupalyse 2 | 3 | Jupiter DCAs are awesome, but it can be difficult to keep track of them and they can be a nightmare at tax time! 4 | 5 | Jupalyse is a simple tool to help you keep track of your Jupiter DCAs. 6 | 7 | ![screenshot showing DCA trades in a table](./readme-resources/fills.png) 8 | 9 | ## Features 10 | 11 | - View all Jupiter DCAs for any address 12 | - View all trades in an interactive table 13 | - Download a CSV with the data in a format your tax person probably won't hate 14 | - Private: Runs entirely locally, your data is never sent anywhere 15 | 16 | ## Try it out 17 | 18 | Visit https://jupalyse.vercel.app to get started. 19 | 20 | ## Running locally 21 | 22 | ```sh 23 | npm install 24 | npm run dev 25 | ``` 26 | 27 | ## Shout outs 28 | 29 | - [Jupiter](https://jup.ag) for the DCA API (and DCA!) 30 | - [Solflare](https://github.com/solflare-wallet/utl-api) for their awesome token API! 31 | 32 | ## Tech stuff 33 | 34 | - Built with [React](https://react.dev) + [React Router](https://reactrouter.com) 35 | - UI components from [Mantine](https://mantine.dev) 36 | 37 | ## License 38 | 39 | MIT 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Callum McIntyre 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 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import react from "eslint-plugin-react"; 4 | import reactHooks from "eslint-plugin-react-hooks"; 5 | import reactRefresh from "eslint-plugin-react-refresh"; 6 | 7 | export default [ 8 | { ignores: ["dist"] }, 9 | { 10 | files: ["**/*.{js,jsx}"], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | parserOptions: { 15 | ecmaVersion: "latest", 16 | ecmaFeatures: { jsx: true }, 17 | sourceType: "module", 18 | }, 19 | }, 20 | settings: { react: { version: "18.3" } }, 21 | plugins: { 22 | react, 23 | "react-hooks": reactHooks, 24 | "react-refresh": reactRefresh, 25 | }, 26 | rules: { 27 | ...js.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs["jsx-runtime"].rules, 30 | ...reactHooks.configs.recommended.rules, 31 | "react/jsx-no-target-blank": "off", 32 | "react-refresh/only-export-components": [ 33 | "warn", 34 | { allowConstantExport: true }, 35 | ], 36 | }, 37 | }, 38 | ]; 39 | -------------------------------------------------------------------------------- /src/routes/fetch-usd-prices.tsx: -------------------------------------------------------------------------------- 1 | import { ActionFunctionArgs } from "react-router-dom"; 2 | import { fetchTokenPrices } from "../token-prices"; 3 | import { TokenPricesToFetch } from "../types"; 4 | 5 | export async function action({ request }: ActionFunctionArgs) { 6 | const formData = await request.formData(); 7 | const birdeyeApiKey = formData.get("birdeyeApiKey")?.toString(); 8 | const rememberApiKeyValue = formData.get("rememberApiKey")?.toString(); 9 | const rememberApiKey = rememberApiKeyValue === "on"; 10 | 11 | if (!birdeyeApiKey) { 12 | throw new Error("Birdeye API key is required"); 13 | } 14 | 15 | const tokenPricesToFetchField = formData.get("tokenPricesToFetch"); 16 | 17 | if (!tokenPricesToFetchField) { 18 | throw new Error("Token prices to fetch is required"); 19 | } 20 | 21 | const tokenPricesToFetch = JSON.parse( 22 | tokenPricesToFetchField.toString(), 23 | ) as TokenPricesToFetch; 24 | 25 | const tokenPrices = await fetchTokenPrices( 26 | tokenPricesToFetch, 27 | birdeyeApiKey, 28 | request.signal, 29 | ); 30 | 31 | if (rememberApiKey) { 32 | localStorage.setItem("birdeyeApiKey", birdeyeApiKey); 33 | } else { 34 | localStorage.removeItem("birdeyeApiKey"); 35 | } 36 | 37 | return tokenPrices; 38 | } 39 | -------------------------------------------------------------------------------- /src/number-display.ts: -------------------------------------------------------------------------------- 1 | import { StringifiedNumber } from "./types"; 2 | 3 | export function numberDisplay(value: StringifiedNumber, decimals: number) { 4 | const formatter = Intl.NumberFormat("en-US", { 5 | maximumFractionDigits: decimals, 6 | }); 7 | 8 | // @ts-expect-error Typescript doesn't know about this format 9 | return formatter.format(`${value}E-${decimals}`); 10 | } 11 | 12 | export function numberDisplayAlreadyAdjustedForDecimals( 13 | value: StringifiedNumber, 14 | ) { 15 | const formatter = Intl.NumberFormat("en-US"); 16 | // @ts-expect-error Typescript doesn't know about this format 17 | // we use the formatter to get better display, eg `123456` -> `123,456` 18 | return formatter.format(`${value}`); 19 | } 20 | 21 | const usdTwoDecimalsFormatter = Intl.NumberFormat("en-US", { 22 | style: "currency", 23 | currency: "USD", 24 | minimumFractionDigits: 2, 25 | maximumFractionDigits: 2, 26 | }); 27 | 28 | const usdEightDecimalsFormatter = Intl.NumberFormat("en-US", { 29 | style: "currency", 30 | currency: "USD", 31 | minimumFractionDigits: 2, 32 | maximumFractionDigits: 8, 33 | }); 34 | 35 | export function usdAmountDisplay(value: number) { 36 | if (value < 0.00000001) { 37 | return "<$0.00000001"; 38 | } 39 | 40 | const formatter = 41 | value < 1 ? usdEightDecimalsFormatter : usdTwoDecimalsFormatter; 42 | return formatter.format(value); 43 | } 44 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jupalyse", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@mantine/core": "^7.13.3", 14 | "@mantine/hooks": "^7.13.3", 15 | "@solana/web3.js": "^2.0.0-rc.1", 16 | "@tabler/icons-react": "^3.19.0", 17 | "@tanstack/query-persist-client-core": "^5.62.0", 18 | "@tanstack/query-sync-storage-persister": "^5.62.0", 19 | "@tanstack/react-query": "^5.62.0", 20 | "jdenticon": "^3.3.0", 21 | "js-big-decimal": "^2.1.0", 22 | "react": "^18.3.1", 23 | "react-dom": "^18.3.1", 24 | "react-router-dom": "^6.27.0" 25 | }, 26 | "devDependencies": { 27 | "@eslint/js": "^9.11.1", 28 | "@tanstack/react-query-devtools": "^5.61.0", 29 | "@types/react": "^18.3.10", 30 | "@types/react-dom": "^18.3.0", 31 | "@vitejs/plugin-react": "^4.3.2", 32 | "eslint": "^9.11.1", 33 | "eslint-plugin-react": "^7.37.0", 34 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 35 | "eslint-plugin-react-refresh": "^0.4.12", 36 | "globals": "^15.9.0", 37 | "postcss": "^8.4.47", 38 | "postcss-preset-mantine": "^1.17.0", 39 | "postcss-simple-vars": "^7.0.1", 40 | "prettier": "3.3.3", 41 | "typescript": "^5.5.3", 42 | "typescript-eslint": "^8.7.0", 43 | "vite": "^5.4.8" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/mint-data.ts: -------------------------------------------------------------------------------- 1 | import { Address } from "@solana/web3.js"; 2 | import { FetchMintsResponse, MintData } from "./types"; 3 | 4 | export async function getMintData(addresses: Address[]) { 5 | if (addresses.length === 0) { 6 | return []; 7 | } 8 | 9 | const url = "https://token-list-api.solana.cloud/v1/mints?chainId=101"; 10 | const response = await fetch(url, { 11 | method: "POST", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | body: JSON.stringify({ 16 | addresses, 17 | }), 18 | }); 19 | 20 | const data = (await response.json()) as FetchMintsResponse; 21 | 22 | const fetchedMints = data.content.map((item) => item.address); 23 | const missingMints = addresses.filter( 24 | (address) => !fetchedMints.includes(address), 25 | ); 26 | 27 | if (missingMints.length > 0) { 28 | // use Jup token list to fetch missing mints 29 | // Jup has a low rate limit so use as fallback 30 | const jupFallbackDataResults = await Promise.allSettled( 31 | missingMints.map(async (address) => { 32 | const response = await fetch(`https://tokens.jup.ag/token/${address}`); 33 | // Jup returns the same structure 34 | if (response.status === 200) { 35 | const mintData = (await response.json()) as MintData; 36 | return [mintData]; 37 | } 38 | return []; 39 | }), 40 | ); 41 | 42 | const jupMintData = jupFallbackDataResults 43 | .filter((result) => result.status === "fulfilled") 44 | .flatMap((result) => result.value); 45 | 46 | return [...data.content, ...jupMintData]; 47 | } 48 | 49 | return data.content; 50 | } 51 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 4 | import Root from "./routes/root"; 5 | import HomeRoute, { action as HomeAction } from "./routes/home"; 6 | import OrdersRoute, { loader as OrdersLoader } from "./routes/orders"; 7 | import TradesRoute, { loader as TradesLoader } from "./routes/trades"; 8 | import { action as TradesCsvAction } from "./routes/trades-csv"; 9 | import { action as FetchUsdPricesAction } from "./routes/fetch-usd-prices"; 10 | import { createTheme, MantineProvider } from "@mantine/core"; 11 | 12 | import "@mantine/core/styles.css"; 13 | import { QueryClientProvider } from "@tanstack/react-query"; 14 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 15 | import { queryClient } from "./query-client"; 16 | const router = createBrowserRouter([ 17 | { 18 | path: "/", 19 | element: , 20 | children: [ 21 | { 22 | index: true, 23 | element: , 24 | action: HomeAction, 25 | }, 26 | { 27 | path: "/orders/:address", 28 | element: , 29 | loader: OrdersLoader, 30 | }, 31 | { 32 | path: "/trades", 33 | element: , 34 | loader: TradesLoader, 35 | }, 36 | { 37 | path: "/trades/csv", 38 | action: TradesCsvAction, 39 | }, 40 | { 41 | path: "/trades/fetch-usd-prices", 42 | action: FetchUsdPricesAction, 43 | }, 44 | ], 45 | }, 46 | ]); 47 | 48 | const theme = createTheme({ 49 | spacing: { 50 | micro: "calc(0.25rem * var(--mantine-scale))", 51 | }, 52 | fontSizes: { 53 | h1: "calc(2.125rem * var(--mantine-scale))", 54 | }, 55 | }); 56 | 57 | ReactDOM.createRoot(document.getElementById("root")!).render( 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | , 66 | ); 67 | -------------------------------------------------------------------------------- /src/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Container, 4 | Stack, 5 | Text, 6 | TextInput, 7 | Title, 8 | } from "@mantine/core"; 9 | import { isAddress } from "@solana/web3.js"; 10 | import { useState } from "react"; 11 | import { Form, redirect, useNavigation } from "react-router-dom"; 12 | 13 | export async function action({ request }: { request: Request }) { 14 | const formData = await request.formData(); 15 | const address = formData.get("address")?.toString(); 16 | 17 | if (!address || !isAddress(address)) { 18 | throw new Error("Invalid address"); 19 | } 20 | 21 | return redirect(`/orders/${address}`); 22 | } 23 | 24 | export default function Home() { 25 | const [validAddress, setValidAddress] = useState( 26 | undefined, 27 | ); 28 | const addressColor = 29 | validAddress === false 30 | ? "red" 31 | : validAddress === true 32 | ? "green" 33 | : undefined; 34 | 35 | const navigation = useNavigation(); 36 | const isLoading = navigation.state === "loading"; 37 | 38 | return ( 39 | 40 | 41 | 42 | 54 | Jupalyse 55 | 56 | 57 | 58 | View and download your Jupiter orders 59 | 60 | 61 | 62 |
63 | 64 | setValidAddress(isAddress(e.target.value))} 71 | // error={validAddress === false} 72 | styles={{ 73 | input: { 74 | outline: addressColor 75 | ? `3px solid ${addressColor}` 76 | : undefined, 77 | }, 78 | }} 79 | /> 80 | 81 | 89 | 90 |
91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Address, Signature } from "@solana/web3.js"; 2 | 3 | type StringifiedDate = string & { __brand: "StringifiedDate" }; 4 | export type StringifiedNumber = string & { __brand: "StringifiedNumber" }; 5 | 6 | export type MintData = { 7 | address: Address; 8 | name: string; 9 | symbol: string; 10 | decimals: number; 11 | logoURI: string; 12 | }; 13 | 14 | export type FetchMintsResponse = { 15 | content: MintData[]; 16 | }; 17 | 18 | type JupiterTrade = { 19 | confirmedAt: StringifiedDate; 20 | inputMint: Address; 21 | outputMint: Address; 22 | /** Note: already adjusted for decimals */ 23 | inputAmount: StringifiedNumber; 24 | /** Note: already adjusted for decimals */ 25 | outputAmount: StringifiedNumber; 26 | /** Note: already adjusted for decimals */ 27 | feeAmount: StringifiedNumber; 28 | orderKey: Address; 29 | txId: Signature; 30 | }; 31 | 32 | export type RecurringOrderFetchedAccount = { 33 | recurringType: "time" | "price"; 34 | orderKey: Address; 35 | inputMint: Address; 36 | outputMint: Address; 37 | /** Note: already adjusted for decimals */ 38 | inDeposited: StringifiedNumber; 39 | /** Note: already adjusted for decimals */ 40 | outReceived: StringifiedNumber; 41 | cycleFrequency: number; 42 | /** Note: already adjusted for decimals */ 43 | inAmountPerCycle: StringifiedNumber; 44 | openTx: Signature; 45 | createdAt: StringifiedDate; 46 | trades: JupiterTrade[]; 47 | }; 48 | 49 | export type RecurringOrdersResponse = { 50 | all: RecurringOrderFetchedAccount[]; 51 | totalPages: number; 52 | page: number; 53 | }; 54 | 55 | export type TriggerOrderFetchedAccount = { 56 | orderKey: Address; 57 | inputMint: Address; 58 | outputMint: Address; 59 | /** Note: already adjusted for decimals */ 60 | makingAmount: StringifiedNumber; 61 | createdAt: StringifiedDate; 62 | status: "Completed" | "Cancelled" | "Open"; 63 | openTx: Signature; 64 | trades: JupiterTrade[]; 65 | }; 66 | 67 | export type TriggerOrdersResponse = { 68 | orders: TriggerOrderFetchedAccount[]; 69 | totalPages: number; 70 | page: number; 71 | }; 72 | 73 | export type AmountToDisplay = { 74 | amount: StringifiedNumber; 75 | adjustedForDecimals: boolean; 76 | }; 77 | 78 | export type OrderType = "recurring time" | "recurring price" | "trigger"; 79 | 80 | export type Deposit = { 81 | kind: "deposit"; 82 | date: Date; 83 | inputMint: Address; 84 | inputAmount: AmountToDisplay; 85 | orderType: OrderType; 86 | orderKey: Address; 87 | userAddress: Address; 88 | transactionSignature: Signature; 89 | }; 90 | 91 | export type Trade = { 92 | kind: "trade"; 93 | date: Date; 94 | inputMint: Address; 95 | outputMint: Address; 96 | inputAmount: AmountToDisplay; 97 | outputAmount: AmountToDisplay; 98 | fee: AmountToDisplay; 99 | orderType: OrderType; 100 | orderKey: Address; 101 | userAddress: Address; 102 | transactionSignature: Signature; 103 | }; 104 | 105 | export type Timestamp = number; 106 | 107 | export type TokenPricesToFetch = { 108 | [key: Address]: Timestamp[]; 109 | }; 110 | 111 | export type FetchedTokenPriceKey = `${Address}-${Timestamp}`; 112 | 113 | export type FetchedTokenPrice = number | "missing"; 114 | 115 | export type FetchedTokenPrices = { 116 | [key in FetchedTokenPriceKey]: FetchedTokenPrice; 117 | }; 118 | -------------------------------------------------------------------------------- /src/jupiter-api.ts: -------------------------------------------------------------------------------- 1 | import { Address } from "@solana/web3.js"; 2 | import { 3 | RecurringOrderFetchedAccount, 4 | RecurringOrdersResponse, 5 | TriggerOrderFetchedAccount, 6 | TriggerOrdersResponse, 7 | } from "./types"; 8 | import { queryClient } from "./query-client"; 9 | 10 | async function getRecurringOrdersHistoryImpl( 11 | address: Address, 12 | ): Promise { 13 | // Note that this API is paginated 14 | let page = 1; 15 | let totalPages = 1; 16 | const orders: RecurringOrderFetchedAccount[] = []; 17 | 18 | while (page <= totalPages) { 19 | const response = await fetch( 20 | `https://lite-api.jup.ag/recurring/v1/getRecurringOrders?user=${address}&orderStatus=history&recurringType=all&includeFailedTx=false&page=${page}`, 21 | ); 22 | if (response.status >= 400) { 23 | throw new Error("Error fetching past recurring orders from Jupiter"); 24 | } 25 | const data = (await response.json()) as RecurringOrdersResponse; 26 | orders.push(...data.all); 27 | totalPages = data.totalPages; 28 | page += 1; 29 | } 30 | return orders; 31 | } 32 | 33 | export async function getRecurringOrdersHistory( 34 | address: Address, 35 | ): Promise { 36 | return queryClient.fetchQuery({ 37 | queryKey: ["recurringOrdersHistory", address], 38 | queryFn: () => getRecurringOrdersHistoryImpl(address), 39 | }); 40 | } 41 | 42 | async function getRecurringOrdersActiveImpl( 43 | address: Address, 44 | ): Promise { 45 | // Note that this API is paginated 46 | let page = 1; 47 | let totalPages = 1; 48 | const orders: RecurringOrderFetchedAccount[] = []; 49 | 50 | while (page <= totalPages) { 51 | const response = await fetch( 52 | `https://lite-api.jup.ag/recurring/v1/getRecurringOrders?user=${address}&orderStatus=active&recurringType=all&includeFailedTx=false&page=${page}`, 53 | ); 54 | if (response.status >= 400) { 55 | throw new Error("Error fetching active recurring orders from Jupiter"); 56 | } 57 | const data = (await response.json()) as RecurringOrdersResponse; 58 | orders.push(...data.all); 59 | totalPages = data.totalPages; 60 | page += 1; 61 | } 62 | return orders; 63 | } 64 | 65 | export async function getRecurringOrdersActive( 66 | address: Address, 67 | ): Promise { 68 | return queryClient.fetchQuery({ 69 | queryKey: ["recurringOrdersActive", address], 70 | queryFn: () => getRecurringOrdersActiveImpl(address), 71 | }); 72 | } 73 | 74 | async function getTriggerOrdersHistoryImpl( 75 | address: Address, 76 | ): Promise { 77 | // Note that this API is paginated 78 | let page = 1; 79 | let totalPages = 1; 80 | const orders: TriggerOrderFetchedAccount[] = []; 81 | 82 | while (page <= totalPages) { 83 | const response = await fetch( 84 | `https://lite-api.jup.ag/trigger/v1/getTriggerOrders?user=${address}&orderStatus=history&page=${page}`, 85 | ); 86 | if (response.status >= 400) { 87 | throw new Error("Error fetching past trigger orders from Jupiter"); 88 | } 89 | const data = (await response.json()) as TriggerOrdersResponse; 90 | orders.push(...data.orders.filter((order) => order.trades.length > 0)); 91 | totalPages = data.totalPages; 92 | page += 1; 93 | } 94 | return orders; 95 | } 96 | 97 | export async function getTriggerOrdersHistory( 98 | address: Address, 99 | ): Promise { 100 | return queryClient.fetchQuery({ 101 | queryKey: ["triggerOrdersHistory", address], 102 | queryFn: () => getTriggerOrdersHistoryImpl(address), 103 | }); 104 | } 105 | 106 | async function getTriggerOrdersActiveImpl( 107 | address: Address, 108 | ): Promise { 109 | // Note that this API is paginated 110 | let page = 1; 111 | let totalPages = 1; 112 | const orders: TriggerOrderFetchedAccount[] = []; 113 | 114 | while (page <= totalPages) { 115 | const response = await fetch( 116 | `https://lite-api.jup.ag/trigger/v1/getTriggerOrders?user=${address}&orderStatus=active&page=${page}`, 117 | ); 118 | if (response.status >= 400) { 119 | throw new Error("Error fetching active trigger orders from Jupiter"); 120 | } 121 | const data = (await response.json()) as TriggerOrdersResponse; 122 | orders.push(...data.orders.filter((order) => order.trades.length > 0)); 123 | totalPages = data.totalPages; 124 | page += 1; 125 | } 126 | return orders; 127 | } 128 | 129 | export async function getTriggerOrdersActive( 130 | address: Address, 131 | ): Promise { 132 | return queryClient.fetchQuery({ 133 | queryKey: ["triggerOrdersActive", address], 134 | queryFn: () => getTriggerOrdersActiveImpl(address), 135 | }); 136 | } 137 | -------------------------------------------------------------------------------- /src/token-prices.ts: -------------------------------------------------------------------------------- 1 | import { Address } from "@solana/web3.js"; 2 | import { 3 | Deposit, 4 | FetchedTokenPrice, 5 | FetchedTokenPriceKey, 6 | FetchedTokenPrices, 7 | Timestamp, 8 | TokenPricesToFetch, 9 | Trade, 10 | } from "./types"; 11 | import { queryClient } from "./query-client"; 12 | 13 | export function getAlreadyFetchedTokenPrices( 14 | events: (Trade | Deposit)[], 15 | ): FetchedTokenPrices { 16 | const relevantKeys = new Set(); 17 | 18 | for (const event of events) { 19 | const { inputMint, date } = event; 20 | const timestamp = Math.floor(date.getTime() / 1000); 21 | const roundedTimestamp = roundTimestampToMinuteBoundary(timestamp); 22 | const inputKey: FetchedTokenPriceKey = `${inputMint}-${roundedTimestamp}`; 23 | relevantKeys.add(inputKey); 24 | 25 | if (event.kind === "trade") { 26 | const { outputMint } = event; 27 | const outputKey: FetchedTokenPriceKey = `${outputMint}-${roundedTimestamp}`; 28 | relevantKeys.add(outputKey); 29 | } 30 | } 31 | 32 | const fetchedTokenPrices: FetchedTokenPrices = {}; 33 | 34 | const cachedTokenPrices = queryClient.getQueryCache().findAll({ 35 | queryKey: ["tokenPrices"], 36 | }); 37 | 38 | for (const cachedTokenPrice of cachedTokenPrices) { 39 | if (!cachedTokenPrice.state.data) { 40 | continue; 41 | } 42 | 43 | const [, tokenAddress, timestamp] = cachedTokenPrice.queryKey; 44 | const key: FetchedTokenPriceKey = `${tokenAddress as Address}-${timestamp as Timestamp}`; 45 | if (!relevantKeys.has(key)) { 46 | continue; 47 | } 48 | 49 | fetchedTokenPrices[key] = cachedTokenPrice.state.data as FetchedTokenPrice; 50 | } 51 | 52 | return fetchedTokenPrices; 53 | } 54 | 55 | export function getTokenPricesToFetch( 56 | events: (Trade | Deposit)[], 57 | alreadyFetchedTokenPrices: FetchedTokenPrices, 58 | ): TokenPricesToFetch { 59 | const tokenPricesToFetch: TokenPricesToFetch = {}; 60 | 61 | for (const event of events) { 62 | const { inputMint, date } = event; 63 | const timestamp = Math.floor(date.getTime() / 1000); 64 | const roundedTimestamp = roundTimestampToMinuteBoundary(timestamp); 65 | 66 | if (!alreadyFetchedTokenPrices[`${inputMint}-${roundedTimestamp}`]) { 67 | tokenPricesToFetch[inputMint] ||= []; 68 | tokenPricesToFetch[inputMint].push(timestamp); 69 | } 70 | 71 | if (event.kind === "trade") { 72 | const { outputMint } = event; 73 | 74 | if (!alreadyFetchedTokenPrices[`${outputMint}-${roundedTimestamp}`]) { 75 | tokenPricesToFetch[outputMint] ||= []; 76 | tokenPricesToFetch[outputMint].push(timestamp); 77 | } 78 | } 79 | } 80 | 81 | return tokenPricesToFetch; 82 | } 83 | 84 | type BirdeyeHistoryPriceResponse = { 85 | success: boolean; 86 | data: { 87 | items: { 88 | address: Address; 89 | unixTime: Timestamp; 90 | value: number; 91 | }[]; 92 | }; 93 | }; 94 | 95 | export function roundTimestampToMinuteBoundary( 96 | timestamp: Timestamp, 97 | ): Timestamp { 98 | return timestamp - (timestamp % 60); 99 | } 100 | 101 | async function waitForSeconds(seconds: number, abortSignal: AbortSignal) { 102 | await new Promise((resolve, reject) => { 103 | const timeoutId = setTimeout(resolve, seconds * 1000); 104 | // If aborted during wait, clear timeout and reject 105 | abortSignal?.addEventListener("abort", () => { 106 | clearTimeout(timeoutId); 107 | reject(new Error("Aborted")); 108 | }); 109 | }); 110 | } 111 | 112 | async function fetchTokenPriceAtTimestampImpl( 113 | tokenAddress: Address, 114 | timestamp: Timestamp, 115 | birdeyeApiKey: string, 116 | abortSignal: AbortSignal, 117 | ): Promise { 118 | const timestampString = timestamp.toString(); 119 | 120 | const queryParams = new URLSearchParams({ 121 | address: tokenAddress, 122 | address_type: "token", 123 | type: "1m", 124 | time_from: timestampString, 125 | time_to: timestampString, 126 | }); 127 | 128 | if (abortSignal.aborted) { 129 | throw new Error("Aborted"); 130 | } 131 | 132 | // rate limit to 1 request per second 133 | await waitForSeconds(1, abortSignal); 134 | 135 | const url = `https://public-api.birdeye.so/defi/history_price?${queryParams.toString()}`; 136 | let response = await fetch(url, { 137 | headers: { 138 | "X-API-KEY": birdeyeApiKey, 139 | "x-chain": "solana", 140 | }, 141 | signal: abortSignal, 142 | }); 143 | 144 | if (response.status === 429) { 145 | // If we get rate limited, wait an additional 10 seconds before retrying 146 | const waitTimeSeconds = 10; 147 | 148 | console.log( 149 | `Birdeye rate limit exceeded. Waiting ${waitTimeSeconds}s before retrying.`, 150 | ); 151 | 152 | await waitForSeconds(waitTimeSeconds, abortSignal); 153 | 154 | // retry the request 155 | // If it fails again, we'll just handle it as an error 156 | response = await fetch(url, { 157 | headers: { 158 | "X-API-KEY": birdeyeApiKey, 159 | "x-chain": "solana", 160 | }, 161 | signal: abortSignal, 162 | }); 163 | } 164 | 165 | if (!(response.status === 200)) { 166 | throw new Error( 167 | `Failed to fetch token price for ${tokenAddress} at rounded timestamp ${timestampString} (requested timestamp: ${timestampString}). Response status: ${response.statusText}`, 168 | ); 169 | } 170 | 171 | const birdeyeHistoryPriceResponse: BirdeyeHistoryPriceResponse = 172 | await response.json(); 173 | 174 | if (!birdeyeHistoryPriceResponse.success) { 175 | throw new Error( 176 | `Failed to fetch token price for ${tokenAddress} at rounded timestamp ${timestampString} (requested timestamp: ${timestampString}). Response: ${JSON.stringify(birdeyeHistoryPriceResponse)}`, 177 | ); 178 | } 179 | 180 | if (birdeyeHistoryPriceResponse.data.items.length === 0) { 181 | // Successful response, but no data available 182 | return "missing"; 183 | } 184 | 185 | return birdeyeHistoryPriceResponse.data.items[0].value; 186 | } 187 | 188 | async function fetchTokenPriceAtTimestamp( 189 | tokenAddress: Address, 190 | timestamp: Timestamp, 191 | birdeyeApiKey: string, 192 | abortSignal: AbortSignal, 193 | ): Promise { 194 | return queryClient.ensureQueryData({ 195 | queryKey: ["tokenPrices", tokenAddress, timestamp], 196 | queryFn: () => 197 | fetchTokenPriceAtTimestampImpl( 198 | tokenAddress, 199 | timestamp, 200 | birdeyeApiKey, 201 | abortSignal, 202 | ), 203 | // keep historic token prices cached indefinitely, since they won't change 204 | staleTime: Infinity, 205 | gcTime: Infinity, 206 | }); 207 | } 208 | 209 | export async function fetchTokenPrices( 210 | tokenPricesToFetch: TokenPricesToFetch, 211 | birdeyeApiKey: string, 212 | abortSignal: AbortSignal, 213 | ): Promise { 214 | const fetchedTokenPrices: FetchedTokenPrices = {}; 215 | 216 | for (const [tokenAddress, timestamps] of Object.entries(tokenPricesToFetch)) { 217 | for (const timestamp of timestamps) { 218 | if (abortSignal.aborted) { 219 | return fetchedTokenPrices; 220 | } 221 | 222 | // round to the minute boundary before fetching 223 | const roundedTimestamp = roundTimestampToMinuteBoundary(timestamp); 224 | 225 | const key: FetchedTokenPriceKey = `${tokenAddress as Address}-${roundedTimestamp as Timestamp}`; 226 | if (fetchedTokenPrices[key]) continue; 227 | 228 | try { 229 | const price = await fetchTokenPriceAtTimestamp( 230 | tokenAddress as Address, 231 | roundedTimestamp as Timestamp, 232 | birdeyeApiKey, 233 | abortSignal, 234 | ); 235 | fetchedTokenPrices[key] = price; 236 | } catch (error) { 237 | console.error(error); 238 | continue; 239 | } 240 | } 241 | } 242 | return fetchedTokenPrices; 243 | } 244 | -------------------------------------------------------------------------------- /src/routes/trades-csv.ts: -------------------------------------------------------------------------------- 1 | import { ActionFunctionArgs } from "react-router-dom"; 2 | import { 3 | Trade, 4 | MintData, 5 | Deposit, 6 | AmountToDisplay, 7 | FetchedTokenPrices, 8 | Timestamp, 9 | FetchedTokenPriceKey, 10 | OrderType, 11 | } from "../types"; 12 | import { Address, Signature, StringifiedNumber } from "@solana/web3.js"; 13 | import { 14 | numberDisplay, 15 | numberDisplayAlreadyAdjustedForDecimals, 16 | } from "../number-display"; 17 | import BigDecimal from "js-big-decimal"; 18 | import { roundTimestampToMinuteBoundary } from "../token-prices"; 19 | 20 | type InputData = { 21 | events: (Deposit | Trade)[]; 22 | mints: MintData[]; 23 | fetchedTokenPrices: FetchedTokenPrices; 24 | }; 25 | 26 | type CSVTradeDataRow = { 27 | kind: "trade"; 28 | timestamp: number; 29 | inTokenAddress: Address; 30 | inTokenName: string; 31 | inTokenSymbol: string; 32 | inAmount: StringifiedNumber; 33 | inAmountUsd: StringifiedNumber; 34 | outTokenAddress: Address; 35 | outTokenName: string; 36 | outTokenSymbol: string; 37 | outAmount: StringifiedNumber; 38 | outAmountUsd: StringifiedNumber; 39 | outAmountFee: StringifiedNumber; 40 | outAmountFeeUsd: StringifiedNumber; 41 | outAmountNet: StringifiedNumber; 42 | outAmountNetUsd: StringifiedNumber; 43 | transactionSignature: Signature; 44 | orderType: OrderType; 45 | orderKey: Address; 46 | }; 47 | 48 | type CSVDepositDataRow = { 49 | kind: "deposit"; 50 | timestamp: number; 51 | inTokenAddress: Address; 52 | inTokenName: string; 53 | inTokenSymbol: string; 54 | inAmount: StringifiedNumber; 55 | inAmountUsd: StringifiedNumber; 56 | transactionSignature: Signature; 57 | orderType: OrderType; 58 | orderKey: Address; 59 | }; 60 | 61 | export function convertToCSV( 62 | items: (CSVTradeDataRow | CSVDepositDataRow)[], 63 | ): string { 64 | if (items.length === 0) { 65 | return ""; 66 | } 67 | const headers = [ 68 | "kind", 69 | "timestamp", 70 | "inTokenAddress", 71 | "inTokenName", 72 | "inTokenSymbol", 73 | "inAmount", 74 | "inAmountUsd", 75 | "outTokenAddress", 76 | "outTokenName", 77 | "outTokenSymbol", 78 | "outAmount", 79 | "outAmountUsd", 80 | "outAmountFee", 81 | "outAmountFeeUsd", 82 | "outAmountNet", 83 | "outAmountNetUsd", 84 | "transactionSignature", 85 | "orderType", 86 | "orderKey", 87 | ]; 88 | const headerNames = [ 89 | "Kind", 90 | "Timestamp", 91 | "In Token Address", 92 | "In Token Name", 93 | "In Token Symbol", 94 | "In Amount", 95 | "In Amount USD", 96 | "Out Token Address", 97 | "Out Token Name", 98 | "Out Token Symbol", 99 | "Out Amount", 100 | "Out Amount USD", 101 | "Out Amount (fee)", 102 | "Out Amount (fee) USD", 103 | "Out Amount (net)", 104 | "Out Amount (net) USD", 105 | "Transaction Signature", 106 | "Order Type", 107 | "Order Key", 108 | ]; 109 | const csvRows = [headerNames.join(",")]; 110 | 111 | // Create a row for each object 112 | for (const item of items) { 113 | const values: string[] = []; 114 | for (const header of headers) { 115 | const value = item[header as keyof typeof item]; 116 | if (!value) { 117 | values.push(""); 118 | } else if (typeof value === "string" && value.includes(",")) { 119 | values.push(`"${value}"`); 120 | } else { 121 | values.push(value.toString()); 122 | } 123 | } 124 | csvRows.push(values.join(",")); 125 | } 126 | 127 | return csvRows.join("\n"); 128 | } 129 | 130 | function getAmountFormatted( 131 | amountToDisplay: AmountToDisplay, 132 | decimals: number, 133 | ): string { 134 | return amountToDisplay.adjustedForDecimals 135 | ? numberDisplayAlreadyAdjustedForDecimals(amountToDisplay.amount) 136 | : numberDisplay(amountToDisplay.amount, decimals); 137 | } 138 | 139 | function getAmountBigDecimal( 140 | amountToDisplay: AmountToDisplay, 141 | decimals: number, 142 | ): BigDecimal { 143 | return amountToDisplay.adjustedForDecimals 144 | ? new BigDecimal(amountToDisplay.amount) 145 | : new BigDecimal(`${amountToDisplay.amount}E-${decimals}`); 146 | } 147 | 148 | function getUsdPrice( 149 | mintAddress: Address, 150 | timestamp: Timestamp, 151 | fetchedTokenPrices: FetchedTokenPrices, 152 | ): number | undefined { 153 | const roundedTimestamp = roundTimestampToMinuteBoundary(timestamp); 154 | const key: FetchedTokenPriceKey = `${mintAddress}-${roundedTimestamp}`; 155 | const price = fetchedTokenPrices[key]; 156 | if (price === "missing") { 157 | return undefined; 158 | } 159 | return price; 160 | } 161 | 162 | function getUsdAmount( 163 | price: number | undefined, 164 | mintAmount: AmountToDisplay, 165 | mintData: MintData | undefined, 166 | ): StringifiedNumber { 167 | if (!price) { 168 | return "" as StringifiedNumber; 169 | } 170 | if (mintData || mintAmount.adjustedForDecimals) { 171 | const mintDecimals = mintData?.decimals ?? 0; 172 | const tokenAmount = getAmountBigDecimal(mintAmount, mintDecimals); 173 | return tokenAmount 174 | .multiply(new BigDecimal(price)) 175 | .round(6) 176 | .getPrettyValue() as StringifiedNumber; 177 | } 178 | return "" as StringifiedNumber; 179 | } 180 | 181 | function csvDataForTrade( 182 | trade: Trade, 183 | mints: MintData[], 184 | fetchedTokenPrices: FetchedTokenPrices, 185 | ): CSVTradeDataRow { 186 | const inputMintData = mints.find((mint) => mint.address === trade.inputMint); 187 | const outputMintData = mints.find( 188 | (mint) => mint.address === trade.outputMint, 189 | ); 190 | const inputAmountFormatted = inputMintData 191 | ? getAmountFormatted(trade.inputAmount, inputMintData.decimals) 192 | : ""; 193 | 194 | let outputAmountFormatted = ""; 195 | let outputAmountFeeFormatted = ""; 196 | let outputAmountNetFormatted = ""; 197 | 198 | if (outputMintData || trade.outputAmount.adjustedForDecimals) { 199 | const decimals = outputMintData?.decimals ?? 0; 200 | 201 | const outputAmountNetBigDecimal = getAmountBigDecimal( 202 | trade.outputAmount, 203 | decimals, 204 | ); 205 | 206 | const outputFeeBigDecimal = getAmountBigDecimal(trade.fee, decimals); 207 | 208 | const outputAmountGrossBigDecimal = 209 | outputAmountNetBigDecimal.add(outputFeeBigDecimal); 210 | 211 | outputAmountFormatted = 212 | outputAmountGrossBigDecimal.getPrettyValue() as StringifiedNumber; 213 | outputAmountFeeFormatted = 214 | outputFeeBigDecimal.getPrettyValue() as StringifiedNumber; 215 | outputAmountNetFormatted = 216 | outputAmountNetBigDecimal.getPrettyValue() as StringifiedNumber; 217 | } 218 | 219 | const timestamp = Math.floor(new Date(trade.date).getTime() / 1000); 220 | 221 | const inUsdPrice = getUsdPrice( 222 | trade.inputMint, 223 | timestamp, 224 | fetchedTokenPrices, 225 | ); 226 | const inAmountUsd = getUsdAmount( 227 | inUsdPrice, 228 | trade.inputAmount, 229 | inputMintData, 230 | ); 231 | 232 | const outUsdPrice = getUsdPrice( 233 | trade.outputMint, 234 | timestamp, 235 | fetchedTokenPrices, 236 | ); 237 | 238 | const outAmountUsd = getUsdAmount( 239 | outUsdPrice, 240 | trade.outputAmount, 241 | outputMintData, 242 | ); 243 | 244 | const outAmountFeeUsd = getUsdAmount(outUsdPrice, trade.fee, outputMintData); 245 | 246 | const outAmountNetUsd = new BigDecimal(outAmountUsd) 247 | .subtract(new BigDecimal(outAmountFeeUsd)) 248 | .round(6) 249 | .getPrettyValue() as StringifiedNumber; 250 | 251 | return { 252 | kind: "trade", 253 | timestamp, 254 | inTokenAddress: trade.inputMint, 255 | inTokenName: inputMintData?.name ?? "", 256 | inTokenSymbol: inputMintData?.symbol ?? "", 257 | inAmount: inputAmountFormatted as StringifiedNumber, 258 | inAmountUsd: inAmountUsd as StringifiedNumber, 259 | outTokenAddress: trade.outputMint, 260 | outTokenName: outputMintData?.name ?? "", 261 | outTokenSymbol: outputMintData?.symbol ?? "", 262 | outAmount: outputAmountFormatted as StringifiedNumber, 263 | outAmountUsd: outAmountUsd as StringifiedNumber, 264 | outAmountFee: outputAmountFeeFormatted as StringifiedNumber, 265 | outAmountFeeUsd: outAmountFeeUsd as StringifiedNumber, 266 | outAmountNet: outputAmountNetFormatted as StringifiedNumber, 267 | outAmountNetUsd: outAmountNetUsd as StringifiedNumber, 268 | transactionSignature: trade.transactionSignature, 269 | orderType: trade.orderType, 270 | orderKey: trade.orderKey, 271 | }; 272 | } 273 | 274 | function csvDataForDeposit( 275 | deposit: Deposit, 276 | mints: MintData[], 277 | fetchedTokenPrices: FetchedTokenPrices, 278 | ): CSVDepositDataRow { 279 | console.log({ fetchedTokenPrices }); 280 | 281 | const inputMintData = mints.find( 282 | (mint) => mint.address === deposit.inputMint, 283 | ); 284 | const inputAmountFormatted = inputMintData 285 | ? getAmountFormatted(deposit.inputAmount, inputMintData.decimals) 286 | : ""; 287 | 288 | const timestamp = Math.floor(new Date(deposit.date).getTime() / 1000); 289 | 290 | const usdPrice = getUsdPrice( 291 | deposit.inputMint, 292 | timestamp, 293 | fetchedTokenPrices, 294 | ); 295 | 296 | const inAmountUsd = getUsdAmount( 297 | usdPrice, 298 | deposit.inputAmount, 299 | inputMintData, 300 | ); 301 | 302 | return { 303 | kind: "deposit", 304 | timestamp, 305 | inTokenAddress: deposit.inputMint, 306 | inTokenName: inputMintData?.name ?? "", 307 | inTokenSymbol: inputMintData?.symbol ?? "", 308 | inAmount: inputAmountFormatted as StringifiedNumber, 309 | inAmountUsd: inAmountUsd as StringifiedNumber, 310 | transactionSignature: deposit.transactionSignature, 311 | orderType: deposit.orderType, 312 | orderKey: deposit.orderKey, 313 | }; 314 | } 315 | 316 | export async function action({ request }: ActionFunctionArgs) { 317 | const inputData: InputData = await request.json(); 318 | 319 | const csvData: (CSVTradeDataRow | CSVDepositDataRow)[] = inputData.events.map( 320 | (event) => { 321 | if (event.kind === "deposit") { 322 | return csvDataForDeposit( 323 | event, 324 | inputData.mints, 325 | inputData.fetchedTokenPrices, 326 | ); 327 | } else { 328 | return csvDataForTrade( 329 | event, 330 | inputData.mints, 331 | inputData.fetchedTokenPrices, 332 | ); 333 | } 334 | }, 335 | ); 336 | 337 | const csvContent = convertToCSV(csvData); 338 | return new Response(csvContent); 339 | } 340 | -------------------------------------------------------------------------------- /src/routes/orders.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Badge, 3 | Button, 4 | Checkbox, 5 | Container, 6 | Group, 7 | Space, 8 | Stack, 9 | Text, 10 | Title, 11 | } from "@mantine/core"; 12 | import { Address, assertIsAddress, isAddress } from "@solana/web3.js"; 13 | import { 14 | Form, 15 | Link, 16 | LoaderFunctionArgs, 17 | useLoaderData, 18 | useNavigation, 19 | useParams, 20 | } from "react-router-dom"; 21 | import { 22 | MintData, 23 | RecurringOrderFetchedAccount, 24 | TriggerOrderFetchedAccount, 25 | } from "../types"; 26 | import { useListState } from "@mantine/hooks"; 27 | import { numberDisplayAlreadyAdjustedForDecimals } from "../number-display"; 28 | import { getMintData } from "../mint-data"; 29 | import { IconArrowLeft } from "@tabler/icons-react"; 30 | import { 31 | getRecurringOrdersHistory, 32 | getRecurringOrdersActive, 33 | getTriggerOrdersHistory, 34 | getTriggerOrdersActive, 35 | } from "../jupiter-api"; 36 | 37 | export async function loader({ params, request }: LoaderFunctionArgs) { 38 | const address = params.address as string; 39 | 40 | if (!isAddress(address)) { 41 | throw new Error("Invalid address"); 42 | } 43 | 44 | // Fetch sequentially from Jupiter API to avoid rate limiting 45 | // Recurring covers both what was previously DCA (time) and value average (price) 46 | const recurringOrdersHistory = await getRecurringOrdersHistory(address); 47 | const recurringOrdersActive = await getRecurringOrdersActive(address); 48 | const triggerOrdersHistory = await getTriggerOrdersHistory(address); 49 | const triggerOrdersActive = await getTriggerOrdersActive(address); 50 | 51 | const uniqueMintAddresses: Address[] = Array.from( 52 | new Set
([ 53 | ...recurringOrdersHistory.flatMap((order) => [ 54 | order.inputMint, 55 | order.outputMint, 56 | ]), 57 | ...recurringOrdersActive.flatMap((order) => [ 58 | order.inputMint, 59 | order.outputMint, 60 | ]), 61 | ...triggerOrdersHistory.flatMap((order) => [ 62 | order.inputMint, 63 | order.outputMint, 64 | ]), 65 | ...triggerOrdersActive.flatMap((order) => [ 66 | order.inputMint, 67 | order.outputMint, 68 | ]), 69 | ]), 70 | ); 71 | 72 | const mints = await getMintData(uniqueMintAddresses); 73 | 74 | const selectedOrderKeys = new Set( 75 | new URL(request.url).searchParams.getAll("o") as Address[], 76 | ); 77 | 78 | return { 79 | recurringOrdersHistory, 80 | recurringOrdersActive, 81 | triggerOrdersHistory, 82 | triggerOrdersActive, 83 | selectedOrderKeys, 84 | mints, 85 | }; 86 | } 87 | 88 | type AccountWithType = 89 | | { account: RecurringOrderWithOrderStatus; type: "recurring" } 90 | | { account: TriggerOrderWithOrderStatus; type: "trigger" }; 91 | 92 | type AccountsWithType = { 93 | [K in AccountWithType["type"]]: Extract< 94 | AccountWithType, 95 | { type: K } 96 | > extends { account: infer A } 97 | ? { accounts: A[]; type: K } 98 | : never; 99 | }[AccountWithType["type"]]; 100 | 101 | function getInputAmountWithSymbol( 102 | accountWithType: AccountWithType, 103 | inputMintData: MintData | undefined, 104 | ): String { 105 | const { account, type } = accountWithType; 106 | 107 | if (type === "recurring") { 108 | // inDeposited is already adjusted for decimals, but is not optimal for display to users 109 | const inputAmountDisplay = numberDisplayAlreadyAdjustedForDecimals( 110 | account.inDeposited, 111 | ); 112 | if (inputMintData) { 113 | return `${inputAmountDisplay} ${inputMintData.symbol}`; 114 | } 115 | return `${inputAmountDisplay} (Unknown (${account.inputMint}))`; 116 | } 117 | 118 | if (type === "trigger") { 119 | if (inputMintData) { 120 | // makingAmount is already adjusted for decimals, but is not optimal for display to users 121 | return `${numberDisplayAlreadyAdjustedForDecimals(account.makingAmount)} ${inputMintData.symbol}`; 122 | } 123 | return `${account.makingAmount} (Unknown (${account.inputMint}))`; 124 | } 125 | 126 | throw new Error("Invalid account type"); 127 | } 128 | 129 | function getOutputDisplay( 130 | account: AccountWithType["account"], 131 | mints: MintData[], 132 | ): string { 133 | const outputMintData = mints.find( 134 | (mint) => mint.address === account.outputMint, 135 | ); 136 | 137 | if (outputMintData) { 138 | return outputMintData.symbol; 139 | } 140 | return `Unknown (${account.outputMint})`; 141 | } 142 | 143 | function getIsOpen(accountWithType: AccountWithType): boolean { 144 | const { account, type } = accountWithType; 145 | if (type === "recurring") { 146 | return account.orderStatus === "active"; 147 | } 148 | 149 | if (type === "trigger") { 150 | return account.status === "Open"; 151 | } 152 | 153 | throw new Error("Invalid account type"); 154 | } 155 | 156 | function getTriggerStatusText(trigger: TriggerOrderFetchedAccount) { 157 | const { status, trades } = trigger; 158 | 159 | if (status === "Completed") { 160 | if (trades.length === 0) { 161 | return "Completed with no trades"; 162 | } 163 | if (trades.length === 1) { 164 | return "Completed after 1 trade"; 165 | } 166 | return `Completed after ${trades.length} trades`; 167 | } 168 | 169 | if (status === "Cancelled") { 170 | if (trades.length === 0) { 171 | return "Cancelled with no trades"; 172 | } 173 | if (trades.length === 1) { 174 | return "Cancelled after 1 trade"; 175 | } 176 | return `Cancelled after ${trades.length} trades`; 177 | } 178 | 179 | if (status === "Open") { 180 | if (trades.length === 0) { 181 | return "Open with no trades"; 182 | } 183 | if (trades.length === 1) { 184 | return "Open with 1 trade so far"; 185 | } 186 | return `Open with ${trades.length} trades so far`; 187 | } 188 | 189 | return undefined; 190 | } 191 | 192 | function SingleItemCheckboxLabel({ 193 | accountWithType, 194 | mints, 195 | }: { 196 | accountWithType: AccountWithType; 197 | mints: MintData[]; 198 | }) { 199 | const { account, type } = accountWithType; 200 | 201 | const inputMintData = mints.find( 202 | (mint) => mint.address === account.inputMint, 203 | ); 204 | 205 | const inputAmountWithSymbol = getInputAmountWithSymbol( 206 | accountWithType, 207 | inputMintData, 208 | ); 209 | const outputDisplay = getOutputDisplay(account, mints); 210 | 211 | const createdAtDate = new Date(account.createdAt); 212 | const friendlyDate = createdAtDate.toLocaleDateString(); 213 | const friendlyTime = createdAtDate.toLocaleTimeString(); 214 | 215 | if (type === "recurring") { 216 | const isOpen = getIsOpen(accountWithType); 217 | 218 | return ( 219 | 220 | 221 | {inputAmountWithSymbol} {"->"} {outputDisplay} • Started{" "} 222 | {friendlyDate} {friendlyTime} 223 | 224 | {isOpen ? ( 225 | 226 | Open 227 | 228 | ) : null} 229 | 230 | ); 231 | } 232 | 233 | if (type === "trigger") { 234 | const statusText = getTriggerStatusText(account); 235 | return ( 236 | 237 | 238 | {inputAmountWithSymbol} {"->"} {outputDisplay} • Opened {friendlyDate}{" "} 239 | {friendlyTime} {statusText ? `• ${statusText}` : null} 240 | 241 | 242 | ); 243 | } 244 | } 245 | 246 | type BaseCheckboxGroupProps = { 247 | selectedKeys: Set
; 248 | mints: MintData[]; 249 | }; 250 | 251 | type SingleItemCheckboxGroupProps = BaseCheckboxGroupProps & { 252 | accountWithType: AccountWithType; 253 | }; 254 | 255 | function SingleItemCheckboxGroup({ 256 | selectedKeys, 257 | mints, 258 | accountWithType, 259 | }: SingleItemCheckboxGroupProps) { 260 | const key = accountWithType.account.orderKey; 261 | const defaultChecked = getDefaultChecked(selectedKeys, key); 262 | 263 | return ( 264 | 271 | } 272 | name={accountWithType.type} 273 | value={key} 274 | /> 275 | ); 276 | } 277 | 278 | function getGroupLabel( 279 | account: AccountsWithType["accounts"][0], 280 | inputMintData: MintData | undefined, 281 | outputMintData: MintData | undefined, 282 | ) { 283 | const { inputMint, outputMint } = account; 284 | return `${inputMintData?.symbol ?? `Unknown (${inputMint})`} -> ${outputMintData?.symbol ?? `Unknown (${outputMint})`}`; 285 | } 286 | 287 | function getFirstAccountWithType( 288 | accountsWithType: AccountsWithType, 289 | ): AccountWithType { 290 | if (accountsWithType.type === "recurring") { 291 | return { 292 | account: accountsWithType.accounts[0], 293 | type: "recurring", 294 | }; 295 | } 296 | return { 297 | account: accountsWithType.accounts[0], 298 | type: "trigger", 299 | }; 300 | } 301 | 302 | function CheckboxGroupItemLabel({ 303 | accountWithType, 304 | inputMintData, 305 | }: { 306 | accountWithType: AccountWithType; 307 | inputMintData: MintData | undefined; 308 | }) { 309 | const { account, type } = accountWithType; 310 | 311 | const inputAmountWithSymbol = getInputAmountWithSymbol( 312 | accountWithType, 313 | inputMintData, 314 | ); 315 | 316 | const date = new Date(account.createdAt); 317 | const friendlyDate = date.toLocaleDateString(); 318 | const friendlyTime = date.toLocaleTimeString(); 319 | 320 | if (type === "recurring") { 321 | const isOpen = getIsOpen(accountWithType); 322 | 323 | return ( 324 | 325 | 326 | {inputAmountWithSymbol} • Started {friendlyDate} {friendlyTime} 327 | 328 | {isOpen ? ( 329 | 330 | Open 331 | 332 | ) : null} 333 | 334 | ); 335 | } 336 | 337 | if (type === "trigger") { 338 | const statusText = getTriggerStatusText(account); 339 | return ( 340 | 341 | 342 | {inputAmountWithSymbol} • Opened {friendlyDate} {friendlyTime}{" "} 343 | {statusText ? `• ${statusText}` : null} 344 | 345 | 346 | ); 347 | } 348 | } 349 | 350 | type MultipleItemCheckboxGroupProps = BaseCheckboxGroupProps & { 351 | accountsWithType: AccountsWithType; 352 | }; 353 | 354 | function MultipleItemCheckboxGroup({ 355 | selectedKeys, 356 | mints, 357 | accountsWithType, 358 | }: MultipleItemCheckboxGroupProps) { 359 | const { accounts, type } = accountsWithType; 360 | 361 | const inputMintData = mints.find( 362 | (mint) => mint.address === accounts[0].inputMint, 363 | ); 364 | const outputMintData = mints.find( 365 | (mint) => mint.address === accounts[0].outputMint, 366 | ); 367 | 368 | const groupLabel = getGroupLabel( 369 | accountsWithType.accounts[0], 370 | inputMintData, 371 | outputMintData, 372 | ); 373 | 374 | const initialValues = accounts 375 | .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) 376 | .map((account) => { 377 | const accountWithType: AccountWithType = { 378 | account, 379 | type, 380 | } as AccountWithType; 381 | const inputMintData = mints.find( 382 | (mint) => mint.address === account.inputMint, 383 | ); 384 | 385 | const key = account.orderKey; 386 | 387 | return { 388 | label: ( 389 | 393 | ), 394 | checked: getDefaultChecked(selectedKeys, key), 395 | key, 396 | }; 397 | }); 398 | 399 | const [values, handlers] = useListState(initialValues); 400 | 401 | const allChecked = values.every((value) => value.checked); 402 | const indeterminate = values.some((value) => value.checked) && !allChecked; 403 | 404 | const items = values.map((value, index) => ( 405 | 413 | handlers.setItemProp(index, "checked", event.currentTarget.checked) 414 | } 415 | /> 416 | )); 417 | 418 | return ( 419 | <> 420 | 425 | handlers.setState((current) => 426 | current.map((value) => ({ ...value, checked: !allChecked })), 427 | ) 428 | } 429 | /> 430 | {items} 431 | 432 | ); 433 | } 434 | 435 | type CheckboxGroupProps = BaseCheckboxGroupProps & { 436 | accountsWithType: AccountsWithType; 437 | }; 438 | 439 | function CheckboxGroup({ 440 | accountsWithType, 441 | selectedKeys, 442 | mints, 443 | }: CheckboxGroupProps) { 444 | if (accountsWithType.accounts.length === 0) { 445 | return null; 446 | } 447 | 448 | if (accountsWithType.accounts.length === 1) { 449 | return ( 450 | 455 | ); 456 | } 457 | 458 | return ( 459 | 464 | ); 465 | } 466 | 467 | function getDefaultChecked(selectedKeys: Set
, key: Address) { 468 | // If no pre-selected keys then select all 469 | // Otherwise only select pre-selected keys 470 | return selectedKeys.size === 0 || selectedKeys.has(key); 471 | } 472 | 473 | function ChangeAddressButton() { 474 | return ( 475 | 483 | ); 484 | } 485 | 486 | type RecurringOrderWithOrderStatus = RecurringOrderFetchedAccount & { 487 | orderStatus: "history" | "active"; 488 | }; 489 | 490 | type TriggerOrderWithOrderStatus = TriggerOrderFetchedAccount & { 491 | orderStatus: "history" | "active"; 492 | }; 493 | 494 | export default function Orders() { 495 | const params = useParams(); 496 | const address = params.address as string; 497 | assertIsAddress(address); 498 | 499 | const { 500 | recurringOrdersHistory, 501 | recurringOrdersActive, 502 | triggerOrdersHistory, 503 | triggerOrdersActive, 504 | selectedOrderKeys, 505 | mints, 506 | } = useLoaderData() as Awaited>; 507 | 508 | const navigation = useNavigation(); 509 | const isLoading = navigation.state === "loading"; 510 | 511 | const recurringOrders: RecurringOrderWithOrderStatus[] = [ 512 | ...recurringOrdersHistory.map((order) => ({ 513 | ...order, 514 | orderStatus: "history" as const, 515 | })), 516 | ...recurringOrdersActive.map((order) => ({ 517 | ...order, 518 | orderStatus: "active" as const, 519 | })), 520 | ]; 521 | 522 | const triggerOrders: TriggerOrderWithOrderStatus[] = [ 523 | ...triggerOrdersHistory.map((order) => ({ 524 | ...order, 525 | orderStatus: "history" as const, 526 | })), 527 | ...triggerOrdersActive.map((order) => ({ 528 | ...order, 529 | orderStatus: "active" as const, 530 | })), 531 | ]; 532 | 533 | const recurringOrdersTime = recurringOrders.filter( 534 | (order) => order.recurringType === "time", 535 | ); 536 | const recurringOrdersPrice = recurringOrders.filter( 537 | (order) => order.recurringType === "price", 538 | ); 539 | 540 | // Group recurring time orders by input + output mint 541 | const groupedRecurringOrdersTime = recurringOrdersTime.reduce( 542 | (acc, order) => { 543 | const key = `${order.inputMint}-${order.outputMint}`; 544 | acc[key] ??= []; 545 | acc[key].push(order); 546 | return acc; 547 | }, 548 | {} as Record, 549 | ); 550 | 551 | // Group recurring price orders by input + output mint 552 | const groupedRecurringOrdersPrice = recurringOrdersPrice.reduce( 553 | (acc, order) => { 554 | const key = `${order.inputMint}-${order.outputMint}`; 555 | acc[key] ??= []; 556 | acc[key].push(order); 557 | return acc; 558 | }, 559 | {} as Record, 560 | ); 561 | 562 | // Group triggers by input + output mint 563 | const groupedTriggers = triggerOrders.reduce( 564 | (acc, trigger) => { 565 | const key = `${trigger.inputMint}-${trigger.outputMint}`; 566 | acc[key] ??= []; 567 | acc[key].push(trigger); 568 | return acc; 569 | }, 570 | {} as Record, 571 | ); 572 | 573 | return ( 574 | 575 | 576 | 577 | 578 | Select orders to display 579 | 580 | 581 | 582 |
583 | 584 | 585 | 586 | {recurringOrders.length === 0 ? ( 587 | No recurring orders found for {address} 588 | ) : null} 589 | 590 | {Object.keys(groupedRecurringOrdersTime).length > 0 ? ( 591 | 592 | Recurring (Time) 593 | {Object.entries(groupedRecurringOrdersTime).map( 594 | ([key, orders]) => ( 595 | 604 | ), 605 | )} 606 | 607 | ) : null} 608 | 609 | {Object.keys(groupedRecurringOrdersPrice).length > 0 ? ( 610 | 611 | Recurring (Price) 612 | {Object.entries(groupedRecurringOrdersPrice).map( 613 | ([key, orders]) => ( 614 | 623 | ), 624 | )} 625 | 626 | ) : null} 627 | 628 | {Object.keys(groupedTriggers).length > 0 ? ( 629 | 630 | Triggers 631 | {Object.entries(groupedTriggers).map(([key, triggers]) => ( 632 | 641 | ))} 642 | 643 | ) : ( 644 | 645 | No Jupiter Trigger orders found for {address} 646 | 647 | )} 648 | 649 | 652 | 653 |
654 |
655 |
656 | ); 657 | } 658 | -------------------------------------------------------------------------------- /src/routes/trades.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Form, 3 | LoaderFunctionArgs, 4 | useFetcher, 5 | useLoaderData, 6 | useNavigation, 7 | } from "react-router-dom"; 8 | import { 9 | AmountToDisplay, 10 | Deposit, 11 | FetchedTokenPriceKey, 12 | FetchedTokenPrices, 13 | MintData, 14 | OrderType, 15 | RecurringOrderFetchedAccount, 16 | StringifiedNumber, 17 | TokenPricesToFetch, 18 | Trade, 19 | TriggerOrderFetchedAccount, 20 | } from "../types"; 21 | import { Address } from "@solana/web3.js"; 22 | import { getMintData } from "../mint-data"; 23 | import { 24 | ActionIcon, 25 | Anchor, 26 | Badge, 27 | Box, 28 | Button, 29 | Checkbox, 30 | CopyButton, 31 | Flex, 32 | Group, 33 | Image, 34 | Input, 35 | Modal, 36 | rem, 37 | Stack, 38 | Switch, 39 | Table, 40 | Text, 41 | TextInput, 42 | Title, 43 | Tooltip, 44 | } from "@mantine/core"; 45 | import { 46 | IconCopy, 47 | IconCheck, 48 | IconArrowsUpDown, 49 | IconArrowLeft, 50 | IconArrowsDownUp, 51 | } from "@tabler/icons-react"; 52 | import { 53 | usdAmountDisplay, 54 | numberDisplay, 55 | numberDisplayAlreadyAdjustedForDecimals, 56 | } from "../number-display"; 57 | import BigDecimal from "js-big-decimal"; 58 | import { 59 | PropsWithChildren, 60 | useCallback, 61 | useEffect, 62 | useMemo, 63 | useRef, 64 | useState, 65 | } from "react"; 66 | import { 67 | getRecurringOrdersActive, 68 | getRecurringOrdersHistory, 69 | getTriggerOrdersActive, 70 | getTriggerOrdersHistory, 71 | } from "../jupiter-api"; 72 | import { toSvg } from "jdenticon"; 73 | import { useDisclosure } from "@mantine/hooks"; 74 | import { 75 | getAlreadyFetchedTokenPrices, 76 | getTokenPricesToFetch, 77 | roundTimestampToMinuteBoundary, 78 | } from "../token-prices"; 79 | 80 | async function getSelectedRecurringOrders( 81 | userAddress: Address, 82 | recurringKeys: Address[], 83 | ): Promise { 84 | const recurringOrdersHistory = await getRecurringOrdersHistory(userAddress); 85 | const recurringOrdersActive = await getRecurringOrdersActive(userAddress); 86 | const keysSet = new Set(recurringKeys); 87 | return [...recurringOrdersHistory, ...recurringOrdersActive].filter((order) => 88 | keysSet.has(order.orderKey), 89 | ); 90 | } 91 | 92 | async function getSelectedTriggerOrders( 93 | userAddress: Address, 94 | triggerKeys: Address[], 95 | ): Promise { 96 | const triggerOrdersHistory = await getTriggerOrdersHistory(userAddress); 97 | const triggerOrdersActive = await getTriggerOrdersActive(userAddress); 98 | const keysSet = new Set(triggerKeys); 99 | return [...triggerOrdersHistory, ...triggerOrdersActive].filter((order) => 100 | keysSet.has(order.orderKey), 101 | ); 102 | } 103 | 104 | function makeDepositsForRecurringOrders( 105 | orders: RecurringOrderFetchedAccount[], 106 | userAddress: Address, 107 | ): Deposit[] { 108 | return orders.map((order) => ({ 109 | kind: "deposit", 110 | date: new Date(order.createdAt), 111 | inputMint: order.inputMint, 112 | inputAmount: { 113 | amount: order.inDeposited, 114 | adjustedForDecimals: true, 115 | }, 116 | orderType: 117 | order.recurringType === "time" ? "recurring time" : "recurring price", 118 | orderKey: order.orderKey, 119 | userAddress, 120 | transactionSignature: order.openTx, 121 | })); 122 | } 123 | 124 | function makeDepositsForTriggerOrders( 125 | orders: TriggerOrderFetchedAccount[], 126 | userAddress: Address, 127 | ): Deposit[] { 128 | return orders.map((order) => ({ 129 | kind: "deposit", 130 | date: new Date(order.createdAt), 131 | inputMint: order.inputMint, 132 | inputAmount: { 133 | amount: order.makingAmount, 134 | adjustedForDecimals: true, 135 | }, 136 | orderType: "trigger", 137 | orderKey: order.orderKey, 138 | userAddress, 139 | transactionSignature: order.openTx, 140 | })); 141 | } 142 | 143 | function makeTradesForRecurringOrders( 144 | orders: RecurringOrderFetchedAccount[], 145 | userAddress: Address, 146 | ): Trade[] { 147 | return orders.flatMap((order) => 148 | order.trades.map((trade) => ({ 149 | kind: "trade", 150 | date: new Date(trade.confirmedAt), 151 | inputMint: trade.inputMint, 152 | outputMint: trade.outputMint, 153 | inputAmount: { 154 | amount: trade.inputAmount, 155 | adjustedForDecimals: true, 156 | }, 157 | outputAmount: { 158 | amount: trade.outputAmount, 159 | adjustedForDecimals: true, 160 | }, 161 | fee: { 162 | amount: trade.feeAmount, 163 | adjustedForDecimals: true, 164 | }, 165 | orderType: 166 | order.recurringType === "time" ? "recurring time" : "recurring price", 167 | orderKey: order.orderKey, 168 | userAddress, 169 | transactionSignature: trade.txId, 170 | })), 171 | ); 172 | } 173 | 174 | function makeTradesForTriggerOrders( 175 | orders: TriggerOrderFetchedAccount[], 176 | userAddress: Address, 177 | ): Trade[] { 178 | return orders.flatMap((order) => 179 | order.trades.map((trade) => ({ 180 | kind: "trade", 181 | date: new Date(trade.confirmedAt), 182 | inputMint: trade.inputMint, 183 | outputMint: trade.outputMint, 184 | inputAmount: { 185 | amount: trade.inputAmount, 186 | adjustedForDecimals: true, 187 | }, 188 | outputAmount: { 189 | amount: trade.outputAmount, 190 | adjustedForDecimals: true, 191 | }, 192 | fee: { 193 | amount: trade.feeAmount, 194 | adjustedForDecimals: true, 195 | }, 196 | orderType: "trigger", 197 | orderKey: order.orderKey, 198 | userAddress, 199 | transactionSignature: trade.txId, 200 | })), 201 | ); 202 | } 203 | 204 | export async function loader({ request }: LoaderFunctionArgs) { 205 | const url = new URL(request.url); 206 | 207 | const userAddress = url.searchParams.get("userAddress") as Address; 208 | 209 | const recurringKeys = [ 210 | ...new Set(url.searchParams.getAll("recurring")), 211 | ] as Address[]; 212 | const triggerKeys = [ 213 | ...new Set(url.searchParams.getAll("trigger")), 214 | ] as Address[]; 215 | 216 | const recurringOrders = await getSelectedRecurringOrders( 217 | userAddress, 218 | recurringKeys, 219 | ); 220 | const triggerOrders = await getSelectedTriggerOrders( 221 | userAddress, 222 | triggerKeys, 223 | ); 224 | 225 | const deposits = [ 226 | ...makeDepositsForRecurringOrders(recurringOrders, userAddress), 227 | ...makeDepositsForTriggerOrders(triggerOrders, userAddress), 228 | ]; 229 | 230 | const trades = [ 231 | ...makeTradesForRecurringOrders(recurringOrders, userAddress), 232 | ...makeTradesForTriggerOrders(triggerOrders, userAddress), 233 | ]; 234 | 235 | const uniqueMintAddresses: Address[] = Array.from( 236 | new Set
([ 237 | ...trades.flatMap((trade) => [trade.inputMint, trade.outputMint]), 238 | ...deposits.map((deposit) => deposit.inputMint), 239 | ]), 240 | ); 241 | const mints = await getMintData(uniqueMintAddresses); 242 | 243 | const events = [...deposits, ...trades].sort( 244 | (a, b) => a.date.getTime() - b.date.getTime(), 245 | ); 246 | 247 | const storedBirdeyeApiKey = localStorage.getItem("birdeyeApiKey"); 248 | 249 | return { 250 | recurringKeys, 251 | triggerKeys, 252 | userAddress, 253 | events, 254 | mints, 255 | storedBirdeyeApiKey, 256 | }; 257 | } 258 | 259 | type DownloadButtonProps = { 260 | events: (Trade | Deposit)[]; 261 | mints: MintData[]; 262 | userAddress: Address; 263 | fetchedTokenPrices: FetchedTokenPrices; 264 | }; 265 | 266 | function DownloadButton({ 267 | events, 268 | mints, 269 | userAddress, 270 | fetchedTokenPrices, 271 | }: DownloadButtonProps) { 272 | const fetcher = useFetcher(); 273 | const isLoading = fetcher.state === "loading"; 274 | const downloadLinkRef = useRef(null); 275 | 276 | const submit = useCallback(() => { 277 | fetcher.submit( 278 | JSON.stringify({ 279 | events, 280 | mints, 281 | fetchedTokenPrices, 282 | }), 283 | { 284 | method: "post", 285 | action: "/trades/csv", 286 | encType: "application/json", 287 | }, 288 | ); 289 | }, [events, mints, fetchedTokenPrices]); 290 | 291 | useEffect(() => { 292 | if (fetcher.data && fetcher.state === "idle") { 293 | const csvContent = fetcher.data as string; 294 | 295 | // Create a Blob with the CSV content 296 | const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); 297 | 298 | // Create a temporary URL for the Blob 299 | const downloadUrl = URL.createObjectURL(blob); 300 | 301 | const link = downloadLinkRef.current; 302 | if (link) { 303 | link.href = downloadUrl; 304 | link.download = `${userAddress}-trades.csv`; 305 | link.click(); 306 | URL.revokeObjectURL(downloadUrl); 307 | } 308 | } 309 | }, [fetcher.data, fetcher.state]); 310 | 311 | return ( 312 | <> 313 | 316 | 317 | 318 | ); 319 | } 320 | 321 | function DateCell({ date }: { date: Date }) { 322 | const friendlyDate = date.toLocaleDateString(); 323 | const friendlyTime = date.toLocaleTimeString(); 324 | const timestamp = Math.floor(date.getTime() / 1000); 325 | return ( 326 | 327 | 328 | {friendlyDate} {friendlyTime} 329 | 330 | 331 | {({ copied, copy }) => ( 332 | 337 | 342 | {copied ? ( 343 | 344 | ) : ( 345 | 346 | )} 347 | 348 | 349 | )} 350 | 351 | 352 | ); 353 | } 354 | 355 | type DottedAnchorLinkProps = { 356 | href: string; 357 | children: React.ReactNode; 358 | }; 359 | 360 | function DottedAnchorLink({ href, children }: DottedAnchorLinkProps) { 361 | return ( 362 | 368 | {children} 369 | 370 | ); 371 | } 372 | 373 | function calculateUsdAmount( 374 | amount: AmountToDisplay, 375 | tokenPrice: number, 376 | tokenMintData: MintData | undefined, 377 | ): number | undefined { 378 | const tokenAmountAlreadyAdjustedForDecimals = amount.adjustedForDecimals; 379 | 380 | if (tokenAmountAlreadyAdjustedForDecimals) { 381 | return tokenPrice * Number(amount.amount); 382 | } 383 | 384 | if (!tokenMintData) { 385 | return undefined; 386 | } 387 | 388 | const tokenAmount = Number(amount.amount) / 10 ** tokenMintData.decimals; 389 | return tokenPrice * tokenAmount; 390 | } 391 | 392 | type UsdAmountProps = { 393 | amount: number; 394 | }; 395 | 396 | function UsdAmount({ amount, children }: PropsWithChildren) { 397 | const formattedUsdAmount = usdAmountDisplay(amount); 398 | 399 | return ( 400 | 401 | 402 | {formattedUsdAmount} 403 | 404 | 405 | ); 406 | } 407 | 408 | type TokenAmountCellProps = { 409 | address: Address; 410 | amountToDisplay: { 411 | amount: StringifiedNumber; 412 | adjustedForDecimals: boolean; 413 | }; 414 | tokenMintData: MintData | undefined; 415 | onNumberClick?: () => void; 416 | tokenPrice?: number; 417 | }; 418 | 419 | function TokenAmountCell({ 420 | address, 421 | amountToDisplay, 422 | tokenMintData, 423 | onNumberClick, 424 | tokenPrice, 425 | }: TokenAmountCellProps) { 426 | const explorerLink = `https://explorer.solana.com/address/${address}`; 427 | const { amount, adjustedForDecimals } = amountToDisplay; 428 | 429 | const usdAmount = useMemo(() => { 430 | return tokenPrice 431 | ? calculateUsdAmount(amountToDisplay, tokenPrice, tokenMintData) 432 | : undefined; 433 | }, [amountToDisplay, tokenPrice, tokenMintData]); 434 | 435 | if (!tokenMintData) { 436 | return ( 437 | Unknown Token 438 | ); 439 | } 440 | 441 | const formattedAmount = adjustedForDecimals 442 | ? numberDisplayAlreadyAdjustedForDecimals(amount) 443 | : numberDisplay(amount, tokenMintData.decimals); 444 | 445 | const formattedTokenPrice = tokenPrice 446 | ? usdAmountDisplay(tokenPrice) 447 | : undefined; 448 | 449 | return ( 450 | 451 | 457 | 464 | 465 | 466 | 467 | {formattedAmount} 468 | {" "} 469 | 470 | {tokenMintData.symbol} 471 | 472 | 473 | {usdAmount ? ( 474 | 475 | 476 | 1 {tokenMintData.symbol ? `${tokenMintData.symbol}` : "token"} ={" "} 477 | {formattedTokenPrice} 478 | 479 | 480 | ) : null} 481 | 482 | 483 | {({ copied, copy }) => ( 484 | 489 | 494 | {copied ? ( 495 | 496 | ) : ( 497 | 498 | )} 499 | 500 | 501 | )} 502 | 503 | 504 | 505 | ); 506 | } 507 | 508 | enum RateType { 509 | INPUT_PER_OUTPUT, 510 | OUTPUT_PER_INPUT, 511 | } 512 | 513 | function getRates( 514 | inputAmountToDisplay: AmountToDisplay, 515 | outputAmountToDisplay: AmountToDisplay, 516 | inputMintData: MintData, 517 | outputMintData: MintData, 518 | ): { 519 | rateInputOverOutput: BigDecimal; 520 | rateOutputOverInput: BigDecimal; 521 | } { 522 | const { amount: inputAmount, adjustedForDecimals: inputAdjustedForDecimals } = 523 | inputAmountToDisplay; 524 | 525 | const { 526 | amount: outputAmount, 527 | adjustedForDecimals: outputAdjustedForDecimals, 528 | } = outputAmountToDisplay; 529 | 530 | const inputAmountBigDecimal = inputAdjustedForDecimals 531 | ? new BigDecimal(inputAmount) 532 | : new BigDecimal(`${inputAmount}E-${inputMintData.decimals}`); 533 | const outputAmountBigDecimal = outputAdjustedForDecimals 534 | ? new BigDecimal(outputAmount) 535 | : new BigDecimal(`${outputAmount}E-${outputMintData.decimals}`); 536 | 537 | const rateInputOverOutput = inputAmountBigDecimal.divide( 538 | outputAmountBigDecimal, 539 | inputMintData.decimals, 540 | ); 541 | const rateOutputOverInput = outputAmountBigDecimal.divide( 542 | inputAmountBigDecimal, 543 | outputMintData.decimals, 544 | ); 545 | 546 | return { 547 | rateInputOverOutput, 548 | rateOutputOverInput, 549 | }; 550 | } 551 | 552 | type RateCellProps = { 553 | inputAmountToDisplay: AmountToDisplay; 554 | outputAmountToDisplay: AmountToDisplay; 555 | inputMintData: MintData | undefined; 556 | outputMintData: MintData | undefined; 557 | rateType: RateType; 558 | onNumberClick: () => void; 559 | }; 560 | 561 | function RateCell({ 562 | inputAmountToDisplay, 563 | outputAmountToDisplay, 564 | inputMintData, 565 | outputMintData, 566 | rateType, 567 | onNumberClick, 568 | }: RateCellProps) { 569 | if (!inputMintData || !outputMintData) { 570 | return Unknown; 571 | } 572 | 573 | const { rateInputOverOutput, rateOutputOverInput } = useMemo( 574 | () => 575 | getRates( 576 | inputAmountToDisplay, 577 | outputAmountToDisplay, 578 | inputMintData, 579 | outputMintData, 580 | ), 581 | [ 582 | inputAmountToDisplay, 583 | outputAmountToDisplay, 584 | inputMintData, 585 | outputMintData, 586 | ], 587 | ); 588 | 589 | const text = useMemo(() => { 590 | return rateType === RateType.INPUT_PER_OUTPUT 591 | ? `${rateInputOverOutput.getPrettyValue()} ${inputMintData.symbol} per ${outputMintData.symbol}` 592 | : `${rateOutputOverInput.getPrettyValue()} ${outputMintData.symbol} per ${inputMintData.symbol}`; 593 | }, [ 594 | rateInputOverOutput, 595 | rateOutputOverInput, 596 | inputMintData, 597 | outputMintData, 598 | rateType, 599 | ]); 600 | 601 | return {text}; 602 | } 603 | 604 | function TransactionLinkCell({ txId }: { txId: string }) { 605 | const explorerLink = `https://explorer.solana.com/tx/${txId}`; 606 | return View; 607 | } 608 | 609 | function TransactionEventTypeBadge({ 610 | eventType, 611 | }: { 612 | eventType: "deposit" | "trade"; 613 | }) { 614 | if (eventType === "deposit") { 615 | return ( 616 | 617 | Deposit 618 | 619 | ); 620 | } 621 | return ( 622 | 623 | Trade 624 | 625 | ); 626 | } 627 | 628 | function TransactionOrderTypeBadge({ orderType }: { orderType: OrderType }) { 629 | if (orderType === "recurring time") { 630 | return ( 631 | 632 | 633 | RT 634 | 635 | 636 | ); 637 | } 638 | 639 | if (orderType === "recurring price") { 640 | return ( 641 | 642 | 643 | RP 644 | 645 | 646 | ); 647 | } 648 | if (orderType === "trigger") { 649 | return ( 650 | 651 | 652 | T 653 | 654 | 655 | ); 656 | } 657 | } 658 | 659 | function OrderKeyIcon({ orderKey }: { orderKey: Address }) { 660 | const size = 24; 661 | const svg = useMemo(() => toSvg(orderKey, size), [orderKey, size]); 662 | return ; 663 | } 664 | 665 | type TransactionEventCellProps = { 666 | orderType: Trade["orderType"]; 667 | orderKey: Address; 668 | }; 669 | 670 | function TransactionEventCell({ 671 | orderType, 672 | orderKey, 673 | }: TransactionEventCellProps) { 674 | return ( 675 | 676 | 677 | 678 | 679 | ); 680 | } 681 | 682 | type ChangeDisplayedTradesButtonProps = { 683 | userAddress: Address; 684 | recurringKeys: Address[]; 685 | triggerKeys: Address[]; 686 | }; 687 | 688 | function ChangeDisplayedTradesButton({ 689 | userAddress, 690 | recurringKeys, 691 | triggerKeys, 692 | }: ChangeDisplayedTradesButtonProps) { 693 | const navigation = useNavigation(); 694 | const isLoading = navigation.state === "loading"; 695 | 696 | return ( 697 |
698 | {recurringKeys.map((recurringKey) => ( 699 | 700 | ))} 701 | {triggerKeys.map((triggerKey) => ( 702 | 703 | ))} 704 | 705 | 713 |
714 | ); 715 | } 716 | 717 | function adjustOutputAmountForFee( 718 | outputAmountToDisplay: AmountToDisplay, 719 | feeToDisplay: AmountToDisplay, 720 | subtractFee: boolean, 721 | ): AmountToDisplay { 722 | if (subtractFee) { 723 | return outputAmountToDisplay; 724 | } 725 | 726 | const { 727 | amount: outputAmount, 728 | adjustedForDecimals: outputAdjustedForDecimals, 729 | } = outputAmountToDisplay; 730 | const { amount: fee, adjustedForDecimals: feeAdjustedForDecimals } = 731 | feeToDisplay; 732 | 733 | if (outputAdjustedForDecimals !== feeAdjustedForDecimals) { 734 | // For now assume that output and fee are either both or neither adjusted for decimals 735 | throw new Error("Output and fee must have the same adjustedForDecimals"); 736 | } 737 | 738 | // If not subtracting fee, add the fee to the output amount 739 | if (outputAdjustedForDecimals) { 740 | return { 741 | amount: new BigDecimal(outputAmount) 742 | .add(new BigDecimal(fee)) 743 | .getValue() as StringifiedNumber, 744 | adjustedForDecimals: true, 745 | }; 746 | } else { 747 | return { 748 | amount: ( 749 | BigInt(outputAmount) + BigInt(fee) 750 | ).toString() as StringifiedNumber, 751 | adjustedForDecimals: false, 752 | }; 753 | } 754 | } 755 | 756 | type TradeRowProps = { 757 | trade: Trade; 758 | mints: MintData[]; 759 | subtractFee: boolean; 760 | rateType: RateType; 761 | switchSubtractFee: () => void; 762 | switchRateType: () => void; 763 | tokenPrices: FetchedTokenPrices; 764 | }; 765 | 766 | function TradeRow({ 767 | trade, 768 | mints, 769 | subtractFee, 770 | rateType, 771 | switchSubtractFee, 772 | switchRateType, 773 | tokenPrices, 774 | }: TradeRowProps) { 775 | const inputMintData = mints.find((mint) => mint.address === trade.inputMint); 776 | const outputMintData = mints.find( 777 | (mint) => mint.address === trade.outputMint, 778 | ); 779 | 780 | const outputAmountWithFee = useMemo( 781 | () => adjustOutputAmountForFee(trade.outputAmount, trade.fee, subtractFee), 782 | [trade.outputAmount, trade.fee, subtractFee], 783 | ); 784 | 785 | const inputTokenPrice: number | undefined = useMemo(() => { 786 | if (!tokenPrices) { 787 | return undefined; 788 | } 789 | 790 | return getTokenPrice(tokenPrices, trade.inputMint, trade.date); 791 | }, [trade, tokenPrices]); 792 | 793 | const outputTokenPrice: number | undefined = useMemo(() => { 794 | if (!tokenPrices) { 795 | return undefined; 796 | } 797 | 798 | return getTokenPrice(tokenPrices, trade.outputMint, trade.date); 799 | }, [trade, tokenPrices]); 800 | 801 | return ( 802 | 803 | 804 | 808 | 809 | 810 | 811 | 812 | 813 | 814 | 815 | 816 | 822 | 823 | 824 | 831 | 832 | 833 | 841 | 842 | 843 | 844 | 845 | 846 | ); 847 | } 848 | 849 | function getTokenPrice( 850 | tokenPrices: FetchedTokenPrices, 851 | mintAddress: Address, 852 | date: Date, 853 | ): number | undefined { 854 | const timestamp = Math.floor(date.getTime() / 1000); 855 | const roundedTimestamp = roundTimestampToMinuteBoundary(timestamp); 856 | const key: FetchedTokenPriceKey = `${mintAddress}-${roundedTimestamp}`; 857 | const price = tokenPrices[key]; 858 | if (price === "missing") { 859 | return undefined; 860 | } 861 | return price; 862 | } 863 | 864 | type DepositRowProps = { 865 | deposit: Deposit; 866 | mints: MintData[]; 867 | tokenPrices: FetchedTokenPrices; 868 | }; 869 | 870 | function DepositRow({ deposit, mints, tokenPrices }: DepositRowProps) { 871 | const inputMintData = mints.find( 872 | (mint) => mint.address === deposit.inputMint, 873 | ); 874 | 875 | const tokenPrice: number | undefined = useMemo(() => { 876 | if (!tokenPrices) { 877 | return undefined; 878 | } 879 | 880 | return getTokenPrice(tokenPrices, deposit.inputMint, deposit.date); 881 | }, [deposit, tokenPrices]); 882 | 883 | return ( 884 | 885 | 886 | 890 | 891 | 892 | 893 | 894 | 895 | 896 | 897 | 898 | 904 | 905 | 906 | 907 | 908 | 909 | ); 910 | } 911 | 912 | function TradeCountsTitle({ 913 | recurringKeysCount, 914 | triggerKeysCount, 915 | tradesCount, 916 | }: { 917 | recurringKeysCount: number; 918 | triggerKeysCount: number; 919 | tradesCount: number; 920 | }) { 921 | const counts = [ 922 | recurringKeysCount > 0 && `${recurringKeysCount} Recurring Orders`, 923 | triggerKeysCount > 0 && `${triggerKeysCount} Triggers`, 924 | ].filter(Boolean); 925 | 926 | const countsDisplay = counts.reduce((acc, curr, i, arr) => { 927 | if (i === 0) return curr; 928 | if (i === arr.length - 1) return `${acc} and ${curr}`; 929 | return `${acc}, ${curr}`; 930 | }, ""); 931 | 932 | return ( 933 | 934 | Displaying data for {countsDisplay} ({tradesCount} trades) 935 | 936 | ); 937 | } 938 | 939 | function UsdPricesModal({ 940 | opened, 941 | onClose, 942 | tokenPricesToFetch, 943 | storedBirdeyeApiKey, 944 | }: { 945 | opened: boolean; 946 | onClose: () => void; 947 | tokenPricesToFetch: TokenPricesToFetch; 948 | storedBirdeyeApiKey: string | null; 949 | }) { 950 | const fetcher = useFetcher(); 951 | 952 | // Close after fetcher is done 953 | useEffect(() => { 954 | if (fetcher.state === "idle" && fetcher.data) { 955 | onClose(); 956 | } 957 | }, [fetcher.state, fetcher.data, onClose]); 958 | 959 | const estimatedRequests = Object.values(tokenPricesToFetch).flat().length; 960 | // Birdeye rate limits us to 1 request per second 961 | const estimatedTimeMinutes = Math.ceil(estimatedRequests / 60); 962 | 963 | return ( 964 | 965 | 966 | 971 | 972 | 975 | Your{" "} 976 | 977 | Birdeye 978 | {" "} 979 | API key 980 | 981 | } 982 | description="Only used to fetch token prices. Never sent anywhere else" 983 | name="birdeyeApiKey" 984 | required 985 | autoComplete="off" 986 | defaultValue={storedBirdeyeApiKey ?? undefined} 987 | /> 988 | 989 | 995 | 996 | 997 | 1005 | 1006 | Approx {estimatedRequests} prices to fetch 1007 | {estimatedTimeMinutes > 1 && 1008 | ` (estimated time: ${estimatedTimeMinutes} minute${ 1009 | estimatedTimeMinutes > 1 ? "s" : "" 1010 | })`} 1011 | 1012 | 1013 | 1014 | 1015 | 1016 | ); 1017 | } 1018 | 1019 | // 1. Get the already fetched token prices from the query cache 1020 | // 2. Find which token prices are missing 1021 | // 3. Fetch them when the modal form is submitted 1022 | 1023 | export default function Trades() { 1024 | const { 1025 | recurringKeys, 1026 | triggerKeys, 1027 | userAddress, 1028 | events, 1029 | mints, 1030 | storedBirdeyeApiKey, 1031 | } = useLoaderData() as Awaited>; 1032 | 1033 | const [rateType, setRateType] = useState(RateType.OUTPUT_PER_INPUT); 1034 | const switchRateType = useCallback(() => { 1035 | setRateType( 1036 | rateType === RateType.INPUT_PER_OUTPUT 1037 | ? RateType.OUTPUT_PER_INPUT 1038 | : RateType.INPUT_PER_OUTPUT, 1039 | ); 1040 | }, [rateType]); 1041 | 1042 | const [subtractFee, setSubtractFee] = useState(true); 1043 | 1044 | const [ 1045 | usdPricesModalOpened, 1046 | { open: openUsdPricesModal, close: closeUsdPricesModal }, 1047 | ] = useDisclosure(false); 1048 | 1049 | // Intentionally not memoized so that it updates when we fetch more token prices 1050 | const alreadyFetchedTokenPrices = getAlreadyFetchedTokenPrices(events); 1051 | 1052 | const tokenPricesToFetch = useMemo( 1053 | () => getTokenPricesToFetch(events, alreadyFetchedTokenPrices), 1054 | [events, alreadyFetchedTokenPrices], 1055 | ); 1056 | 1057 | const amountOfTokenPricesAlreadyFetched = Object.values( 1058 | alreadyFetchedTokenPrices, 1059 | ).flat().length; 1060 | const hasAlreadyFetchedAnyTokenPrices = amountOfTokenPricesAlreadyFetched > 0; 1061 | const amountOfTokenPricesMissing = 1062 | Object.values(tokenPricesToFetch).flat().length; 1063 | const [showUsdPrices, setShowUsdPrices] = useState( 1064 | hasAlreadyFetchedAnyTokenPrices, 1065 | ); 1066 | 1067 | if (recurringKeys.length === 0 && triggerKeys.length === 0) { 1068 | return ( 1069 | 1070 | 1071 | 1076 | No trades selected 1077 | 1078 | 1079 | ); 1080 | } 1081 | 1082 | const trades = events.filter((event) => event.kind === "trade") as Trade[]; 1083 | 1084 | return ( 1085 | <> 1086 | 1092 | 1093 | 1094 | 1095 | 1100 | 1105 | 1106 | setShowUsdPrices(!showUsdPrices)} 1109 | label={ 1110 |
1111 | Show USD prices 1112 |
({amountOfTokenPricesAlreadyFetched} fetched) 1113 |
1114 | } 1115 | /> 1116 | 1117 | {amountOfTokenPricesMissing > 0 ? ( 1118 | 1122 | ) : ( 1123 | 1126 | )} 1127 | 1128 | 1134 |
1135 |
1136 | 1137 | 1138 | 1139 | 1140 | Order 1141 | Date 1142 | Action 1143 | Amount 1144 | 1145 | 1146 | For 1147 | setSubtractFee(!subtractFee)} 1150 | label="Subtract fee" 1151 | styles={{ 1152 | label: { 1153 | fontWeight: "normal", 1154 | }, 1155 | }} 1156 | /> 1157 | 1158 | 1159 | 1160 | 1161 | Rate 1162 | 1169 | {rateType === RateType.OUTPUT_PER_INPUT ? ( 1170 | 1174 | ) : ( 1175 | 1179 | )} 1180 | 1181 | 1182 | 1183 | Transaction 1184 | 1185 | 1186 | 1187 | {events.map((event) => { 1188 | if (event.kind === "trade") { 1189 | return ( 1190 | setSubtractFee(!subtractFee)} 1197 | switchRateType={switchRateType} 1198 | tokenPrices={showUsdPrices ? alreadyFetchedTokenPrices : {}} 1199 | /> 1200 | ); 1201 | } else { 1202 | return ( 1203 | 1209 | ); 1210 | } 1211 | })} 1212 | 1213 |
1214 |
1215 | 1216 | ); 1217 | } 1218 | --------------------------------------------------------------------------------