├── .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 | 
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 |
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 | }
478 | component={Link}
479 | to={"/"}
480 | >
481 | Change Address
482 |
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 |
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 |
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 |
--------------------------------------------------------------------------------