├── .github └── CODEOWNERS ├── rust-toolchain.toml ├── Cargo.toml ├── programs └── perpetuals │ ├── Xargo.toml │ ├── tests │ └── native │ │ ├── tests_suite │ │ ├── swap │ │ │ └── mod.rs │ │ ├── liquidity │ │ │ └── mod.rs │ │ ├── mod.rs │ │ └── position │ │ │ └── mod.rs │ │ ├── utils │ │ ├── mod.rs │ │ ├── pda.rs │ │ └── fixtures.rs │ │ ├── main.rs │ │ └── instructions │ │ ├── mod.rs │ │ ├── test_set_custody_config.rs │ │ ├── test_set_test_oracle_price.rs │ │ ├── test_close_position.rs │ │ ├── test_add_pool.rs │ │ ├── test_init.rs │ │ └── test_liquidate.rs │ ├── src │ ├── state.rs │ ├── instructions │ │ ├── get_assets_under_management.rs │ │ ├── set_admin_signers.rs │ │ ├── get_oracle_price.rs │ │ ├── set_test_time.rs │ │ ├── get_liquidation_state.rs │ │ ├── remove_pool.rs │ │ ├── set_permissions.rs │ │ ├── get_pnl.rs │ │ ├── set_test_oracle_price.rs │ │ ├── get_exit_price_and_fee.rs │ │ ├── withdraw_sol_fees.rs │ │ ├── set_custody_config.rs │ │ ├── get_entry_price_and_fee.rs │ │ ├── test_init.rs │ │ ├── get_remove_liquidity_amount_and_fee.rs │ │ ├── init.rs │ │ ├── get_add_liquidity_amount_and_fee.rs │ │ ├── withdraw_fees.rs │ │ └── get_liquidation_price.rs │ ├── state │ │ └── position.rs │ ├── instructions.rs │ └── error.rs │ └── Cargo.toml ├── rustfmt.toml ├── .vscode └── settings.json ├── .prettierignore ├── .gitignore ├── ui ├── postcss.config.js ├── next-env.d.ts ├── next.config.js ├── src │ ├── styles │ │ └── globals.css │ ├── pages │ │ ├── index.tsx │ │ ├── trade │ │ │ ├── index.tsx │ │ │ └── [pair].tsx │ │ ├── admin │ │ │ └── index.tsx │ │ ├── pools │ │ │ └── [poolName].tsx │ │ └── _app.tsx │ ├── components │ │ ├── Positions │ │ │ ├── PositionColumn.tsx │ │ │ ├── NoPositions.tsx │ │ │ ├── PositionValueDelta.tsx │ │ │ ├── index.tsx │ │ │ ├── PoolPositionHeader.tsx │ │ │ ├── ExistingPositions.tsx │ │ │ └── PoolPositionRow.tsx │ │ ├── Notify.tsx │ │ ├── Layouts │ │ │ ├── TradeLayout.tsx │ │ │ └── PoolLayout.tsx │ │ ├── Atoms │ │ │ ├── MaxButton.tsx │ │ │ ├── PoolBackButton.tsx │ │ │ └── UserBalance.tsx │ │ ├── PoolTokens.tsx │ │ ├── SidebarTab.tsx │ │ ├── Molecules │ │ │ └── PoolHeaders │ │ │ │ ├── TableHeader.tsx │ │ │ │ └── TitleHeader.tsx │ │ ├── LoadingDots.tsx │ │ ├── NavbarLink.tsx │ │ ├── Chart │ │ │ ├── DailyStats.tsx │ │ │ ├── CandlestickChart.tsx │ │ │ └── ChartCurrency.tsx │ │ ├── Icons │ │ │ └── LoadingSpinner.tsx │ │ ├── TradeSidebar │ │ │ ├── TradeSwapDetails.tsx │ │ │ ├── index.tsx │ │ │ └── TradeDetails.tsx │ │ ├── Navbar.tsx │ │ ├── SolidButton.tsx │ │ ├── PoolModal │ │ │ ├── LpSelector.tsx │ │ │ └── PoolGeneralStats.tsx │ │ ├── TokenSelectorList.tsx │ │ ├── LeverageSlider.tsx │ │ ├── AirdropButton.tsx │ │ └── PoolSelector.tsx │ ├── lib │ │ ├── UserAccount.tsx │ │ ├── classGetters.ts │ │ ├── PositionAccount.tsx │ │ ├── CustodyAccount.tsx │ │ └── types.tsx │ ├── utils │ │ ├── provider.ts │ │ ├── organizers.ts │ │ ├── formatters.ts │ │ ├── retrieveData.ts │ │ ├── constants.ts │ │ └── transactionHelpers.ts │ ├── hooks │ │ ├── storeHelpers │ │ │ ├── fetchCustodies.ts │ │ │ ├── fetchPrices.ts │ │ │ ├── fetchPositions.ts │ │ │ ├── fetchUserData.ts │ │ │ └── fetchPools.ts │ │ └── useHydrateStore.ts │ ├── stores │ │ └── store.tsx │ └── actions │ │ └── closePosition.ts ├── tailwind.config.js ├── tsconfig.json └── package.json ├── migrations ├── migrate-target.sh └── deploy.ts ├── app ├── tsconfig.json └── package.json ├── tsconfig.json ├── .githooks └── pre-commit ├── Anchor.toml ├── LICENSE ├── package.json └── DISCLAIMER.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @askibin -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.66.0" -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "programs/*" 4 | ] 5 | -------------------------------------------------------------------------------- /programs/perpetuals/Xargo.toml: -------------------------------------------------------------------------------- 1 | [target.bpfel-unknown-unknown.dependencies.std] 2 | features = [] 3 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | imports_granularity = "One" 2 | group_imports = "One" 3 | use_field_init_shorthand = true -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.rustfmt.extraArgs": [ 3 | "+nightly" 4 | ], 5 | } -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | 2 | .anchor 3 | .DS_Store 4 | target 5 | node_modules 6 | dist 7 | build 8 | test-ledger 9 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/tests_suite/swap/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod insuffisient_fund; 2 | 3 | pub use insuffisient_fund::*; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .anchor 3 | .DS_Store 4 | target 5 | **/*.rs.bk 6 | **/node_modules 7 | test-ledger 8 | 9 | **/yarn.lock 10 | 11 | **/.next 12 | -------------------------------------------------------------------------------- /ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod fixtures; 2 | pub mod pda; 3 | #[allow(clippy::module_inception)] 4 | pub mod utils; 5 | 6 | pub use {fixtures::*, pda::*, utils::*}; 7 | -------------------------------------------------------------------------------- /programs/perpetuals/src/state.rs: -------------------------------------------------------------------------------- 1 | // Program state handling. 2 | 3 | pub mod custody; 4 | pub mod multisig; 5 | pub mod oracle; 6 | pub mod perpetuals; 7 | pub mod pool; 8 | pub mod position; 9 | -------------------------------------------------------------------------------- /migrations/migrate-target.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf ui/src/target/* 4 | mkdir ui/src/target/types 5 | mkdir ui/src/target/idl 6 | cp -rf target/idl ui/src/target 7 | cp -rf target/types ui/src/target 8 | 9 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/tests_suite/liquidity/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod fixed_fees; 2 | pub mod insuffisient_fund; 3 | pub mod min_max_ratio; 4 | 5 | pub use {fixed_fees::*, insuffisient_fund::*, min_max_ratio::*}; 6 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/tests_suite/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod basic_interactions; 2 | pub mod liquidity; 3 | pub mod position; 4 | pub mod swap; 5 | 6 | pub use {basic_interactions::*, liquidity::*, position::*, swap::*}; 7 | -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "typeRoots": ["./node_modules/@types"], 4 | "lib": ["es2015"], 5 | "module": "commonjs", 6 | "target": "es6", 7 | "esModuleInterop": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/tests_suite/position/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod liquidate_position; 2 | pub mod max_user_profit; 3 | pub mod min_max_leverage; 4 | 5 | pub use {liquidate_position::*, max_user_profit::*, min_max_leverage::*}; 6 | -------------------------------------------------------------------------------- /ui/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["mocha", "chai"], 4 | "typeRoots": ["./node_modules/@types"], 5 | "lib": ["es2015"], 6 | "module": "commonjs", 7 | "target": "es6", 8 | "esModuleInterop": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | diff=$(cargo +nightly fmt -- --check) 4 | result=$? 5 | 6 | if [[ ${result} -ne 0 ]] ; then 7 | cat <<\EOF 8 | There are some code style issues, run `cargo +nightly fmt` first. 9 | EOF 10 | exit 1 11 | fi 12 | 13 | exit 0 -------------------------------------------------------------------------------- /ui/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | typescript: { 3 | // !! WARN !! 4 | // Dangerously allow production builds to successfully complete even if 5 | // your project has type errors. 6 | // !! WARN !! 7 | ignoreBuildErrors: true, 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /ui/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | input[type="number"]::-webkit-outer-spin-button, 6 | input[type="number"]::-webkit-inner-spin-button { 7 | -webkit-appearance: none; 8 | margin: 0; 9 | } 10 | 11 | input[type="number"] { 12 | -moz-appearance: textfield; 13 | } 14 | -------------------------------------------------------------------------------- /ui/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import Router from "next/router"; 3 | 4 | const IndexPage = () => { 5 | useEffect(() => { 6 | const { pathname } = Router; 7 | if (pathname == "/") { 8 | Router.push("/trade"); 9 | } 10 | }); 11 | 12 | return <>; 13 | }; 14 | 15 | export default IndexPage; 16 | -------------------------------------------------------------------------------- /ui/src/pages/trade/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import Router from "next/router"; 3 | 4 | const IndexPage = () => { 5 | useEffect(() => { 6 | const { pathname } = Router; 7 | if (pathname == "/trade") { 8 | Router.push("/trade/SOL-USD"); 9 | } 10 | }); 11 | 12 | return <>; 13 | }; 14 | 15 | export default IndexPage; 16 | -------------------------------------------------------------------------------- /ui/src/components/Positions/PositionColumn.tsx: -------------------------------------------------------------------------------- 1 | export const COL_WIDTHS = { 2 | 1: 14, 3 | 2: 13, 4 | 3: 13, 5 | 4: 13, 6 | 5: 13, 7 | 6: 13, 8 | 7: 18, 9 | } as const; 10 | 11 | export function PositionColumn(props: { 12 | children: React.ReactNode; 13 | num: keyof typeof COL_WIDTHS; 14 | }) { 15 | return ( 16 |
{props.children}
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/components/Notify.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "react-toastify"; 2 | 3 | // @ts-ignore 4 | export const notify = ( 5 | text: string | JSX.Element, 6 | type = "success", 7 | hideProgressBar = false 8 | ) => 9 | toast[type](text, { 10 | position: "bottom-left", 11 | autoClose: 3000, 12 | hideProgressBar: hideProgressBar, 13 | closeOnClick: true, 14 | pauseOnHover: true, 15 | draggable: true, 16 | progress: undefined, 17 | }); 18 | -------------------------------------------------------------------------------- /migrations/deploy.ts: -------------------------------------------------------------------------------- 1 | // Migrations are an early feature. Currently, they're nothing more than this 2 | // single deploy script that's invoked from the CLI, injecting a provider 3 | // configured from the workspace's Anchor.toml. 4 | 5 | const anchor = require("@project-serum/anchor"); 6 | 7 | module.exports = async function (provider) { 8 | // Configure client to use the provider. 9 | anchor.setProvider(provider); 10 | 11 | // Add your deploy script here. 12 | }; 13 | -------------------------------------------------------------------------------- /Anchor.toml: -------------------------------------------------------------------------------- 1 | [features] 2 | seeds = false 3 | [programs.localnet] 4 | perpetuals = "Bmr31xzZYYVUdoHmAJL1DAp2anaitW8Tw9YfASS94MKJ" 5 | [programs.devnet] 6 | perpetuals = "Bmr31xzZYYVUdoHmAJL1DAp2anaitW8Tw9YfASS94MKJ" 7 | 8 | [registry] 9 | url = "https://anchor.projectserum.com" 10 | 11 | [provider] 12 | cluster = "localnet" 13 | wallet = "~/.config/solana/id.json" 14 | 15 | [scripts] 16 | test = "yarn run ts-mocha -p ./tsconfig.json -t 1000000 programs/perpetuals/tests/anchor/*.ts" 17 | -------------------------------------------------------------------------------- /ui/src/components/Positions/NoPositions.tsx: -------------------------------------------------------------------------------- 1 | import ChartCandlestick from "@carbon/icons-react/lib/ChartCandlestick"; 2 | 3 | interface Props { 4 | className?: string; 5 | emptyString?: string; 6 | } 7 | 8 | export function NoPositions(props: Props) { 9 | return ( 10 |
11 | 12 |

{props.emptyString}

13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/lib/UserAccount.tsx: -------------------------------------------------------------------------------- 1 | import { TokenE } from "@/lib/Token"; 2 | 3 | export class UserAccount { 4 | public lpBalances: Record; 5 | public tokenBalances: Record; 6 | 7 | constructor( 8 | lpBalances: Record = {}, 9 | tokenBalances: Record = {} 10 | ) { 11 | this.lpBalances = lpBalances; 12 | this.tokenBalances = tokenBalances; 13 | } 14 | 15 | getUserLpBalance(poolAddress: string): number { 16 | return this.lpBalances[poolAddress] || 0; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Solana Foundation. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/main.rs: -------------------------------------------------------------------------------- 1 | pub mod instructions; 2 | pub mod tests_suite; 3 | pub mod utils; 4 | 5 | #[tokio::test] 6 | pub async fn test_integration() { 7 | tests_suite::basic_interactions().await; 8 | 9 | tests_suite::swap::insuffisient_fund().await; 10 | 11 | tests_suite::liquidity::fixed_fees().await; 12 | tests_suite::liquidity::insuffisient_fund().await; 13 | tests_suite::liquidity::min_max_ratio().await; 14 | 15 | tests_suite::position::min_max_leverage().await; 16 | tests_suite::position::liquidate_position().await; 17 | tests_suite::position::max_user_profit().await; 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/lib/classGetters.ts: -------------------------------------------------------------------------------- 1 | import { CustodyAccount } from "@/lib/CustodyAccount"; 2 | import { PoolAccount } from "@/lib/PoolAccount"; 3 | import { PriceStats } from "@/lib/types"; 4 | 5 | export function getCurrentWeight( 6 | pool: PoolAccount, 7 | custody: CustodyAccount, 8 | stats: PriceStats 9 | ): number { 10 | let token = custody.getTokenE(); 11 | const custodyAmount = Number(custody.assets.owned) / 10 ** custody.decimals; 12 | 13 | const custodyPrice = stats[token].currentPrice; 14 | 15 | return pool.getLiquidities(stats) 16 | ? (100 * custodyAmount * custodyPrice) / pool.getLiquidities(stats) 17 | : 0; 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", 4 | "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" 5 | }, 6 | "dependencies": { 7 | "@project-serum/anchor": "^0.25.0", 8 | "@solana/spl-token": "^0.3.6", 9 | "@solana/web3.js": "^1.70.1", 10 | "ts-node": "^10.9.1" 11 | }, 12 | "devDependencies": { 13 | "@types/bn.js": "^5.1.1", 14 | "@types/chai": "^4.3.4", 15 | "@types/mocha": "^10.0.1", 16 | "chai": "^4.3.7", 17 | "mocha": "^10.2.0", 18 | "prettier": "^2.8.1", 19 | "ts-mocha": "^10.0.0", 20 | "typescript": "^4.9.4" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w", 4 | "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check" 5 | }, 6 | "dependencies": { 7 | "@project-serum/anchor": "^0.25.0", 8 | "@solana/spl-token": "^0.3.6", 9 | "@solana/web3.js": "^1.70.1", 10 | "bs58": "^5.0.0", 11 | "commander": "^10.0.0", 12 | "fs": "^0.0.1-security", 13 | "js-sha256": "^0.9.0", 14 | "jsbi": "^4", 15 | "node-fetch": "^3.3.0", 16 | "ts-node": "^10.9.1" 17 | }, 18 | "devDependencies": { 19 | "@types/bn.js": "^5.1.1", 20 | "prettier": "^2.8.1", 21 | "typescript": "^4.9.4" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/instructions/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod test_add_custody; 2 | pub mod test_add_liquidity; 3 | pub mod test_add_pool; 4 | pub mod test_close_position; 5 | pub mod test_init; 6 | pub mod test_liquidate; 7 | pub mod test_open_position; 8 | pub mod test_remove_liquidity; 9 | pub mod test_set_custody_config; 10 | pub mod test_set_test_oracle_price; 11 | pub mod test_swap; 12 | 13 | pub use { 14 | test_add_custody::*, test_add_liquidity::*, test_add_pool::*, test_close_position::*, 15 | test_init::*, test_liquidate::*, test_open_position::*, test_remove_liquidity::*, 16 | test_set_custody_config::*, test_set_test_oracle_price::*, test_swap::*, 17 | }; 18 | -------------------------------------------------------------------------------- /ui/src/components/Layouts/TradeLayout.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | interface Props { 4 | className?: string; 5 | children: [React.ReactNode, React.ReactNode]; 6 | } 7 | 8 | export function TradeLayout(props: Props) { 9 | return ( 10 |
23 |
{props.children[0]}
24 |
{props.children[1]}
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/components/Atoms/MaxButton.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | interface Props { 4 | maxBalance?: number; 5 | onChangeAmount?: (amount: number) => void; 6 | } 7 | export function MaxButton(props: Props) { 8 | if (props.maxBalance && props.onChangeAmount) { 9 | return ( 10 | 24 | ); 25 | } else { 26 | return <>; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/utils/provider.ts: -------------------------------------------------------------------------------- 1 | import { Connection } from "@solana/web3.js"; 2 | import { AnchorProvider, Wallet } from "@project-serum/anchor"; 3 | 4 | export async function getProvider( 5 | wallet: Wallet, 6 | network: string = "devnet" 7 | ): Promise { 8 | let network_url; 9 | if (network === "devnet") { 10 | network_url = "https://api.devnet.solana.com"; 11 | } else { 12 | network_url = "http://localhost:8899"; 13 | } 14 | 15 | const connection = new Connection(network_url, { 16 | commitment: "processed", 17 | }); 18 | 19 | const provider = new AnchorProvider(connection, wallet, { 20 | commitment: "processed", 21 | skipPreflight: true, 22 | }); 23 | return provider; 24 | } 25 | -------------------------------------------------------------------------------- /ui/src/components/Atoms/PoolBackButton.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeft } from "@carbon/icons-react"; 2 | import { useRouter } from "next/router"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | interface Props { 6 | className?: string; 7 | } 8 | export default function PoolBackButton(props: Props) { 9 | const router = useRouter(); 10 | 11 | return ( 12 |
router.push("/pools")} 21 | > 22 | 23 | 24 |

Back To Pools

25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /ui/src/pages/admin/index.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/Icons/LoadingSpinner"; 2 | import { ExistingPositions } from "@/components/Positions/ExistingPositions"; 3 | import { useGlobalStore } from "@/stores/store"; 4 | 5 | interface Props { 6 | className?: string; 7 | } 8 | 9 | export default function Admin(props: Props) { 10 | const positionData = useGlobalStore((state) => state.positionData); 11 | return ( 12 |
13 |
14 |
All Positions
15 | {positionData.status === "pending" && ( 16 | 17 | )} 18 |
19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/components/Layouts/PoolLayout.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | interface Props { 4 | className?: string; 5 | children: [React.ReactNode, React.ReactNode, React.ReactNode]; 6 | } 7 | 8 | export function PoolLayout(props: Props) { 9 | return ( 10 |
20 |
{props.children[0]}
21 |
30 |
{props.children[1]}
31 |
{props.children[2]}
32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /ui/src/components/PoolTokens.tsx: -------------------------------------------------------------------------------- 1 | import { getTokenIcon, TokenE } from "@/lib/Token"; 2 | import { cloneElement } from "react"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | interface Props { 6 | className?: string; 7 | tokens: TokenE[]; 8 | } 9 | 10 | export function PoolTokens(props: Props) { 11 | return ( 12 |
13 | {props.tokens.slice(0, 3).map((token, i) => { 14 | const tokenIcon = getTokenIcon(token); 15 | 16 | return cloneElement(tokenIcon, { 17 | className: twMerge( 18 | tokenIcon.props.className, 19 | props.className, 20 | "border-black", 21 | "border", 22 | "rounded-full", 23 | "relative", 24 | "shrink-0" 25 | ), 26 | style: { zIndex: 3 - i }, 27 | key: i, 28 | }); 29 | })} 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /ui/src/hooks/storeHelpers/fetchCustodies.ts: -------------------------------------------------------------------------------- 1 | import { CustodyAccount } from "@/lib/CustodyAccount"; 2 | import { Custody } from "@/lib/types"; 3 | import { getPerpetualProgramAndProvider } from "@/utils/constants"; 4 | import { PublicKey } from "@solana/web3.js"; 5 | 6 | interface FetchCustody { 7 | account: Custody; 8 | publicKey: PublicKey; 9 | } 10 | 11 | export async function getCustodyData(): Promise< 12 | Record 13 | > { 14 | let { perpetual_program } = await getPerpetualProgramAndProvider(); 15 | 16 | // @ts-ignore 17 | let fetchedCustodies: FetchCustody[] = 18 | await perpetual_program.account.custody.all(); 19 | 20 | let custodyInfos: Record = fetchedCustodies.reduce( 21 | (acc: Record, { account, publicKey }) => ( 22 | (acc[publicKey.toString()] = new CustodyAccount(account, publicKey)), acc 23 | ), 24 | {} 25 | ); 26 | 27 | return custodyInfos; 28 | } 29 | -------------------------------------------------------------------------------- /ui/src/components/SidebarTab.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | interface Props { 4 | className?: string; 5 | children: React.ReactNode; 6 | selected?: boolean; 7 | onClick?(): void; 8 | } 9 | 10 | export function SidebarTab(props: Props) { 11 | return ( 12 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/components/Molecules/PoolHeaders/TableHeader.tsx: -------------------------------------------------------------------------------- 1 | import { PoolTokens } from "@/components/PoolTokens"; 2 | import { PoolAccount } from "@/lib/PoolAccount"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | interface Props { 6 | iconClassName?: string; 7 | poolClassName?: string; 8 | pool: PoolAccount; 9 | } 10 | 11 | export function TableHeader(props: Props) { 12 | return ( 13 |
14 | {Object.keys(props.pool.custodies).length > 0 ? ( 15 | 19 | ) : ( 20 |
21 | )} 22 |
23 |

24 | {props.pool.name} 25 |

26 |
27 |

{props.pool.getTokenList().join(", ")}

28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 3 | theme: { 4 | fontSize: { 5 | xs: "0.75rem", 6 | sm: "0.875rem", 7 | base: "1rem", 8 | lg: "1.125rem", 9 | xl: "1.25rem", 10 | "2xl": "1.5rem", 11 | "3xl": "1.875rem", 12 | "4xl": "2.25rem", 13 | "5xl": "3rem", 14 | "6xl": "4rem", 15 | }, 16 | extend: { 17 | colors: { 18 | gray: { 19 | 100: "#f7fafc", 20 | 200: "#edf2f7", 21 | 300: "#e2e8f0", 22 | 400: "#cbd5e0", 23 | 500: "#a0aec0", 24 | 600: "#718096", 25 | 700: "#4a5568", 26 | 800: "#2d3748", 27 | 900: "#1a202c", 28 | }, 29 | blue: { 30 | 100: "#ebf8ff", 31 | 200: "#bee3f8", 32 | 300: "#90cdf4", 33 | 400: "#63b3ed", 34 | 500: "#4299e1", 35 | 600: "#3182ce", 36 | 700: "#2b6cb0", 37 | 800: "#2c5282", 38 | 900: "#2a4365", 39 | }, 40 | }, 41 | }, 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /ui/src/components/LoadingDots.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | 3 | interface Props { 4 | className?: string; 5 | numDots: number; 6 | style: "bounce" | "pulse"; 7 | } 8 | 9 | export function LoadingDots(props: Props) { 10 | return ( 11 |
20 | {Array.from({ length: props.numDots }).map((_, i) => ( 21 |
37 | ))} 38 |
39 | ); 40 | } 41 | 42 | LoadingDots.defaultProps = { 43 | numDots: 3, 44 | style: "bounce", 45 | }; 46 | -------------------------------------------------------------------------------- /ui/src/components/Positions/PositionValueDelta.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | formatValueDelta, 3 | formatValueDeltaPercentage, 4 | } from "@/utils/formatters"; 5 | import { twMerge } from "tailwind-merge"; 6 | 7 | interface Props { 8 | className?: string; 9 | valueDelta: number; 10 | valueDeltaPercentage: number; 11 | } 12 | 13 | export function PositionValueDelta(props: Props) { 14 | return ( 15 |
16 |
0 ? "text-emerald-400" : "text-rose-400" 21 | )} 22 | > 23 | {props.valueDelta > 0 && "+"} 24 | {formatValueDelta(props.valueDelta)} 25 |
26 |
0 ? "bg-emerald-400" : "bg-rose-400" 34 | )} 35 | > 36 | {props.valueDeltaPercentage > 0 && "+"} 37 | {formatValueDeltaPercentage(props.valueDeltaPercentage)}% 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /programs/perpetuals/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "perpetuals" 3 | version = "0.1.0" 4 | description = "Solana Perpetuals Exchange" 5 | authors = ["Solana Maintainers "] 6 | repository = "https://github.com/askibin/perpetuals" 7 | categories = ["finance"] 8 | keywords = ["solana", "dex", "perpetuals", "futures", "exchange"] 9 | license = "Apache-2.0" 10 | homepage = "https://solana.com/" 11 | edition = "2021" 12 | 13 | [lib] 14 | crate-type = ["cdylib", "lib"] 15 | name = "perpetuals" 16 | 17 | [features] 18 | no-entrypoint = [] 19 | no-idl = [] 20 | no-log-ix-name = [] 21 | cpi = ["no-entrypoint"] 22 | test = [] 23 | default = [] 24 | 25 | [profile.release] 26 | lto = true 27 | codegen-units = 1 28 | overflow-checks = true 29 | 30 | [dependencies] 31 | anchor-lang = {version = "0.26.0", features = ["init-if-needed"]} 32 | anchor-spl = "0.26.0" 33 | solana-program = "1.14.13" 34 | solana-security-txt = "1.1.0" 35 | pyth-sdk-solana = "0.7.0" 36 | ahash = "=0.7.6" 37 | num-traits = "0.2.15" 38 | num = "0.4.0" 39 | 40 | [dev-dependencies] 41 | solana-program-test = "1.14.13" 42 | solana-sdk = "1.14.13" 43 | tokio = { version = "1.0.0", features = ["macros"]} 44 | bonfida-test-utils = "0.2.1" 45 | bincode = "1.3.3" -------------------------------------------------------------------------------- /ui/src/components/Molecules/PoolHeaders/TitleHeader.tsx: -------------------------------------------------------------------------------- 1 | import { PoolTokens } from "@/components/PoolTokens"; 2 | import { PoolAccount } from "@/lib/PoolAccount"; 3 | import { ACCOUNT_URL } from "@/utils/TransactionHandlers"; 4 | import NewTab from "@carbon/icons-react/lib/NewTab"; 5 | import { twMerge } from "tailwind-merge"; 6 | 7 | interface Props { 8 | className?: string; 9 | iconClassName?: string; 10 | pool: PoolAccount; 11 | } 12 | 13 | export function TitleHeader(props: Props) { 14 | return ( 15 |
16 |
17 | 21 |

{props.pool.name}

22 | 27 | 28 | 29 |
30 |
31 |

{props.pool.getTokenList().join(", ")}

32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_assets_under_management.rs: -------------------------------------------------------------------------------- 1 | //! GetAssetsUnderManagement instruction handler 2 | 3 | use { 4 | crate::state::{ 5 | perpetuals::Perpetuals, 6 | pool::{AumCalcMode, Pool}, 7 | }, 8 | anchor_lang::prelude::*, 9 | }; 10 | 11 | #[derive(Accounts)] 12 | pub struct GetAssetsUnderManagement<'info> { 13 | #[account( 14 | seeds = [b"perpetuals"], 15 | bump = perpetuals.perpetuals_bump 16 | )] 17 | pub perpetuals: Box>, 18 | 19 | #[account( 20 | seeds = [b"pool", 21 | pool.name.as_bytes()], 22 | bump = pool.bump 23 | )] 24 | pub pool: Box>, 25 | // remaining accounts: 26 | // pool.tokens.len() custody accounts (read-only, unsigned) 27 | // pool.tokens.len() custody oracles (read-only, unsigned) 28 | } 29 | 30 | #[derive(AnchorSerialize, AnchorDeserialize)] 31 | pub struct GetAssetsUnderManagementParams {} 32 | 33 | pub fn get_assets_under_management( 34 | ctx: Context, 35 | _params: &GetAssetsUnderManagementParams, 36 | ) -> Result { 37 | ctx.accounts.pool.get_assets_under_management_usd( 38 | AumCalcMode::EMA, 39 | ctx.remaining_accounts, 40 | ctx.accounts.perpetuals.get_time()?, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /ui/src/components/Atoms/UserBalance.tsx: -------------------------------------------------------------------------------- 1 | import { TokenE } from "@/lib/Token"; 2 | import { useGlobalStore } from "@/stores/store"; 3 | import { useWallet } from "@solana/wallet-adapter-react"; 4 | 5 | interface Props { 6 | token: TokenE; 7 | onClick: () => void; 8 | } 9 | 10 | export function UserBalance(props: Props) { 11 | const { publicKey } = useWallet(); 12 | const userData = useGlobalStore((state) => state.userData); 13 | 14 | let balance = userData.tokenBalances[props.token]; 15 | if (!publicKey) { 16 | return ( 17 |
18 |

Connect Wallet

19 |
20 | ); 21 | } 22 | if (balance) { 23 | return ( 24 |
28 |

{balance.toFixed(4)}

29 |

{props.token}

30 |

Balance

31 |
32 | ); 33 | } else { 34 | return ( 35 |
36 |

0

37 |

{props.token}

38 |

Balance

39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ui/src/components/NavbarLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useRouter } from "next/router"; 3 | import { cloneElement } from "react"; 4 | import { twMerge } from "tailwind-merge"; 5 | 6 | interface Props extends React.AnchorHTMLAttributes { 7 | href: string; 8 | icon: JSX.Element; 9 | } 10 | 11 | export function NavbarLink(props: Props) { 12 | const router = useRouter(); 13 | 14 | const currentPath = router.pathname; 15 | const selected = currentPath.startsWith(props.href); 16 | 17 | return ( 18 | 37 |
{props.children}
38 | {cloneElement(props.icon, { 39 | className: twMerge( 40 | props.icon.props.className, 41 | "block", 42 | "fill-current", 43 | "h-4", 44 | "w-4", 45 | "md:hidden" 46 | ), 47 | })} 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/components/Chart/DailyStats.tsx: -------------------------------------------------------------------------------- 1 | import { TokenE } from "@/lib/Token"; 2 | import { useGlobalStore } from "@/stores/store"; 3 | import { formatNumberCommas } from "@/utils/formatters"; 4 | import { twMerge } from "tailwind-merge"; 5 | 6 | interface DailyStatsProps { 7 | className?: string; 8 | token: TokenE; 9 | } 10 | 11 | export function DailyStats(props: DailyStatsProps) { 12 | const stats = useGlobalStore((state) => state.priceStats); 13 | 14 | if (Object.values(stats).length === 0) return

sdf

; 15 | 16 | return ( 17 |
20 |
21 |
Current Price
22 |
23 | ${formatNumberCommas(stats[props.token].currentPrice)} 24 |
25 |
26 |
27 |
24h Change
28 |
0 && "text-emerald-400" 34 | )} 35 | > 36 | ${formatNumberCommas(stats[props.token].change24hr)} 37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /ui/src/components/Icons/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const LoadingSpinner = (props: React.SVGProps) => { 4 | const {className, ...rest} = props 5 | return ( 6 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/pages/trade/[pair].tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { TradeLayout } from "@/components/Layouts/TradeLayout"; 3 | import { CandlestickChart } from "@/components/Chart/CandlestickChart"; 4 | import { TradeSidebar } from "@/components/TradeSidebar"; 5 | import { asToken } from "@/lib/Token"; 6 | import { Positions } from "@/components/Positions"; 7 | 8 | function getToken(pair: string) { 9 | const [token, _] = pair.split("-"); 10 | return asToken(token || ""); 11 | } 12 | 13 | function getComparisonCurrency() { 14 | return "usd" as const; 15 | } 16 | 17 | export default function Page() { 18 | const router = useRouter(); 19 | const { pair } = router.query; 20 | 21 | if (!pair) { 22 | return <>; 23 | } 24 | 25 | // @ts-ignore 26 | let token: ReturnType = asToken(pair.split("-")[0]); 27 | let currency: ReturnType = 28 | getComparisonCurrency(); 29 | 30 | if (pair && Array.isArray(pair)) { 31 | const tokenAndCurrency = pair[0]; 32 | 33 | if (tokenAndCurrency) { 34 | token = getToken(tokenAndCurrency); 35 | currency = getComparisonCurrency(); 36 | } 37 | } 38 | 39 | return ( 40 | 41 |
42 | 43 |
44 |
45 | 46 | 47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /ui/src/pages/pools/[poolName].tsx: -------------------------------------------------------------------------------- 1 | import PoolBackButton from "@/components/Atoms/PoolBackButton"; 2 | import { LoadingSpinner } from "@/components/Icons/LoadingSpinner"; 3 | import { PoolLayout } from "@/components/Layouts/PoolLayout"; 4 | import { TitleHeader } from "@/components/Molecules/PoolHeaders/TitleHeader"; 5 | import LiquidityCard from "@/components/PoolModal/LiquidityCard"; 6 | import PoolGeneralStats from "@/components/PoolModal/PoolGeneralStats"; 7 | import PoolTokenStats from "@/components/PoolModal/PoolTokenStats"; 8 | import { useGlobalStore } from "@/stores/store"; 9 | import { useRouter } from "next/router"; 10 | 11 | export default function SinglePool() { 12 | const router = useRouter(); 13 | 14 | const poolData = useGlobalStore((state) => state.poolData); 15 | let pool = poolData[router.query.poolName as string]; 16 | 17 | if (!pool) { 18 | return ; 19 | } else { 20 | return ( 21 | 22 |
23 | 24 | 29 |
30 |
31 | 32 | 33 |
34 | 35 |
36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/hooks/storeHelpers/fetchPrices.ts: -------------------------------------------------------------------------------- 1 | import { TOKEN_LIST, getTokenId } from "@/lib/Token"; 2 | import { PriceStats } from "@/lib/types"; 3 | 4 | type FetchedData = { 5 | [key: string]: { 6 | usd: number; 7 | usd_24h_vol: number; 8 | usd_24h_change: number; 9 | }; 10 | }; 11 | 12 | export function fetchAllStats(): PriceStats { 13 | let stats = fetch( 14 | `https://api.coingecko.com/api/v3/simple/price?ids=${TOKEN_LIST.map( 15 | getTokenId 16 | ).join( 17 | "," 18 | )}&vs_currencies=USD&include_24hr_vol=true&include_24hr_change=true` 19 | ) 20 | .then((resp) => resp.json()) 21 | .then((data: FetchedData) => { 22 | const allStats = TOKEN_LIST.reduce((acc, token) => { 23 | const tokenData = data[getTokenId(token)]; 24 | 25 | acc[token] = { 26 | change24hr: tokenData!.usd_24h_change, 27 | currentPrice: tokenData!.usd, 28 | high24hr: 0, 29 | low24hr: 0, 30 | }; 31 | 32 | return acc; 33 | }, {} as PriceStats); 34 | 35 | return allStats; 36 | }) 37 | .catch(() => { 38 | console.log("caught data fetching error"); 39 | const allStats = TOKEN_LIST.reduce((acc, token) => { 40 | acc[token] = { 41 | change24hr: 0, 42 | currentPrice: 0, 43 | high24hr: 0, 44 | low24hr: 0, 45 | }; 46 | 47 | return acc; 48 | }, {} as PriceStats); 49 | 50 | return allStats; 51 | }); 52 | 53 | return stats; 54 | } 55 | -------------------------------------------------------------------------------- /ui/src/components/Positions/index.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/Icons/LoadingSpinner"; 2 | import { LoadingDots } from "@/components/LoadingDots"; 3 | import { ExistingPositions } from "@/components/Positions/ExistingPositions"; 4 | import { NoPositions } from "@/components/Positions/NoPositions"; 5 | import { useGlobalStore } from "@/stores/store"; 6 | import { useWallet } from "@solana/wallet-adapter-react"; 7 | 8 | interface Props { 9 | className?: string; 10 | } 11 | 12 | export function Positions(props: Props) { 13 | const { publicKey } = useWallet(); 14 | 15 | const positionData = useGlobalStore((state) => state.positionData); 16 | 17 | if (positionData.status === "pending") { 18 | return ; 19 | } 20 | 21 | if (!publicKey) { 22 | return ( 23 |
24 |
25 |
My Positions
26 |
27 | 28 | 29 |
30 | ); 31 | } 32 | 33 | return ( 34 |
35 |
36 |
My Positions
37 | {positionData.status === "pending" && ( 38 | 39 | )} 40 |
41 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /ui/src/hooks/storeHelpers/fetchPositions.ts: -------------------------------------------------------------------------------- 1 | import { CustodyAccount } from "@/lib/CustodyAccount"; 2 | import { PositionAccount } from "@/lib/PositionAccount"; 3 | import { Position } from "@/lib/types"; 4 | import { getPerpetualProgramAndProvider } from "@/utils/constants"; 5 | import { PublicKey } from "@solana/web3.js"; 6 | 7 | interface Pending { 8 | status: "pending"; 9 | } 10 | 11 | interface Failure { 12 | status: "failure"; 13 | error: Error; 14 | } 15 | 16 | interface Success { 17 | status: "success"; 18 | data: Record; 19 | } 20 | 21 | interface FetchPosition { 22 | account: Position; 23 | publicKey: PublicKey; 24 | } 25 | 26 | export type PositionRequest = Pending | Failure | Success; 27 | 28 | export async function getPositionData( 29 | custodyInfos: Record 30 | ): Promise { 31 | let { perpetual_program } = await getPerpetualProgramAndProvider(); 32 | 33 | // @ts-ignore 34 | let fetchedPositions: FetchPosition[] = 35 | await perpetual_program.account.position.all(); 36 | 37 | let positionInfos: Record = fetchedPositions.reduce( 38 | (acc: Record, position: FetchPosition) => ( 39 | (acc[position.publicKey.toString()] = new PositionAccount( 40 | position.account, 41 | position.publicKey, 42 | custodyInfos 43 | )), 44 | acc 45 | ), 46 | {} 47 | ); 48 | 49 | return { 50 | status: "success", 51 | data: positionInfos, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "removeComments": true, 8 | "preserveConstEnums": true, 9 | "strict": true, 10 | "alwaysStrict": true, 11 | "strictNullChecks": true, 12 | "noUncheckedIndexedAccess": true, 13 | "noImplicitAny": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "allowUnreachableCode": false, 19 | "noFallthroughCasesInSwitch": true, 20 | "target": "es5", 21 | "outDir": "out", 22 | "declaration": true, 23 | "sourceMap": true, 24 | "esModuleInterop": true, 25 | "allowSyntheticDefaultImports": true, 26 | "allowJs": false, 27 | "skipLibCheck": true, 28 | "forceConsistentCasingInFileNames": true, 29 | "jsx": "preserve", 30 | "noEmit": true, 31 | "isolatedModules": true, 32 | "incremental": true, 33 | "baseUrl": ".", 34 | "paths": { 35 | "@/components/*": ["./src/components/*"], 36 | "@/utils/*": ["./src/utils/*"], 37 | "@/styles/*": ["./src/styles/*"], 38 | "@/hooks/*": ["./src/hooks/*"], 39 | "@/target/*": ["./src/target/*"], 40 | "@/lib/*": ["src/lib/*"], 41 | "@/stores/*": ["src/stores/*"], 42 | } 43 | }, 44 | "exclude": ["./out/**/*", "./node_modules/**/*", "**/*.test.ts"], 45 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 46 | } 47 | -------------------------------------------------------------------------------- /ui/src/components/Positions/PoolPositionHeader.tsx: -------------------------------------------------------------------------------- 1 | import { PoolTokens } from "@/components/PoolTokens"; 2 | import { PositionColumn } from "@/components/Positions/PositionColumn"; 3 | import { PositionAccount } from "@/lib/PositionAccount"; 4 | import { useGlobalStore } from "@/stores/store"; 5 | 6 | interface Props { 7 | className?: string; 8 | positions: PositionAccount[]; 9 | } 10 | 11 | export default function PoolPositionHeader(props: Props) { 12 | const allTokens = props.positions.map((position) => { 13 | return position.token; 14 | }); 15 | 16 | const tokens = Array.from(new Set(allTokens)); 17 | 18 | const poolData = useGlobalStore((state) => state.poolData); 19 | 20 | if (!props.positions[0]) return

No Positions

; 21 | 22 | return ( 23 | <> 24 | 25 |
26 | 27 |
28 | {poolData[props.positions[0].pool.toString()]?.name} 29 |
30 |
31 |
32 | Leverage 33 | Net Value 34 | Collateral 35 | Entry Price 36 | Mark Price 37 | Liq. Price 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /programs/perpetuals/src/state/position.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{math, state::perpetuals::Perpetuals}, 3 | anchor_lang::prelude::*, 4 | }; 5 | 6 | #[derive(Copy, Clone, PartialEq, AnchorSerialize, AnchorDeserialize, Debug)] 7 | pub enum Side { 8 | None, 9 | Long, 10 | Short, 11 | } 12 | 13 | impl Default for Side { 14 | fn default() -> Self { 15 | Self::None 16 | } 17 | } 18 | 19 | #[derive(Copy, Clone, PartialEq, AnchorSerialize, AnchorDeserialize, Debug)] 20 | pub enum CollateralChange { 21 | None, 22 | Add, 23 | Remove, 24 | } 25 | 26 | impl Default for CollateralChange { 27 | fn default() -> Self { 28 | Self::None 29 | } 30 | } 31 | 32 | #[account] 33 | #[derive(Default, Debug)] 34 | pub struct Position { 35 | pub owner: Pubkey, 36 | pub pool: Pubkey, 37 | pub custody: Pubkey, 38 | 39 | pub open_time: i64, 40 | pub update_time: i64, 41 | pub side: Side, 42 | pub price: u64, 43 | pub size_usd: u64, 44 | pub collateral_usd: u64, 45 | pub unrealized_profit_usd: u64, 46 | pub unrealized_loss_usd: u64, 47 | pub cumulative_interest_snapshot: u128, 48 | pub locked_amount: u64, 49 | pub collateral_amount: u64, 50 | 51 | pub bump: u8, 52 | } 53 | 54 | impl Position { 55 | pub const LEN: usize = 8 + std::mem::size_of::(); 56 | 57 | pub fn get_initial_leverage(&self) -> Result { 58 | math::checked_as_u64(math::checked_div( 59 | math::checked_mul(self.size_usd as u128, Perpetuals::BPS_POWER)?, 60 | self.collateral_usd as u128, 61 | )?) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/set_admin_signers.rs: -------------------------------------------------------------------------------- 1 | //! SetAdminSigners instruction handler 2 | 3 | use { 4 | crate::state::multisig::{AdminInstruction, Multisig}, 5 | anchor_lang::prelude::*, 6 | }; 7 | 8 | #[derive(Accounts)] 9 | pub struct SetAdminSigners<'info> { 10 | #[account()] 11 | pub admin: Signer<'info>, 12 | 13 | #[account( 14 | mut, 15 | seeds = [b"multisig"], 16 | bump = multisig.load()?.bump 17 | )] 18 | pub multisig: AccountLoader<'info, Multisig>, 19 | // remaining accounts: 1 to Multisig::MAX_SIGNERS admin signers (read-only, unsigned) 20 | } 21 | 22 | #[derive(AnchorSerialize, AnchorDeserialize)] 23 | pub struct SetAdminSignersParams { 24 | pub min_signatures: u8, 25 | } 26 | 27 | pub fn set_admin_signers<'info>( 28 | ctx: Context<'_, '_, '_, 'info, SetAdminSigners<'info>>, 29 | params: &SetAdminSignersParams, 30 | ) -> Result { 31 | // validate signatures 32 | let mut multisig = ctx.accounts.multisig.load_mut()?; 33 | 34 | let signatures_left = multisig.sign_multisig( 35 | &ctx.accounts.admin, 36 | &Multisig::get_account_infos(&ctx)[1..], 37 | &Multisig::get_instruction_data(AdminInstruction::SetAdminSigners, params)?, 38 | )?; 39 | if signatures_left > 0 { 40 | msg!( 41 | "Instruction has been signed but more signatures are required: {}", 42 | signatures_left 43 | ); 44 | return Ok(signatures_left); 45 | } 46 | 47 | // set new admin signers 48 | multisig.set_signers(ctx.remaining_accounts, params.min_signatures)?; 49 | 50 | Ok(0) 51 | } 52 | -------------------------------------------------------------------------------- /ui/src/components/TradeSidebar/TradeSwapDetails.tsx: -------------------------------------------------------------------------------- 1 | import { TokenE } from "@/lib/Token"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | function formatPrice(num: number) { 5 | const formatter = Intl.NumberFormat("en", { 6 | maximumFractionDigits: 2, 7 | minimumFractionDigits: 2, 8 | }); 9 | return formatter.format(num); 10 | } 11 | 12 | interface Props { 13 | availableLiquidity: number; 14 | className?: string; 15 | payToken: TokenE; 16 | payTokenPrice: number; 17 | receiveToken: TokenE; 18 | receiveTokenPrice: number; 19 | } 20 | 21 | export function TradeSwapDetails(props: Props) { 22 | return ( 23 |
24 |
25 | {[ 26 | { 27 | label: `${props.payToken} Price`, 28 | value: `$${formatPrice(props.payTokenPrice)}`, 29 | }, 30 | { 31 | label: `${props.receiveToken} Price`, 32 | value: `$${formatPrice(props.receiveTokenPrice)}`, 33 | }, 34 | { 35 | label: "Available Liquidity", 36 | value: `$${formatPrice(props.availableLiquidity)}`, 37 | }, 38 | ].map(({ label, value }, i) => ( 39 |
47 |
{label}
48 |
{value}
49 |
50 | ))} 51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /ui/src/components/Chart/CandlestickChart.tsx: -------------------------------------------------------------------------------- 1 | import { ChartCurrency } from "@/components/Chart/ChartCurrency"; 2 | import { DailyStats } from "@/components/Chart/DailyStats"; 3 | import { getSymbol, TokenE } from "@/lib/Token"; 4 | import dynamic from "next/dynamic"; 5 | 6 | // @ts-ignore 7 | const TradingViewWidget = dynamic(import("react-tradingview-widget"), { 8 | ssr: false, 9 | }); 10 | 11 | interface Props { 12 | className?: string; 13 | comparisonCurrency: "usd"; 14 | token: TokenE; 15 | } 16 | 17 | export function CandlestickChart(props: Props) { 18 | return ( 19 |
20 |
21 | 25 | 26 |
27 |
28 | 33 |
34 | 40 | {props.token} stock chart 41 | 42 | by TradingView 43 |
44 |
45 |
46 | ); 47 | } 48 | 49 | CandlestickChart.defaultProps = { 50 | token: TokenE.SOL, 51 | comparisonCurrency: "usd", 52 | }; 53 | -------------------------------------------------------------------------------- /ui/src/stores/store.tsx: -------------------------------------------------------------------------------- 1 | import { PositionRequest } from "@/hooks/storeHelpers/fetchPositions"; 2 | import { CustodyAccount } from "@/lib/CustodyAccount"; 3 | import { PoolAccount } from "@/lib/PoolAccount"; 4 | import { Custody, PriceStats } from "@/lib/types"; 5 | import { UserAccount } from "@/lib/UserAccount"; 6 | import { create } from "zustand"; 7 | import { devtools } from "zustand/middleware"; 8 | 9 | interface StoreState { 10 | positionData: PositionRequest; 11 | setPositionData: (position: PositionRequest) => void; 12 | 13 | poolData: Record; 14 | setPoolData: (pool: Record) => void; 15 | 16 | custodyData: Record; 17 | setCustodyData: (custody: Record) => void; 18 | 19 | userData: UserAccount; 20 | setUserData: (user: UserAccount) => void; 21 | 22 | priceStats: PriceStats; 23 | setPriceStats: (stats: PriceStats) => void; 24 | } 25 | 26 | export const useGlobalStore = create()( 27 | devtools((set, get) => ({ 28 | devtools: false, 29 | 30 | positionData: { 31 | status: "pending", 32 | }, 33 | setPositionData: (position: PositionRequest) => 34 | set({ positionData: position }), 35 | 36 | poolData: {}, 37 | setPoolData: (poolObjs: Record) => 38 | set({ poolData: poolObjs }), 39 | 40 | custodyData: {}, 41 | setCustodyData: (custody: Record) => 42 | set({ custodyData: custody }), 43 | 44 | userData: new UserAccount(), 45 | setUserData: (user: UserAccount) => set({ userData: user }), 46 | 47 | priceStats: {}, 48 | setPriceStats: (stats: PriceStats) => set({ priceStats: stats }), 49 | })) 50 | ); 51 | -------------------------------------------------------------------------------- /ui/src/utils/organizers.ts: -------------------------------------------------------------------------------- 1 | import { PositionRequest } from "@/hooks/storeHelpers/fetchPositions"; 2 | import { PositionAccount } from "@/lib/PositionAccount"; 3 | import { PublicKey } from "@solana/web3.js"; 4 | 5 | export function getPoolSortedPositions( 6 | positionData: PositionRequest, 7 | user?: PublicKey 8 | ) { 9 | let sortedPositions: Record = {}; 10 | 11 | if ( 12 | positionData.status === "success" && 13 | Object.values(positionData.data).length > 0 14 | ) { 15 | Object.values(positionData.data).forEach((position: PositionAccount) => { 16 | if (user && position.owner.toBase58() !== user.toBase58()) { 17 | return; 18 | } 19 | 20 | let pool = position.pool.toString(); 21 | 22 | if (!sortedPositions[pool]) { 23 | sortedPositions[pool] = []; 24 | } 25 | 26 | sortedPositions[pool].push(position); 27 | }); 28 | } 29 | 30 | return sortedPositions; 31 | } 32 | 33 | export function getUserPositionTokens( 34 | positionData: PositionRequest, 35 | user: PublicKey 36 | ): Record { 37 | let positionTokens: Record = {}; 38 | 39 | if ( 40 | positionData.status === "success" && 41 | Object.values(positionData.data).length > 0 && 42 | user 43 | ) { 44 | Object.values(positionData.data).forEach((position: PositionAccount) => { 45 | if (position.owner.toBase58() !== user.toBase58()) { 46 | return; 47 | } 48 | 49 | let tok = position.token; 50 | 51 | if (!positionTokens[tok]) { 52 | positionTokens[tok] = 1; 53 | } 54 | }); 55 | } 56 | 57 | return positionTokens; 58 | } 59 | 60 | export function countDictList(dict: Record) { 61 | return Object.values(dict).reduce((acc, val) => acc + val.length, 0); 62 | } 63 | -------------------------------------------------------------------------------- /ui/src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import ChartCandlestickIcon from "@carbon/icons-react/lib/ChartCandlestick"; 2 | import CircleDash from "@carbon/icons-react/lib/CircleDash"; 3 | import StoragePoolIcon from "@carbon/icons-react/lib/StoragePool"; 4 | import dynamic from "next/dynamic"; 5 | import Link from "next/link"; 6 | import { twMerge } from "tailwind-merge"; 7 | 8 | import UserAdmin from "@carbon/icons-react/lib/UserAdmin"; 9 | import { NavbarLink } from "./NavbarLink"; 10 | 11 | const WalletMultiButtonDynamic = dynamic( 12 | async () => 13 | (await import("@solana/wallet-adapter-react-ui")).WalletMultiButton, 14 | { ssr: false } 15 | ); 16 | 17 | export const Navbar = () => { 18 | return ( 19 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions.rs: -------------------------------------------------------------------------------- 1 | // admin instructions 2 | pub mod add_custody; 3 | pub mod add_pool; 4 | pub mod init; 5 | pub mod remove_custody; 6 | pub mod remove_pool; 7 | pub mod set_admin_signers; 8 | pub mod set_custody_config; 9 | pub mod set_permissions; 10 | pub mod upgrade_custody; 11 | pub mod withdraw_fees; 12 | pub mod withdraw_sol_fees; 13 | 14 | // test instructions 15 | pub mod set_test_oracle_price; 16 | pub mod set_test_time; 17 | pub mod test_init; 18 | 19 | // public instructions 20 | pub mod add_collateral; 21 | pub mod add_liquidity; 22 | pub mod close_position; 23 | pub mod get_add_liquidity_amount_and_fee; 24 | pub mod get_assets_under_management; 25 | pub mod get_entry_price_and_fee; 26 | pub mod get_exit_price_and_fee; 27 | pub mod get_liquidation_price; 28 | pub mod get_liquidation_state; 29 | pub mod get_oracle_price; 30 | pub mod get_pnl; 31 | pub mod get_remove_liquidity_amount_and_fee; 32 | pub mod get_swap_amount_and_fees; 33 | pub mod liquidate; 34 | pub mod open_position; 35 | pub mod remove_collateral; 36 | pub mod remove_liquidity; 37 | pub mod swap; 38 | 39 | // bring everything in scope 40 | pub use { 41 | add_collateral::*, add_custody::*, add_liquidity::*, add_pool::*, close_position::*, 42 | get_add_liquidity_amount_and_fee::*, get_assets_under_management::*, 43 | get_entry_price_and_fee::*, get_exit_price_and_fee::*, get_liquidation_price::*, 44 | get_liquidation_state::*, get_oracle_price::*, get_pnl::*, 45 | get_remove_liquidity_amount_and_fee::*, get_swap_amount_and_fees::*, init::*, liquidate::*, 46 | open_position::*, remove_collateral::*, remove_custody::*, remove_liquidity::*, remove_pool::*, 47 | set_admin_signers::*, set_custody_config::*, set_permissions::*, set_test_oracle_price::*, 48 | set_test_time::*, swap::*, test_init::*, upgrade_custody::*, withdraw_fees::*, 49 | withdraw_sol_fees::*, 50 | }; 51 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_oracle_price.rs: -------------------------------------------------------------------------------- 1 | //! GetOraclePrice instruction handler 2 | 3 | use { 4 | crate::state::{custody::Custody, oracle::OraclePrice, perpetuals::Perpetuals, pool::Pool}, 5 | anchor_lang::prelude::*, 6 | }; 7 | 8 | #[derive(Accounts)] 9 | pub struct GetOraclePrice<'info> { 10 | #[account( 11 | seeds = [b"perpetuals"], 12 | bump = perpetuals.perpetuals_bump 13 | )] 14 | pub perpetuals: Box>, 15 | 16 | #[account( 17 | seeds = [b"pool", 18 | pool.name.as_bytes()], 19 | bump = pool.bump 20 | )] 21 | pub pool: Box>, 22 | 23 | #[account( 24 | seeds = [b"custody", 25 | pool.key().as_ref(), 26 | custody.mint.as_ref()], 27 | bump = custody.bump 28 | )] 29 | pub custody: Box>, 30 | 31 | /// CHECK: oracle account for the collateral token 32 | #[account( 33 | constraint = custody_oracle_account.key() == custody.oracle.oracle_account 34 | )] 35 | pub custody_oracle_account: AccountInfo<'info>, 36 | } 37 | 38 | #[derive(AnchorSerialize, AnchorDeserialize)] 39 | pub struct GetOraclePriceParams { 40 | ema: bool, 41 | } 42 | 43 | pub fn get_oracle_price( 44 | ctx: Context, 45 | params: &GetOraclePriceParams, 46 | ) -> Result { 47 | let custody = ctx.accounts.custody.as_mut(); 48 | let curtime = ctx.accounts.perpetuals.get_time()?; 49 | 50 | let price = OraclePrice::new_from_oracle( 51 | custody.oracle.oracle_type, 52 | &ctx.accounts.custody_oracle_account.to_account_info(), 53 | custody.oracle.max_price_error, 54 | custody.oracle.max_price_age_sec, 55 | curtime, 56 | params.ema, 57 | )?; 58 | 59 | Ok(price 60 | .scale_to_exponent(-(Perpetuals::PRICE_DECIMALS as i32))? 61 | .price) 62 | } 63 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/set_test_time.rs: -------------------------------------------------------------------------------- 1 | //! SetTestTime instruction handler 2 | 3 | use { 4 | crate::{ 5 | error::PerpetualsError, 6 | state::{ 7 | multisig::{AdminInstruction, Multisig}, 8 | perpetuals::Perpetuals, 9 | }, 10 | }, 11 | anchor_lang::prelude::*, 12 | }; 13 | 14 | #[derive(Accounts)] 15 | pub struct SetTestTime<'info> { 16 | #[account()] 17 | pub admin: Signer<'info>, 18 | 19 | #[account( 20 | mut, 21 | seeds = [b"multisig"], 22 | bump = multisig.load()?.bump 23 | )] 24 | pub multisig: AccountLoader<'info, Multisig>, 25 | 26 | #[account( 27 | mut, 28 | seeds = [b"perpetuals"], 29 | bump = perpetuals.perpetuals_bump 30 | )] 31 | pub perpetuals: Box>, 32 | } 33 | 34 | #[derive(AnchorSerialize, AnchorDeserialize)] 35 | pub struct SetTestTimeParams { 36 | pub time: i64, 37 | } 38 | 39 | pub fn set_test_time<'info>( 40 | ctx: Context<'_, '_, '_, 'info, SetTestTime<'info>>, 41 | params: &SetTestTimeParams, 42 | ) -> Result { 43 | if !cfg!(feature = "test") { 44 | return err!(PerpetualsError::InvalidEnvironment); 45 | } 46 | 47 | // validate signatures 48 | let mut multisig = ctx.accounts.multisig.load_mut()?; 49 | 50 | let signatures_left = multisig.sign_multisig( 51 | &ctx.accounts.admin, 52 | &Multisig::get_account_infos(&ctx)[1..], 53 | &Multisig::get_instruction_data(AdminInstruction::SetTestTime, params)?, 54 | )?; 55 | if signatures_left > 0 { 56 | msg!( 57 | "Instruction has been signed but more signatures are required: {}", 58 | signatures_left 59 | ); 60 | return Ok(signatures_left); 61 | } 62 | 63 | // update time data 64 | if cfg!(feature = "test") { 65 | ctx.accounts.perpetuals.inception_time = params.time; 66 | } 67 | 68 | Ok(0) 69 | } 70 | -------------------------------------------------------------------------------- /ui/src/utils/formatters.ts: -------------------------------------------------------------------------------- 1 | import { BN } from "@project-serum/anchor"; 2 | 3 | export function formatNumberCommas(num: number | BN | null) { 4 | if (typeof num === "bigint") { 5 | return Number(num).toLocaleString(undefined, { 6 | minimumFractionDigits: 2, 7 | maximumFractionDigits: 2, 8 | }); 9 | } else if (typeof num === "number") { 10 | return num.toLocaleString(undefined, { 11 | minimumFractionDigits: 2, 12 | maximumFractionDigits: 2, 13 | }); 14 | } else { 15 | return null; 16 | } 17 | } 18 | 19 | export function formatNumber(num: number) { 20 | const formatter = Intl.NumberFormat("en", { 21 | maximumFractionDigits: 2, 22 | minimumFractionDigits: 2, 23 | }); 24 | return formatter.format(num); 25 | } 26 | 27 | export function formatNumberLessThan(num: number) { 28 | const formatter = Intl.NumberFormat("en", { 29 | maximumFractionDigits: 2, 30 | minimumFractionDigits: 2, 31 | }); 32 | 33 | if (num < 0.01) { 34 | return "<$0.01"; 35 | } else { 36 | return "$" + formatter.format(num); 37 | } 38 | } 39 | 40 | export function formatPrice(num: number) { 41 | const formatter = Intl.NumberFormat("en", { 42 | maximumFractionDigits: 2, 43 | minimumFractionDigits: 2, 44 | }); 45 | return formatter.format(num); 46 | } 47 | 48 | export function formatFees(num: number) { 49 | const formatter = Intl.NumberFormat("en", { 50 | maximumFractionDigits: 5, 51 | minimumFractionDigits: 3, 52 | }); 53 | return formatter.format(num); 54 | } 55 | 56 | export function formatValueDelta(num: number) { 57 | const formatter = new Intl.NumberFormat("en", { 58 | maximumFractionDigits: 4, 59 | minimumFractionDigits: 4, 60 | }); 61 | return formatter.format(num); 62 | } 63 | 64 | export function formatValueDeltaPercentage(num: number) { 65 | const formatter = new Intl.NumberFormat("en", { 66 | maximumFractionDigits: 2, 67 | minimumFractionDigits: 2, 68 | }); 69 | return formatter.format(num); 70 | } 71 | -------------------------------------------------------------------------------- /programs/perpetuals/src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error types 2 | 3 | use anchor_lang::prelude::*; 4 | 5 | #[error_code] 6 | pub enum PerpetualsError { 7 | #[msg("Account is not authorized to sign this instruction")] 8 | MultisigAccountNotAuthorized, 9 | #[msg("Account has already signed this instruction")] 10 | MultisigAlreadySigned, 11 | #[msg("This instruction has already been executed")] 12 | MultisigAlreadyExecuted, 13 | #[msg("Overflow in arithmetic operation")] 14 | MathOverflow, 15 | #[msg("Unsupported price oracle")] 16 | UnsupportedOracle, 17 | #[msg("Invalid oracle account")] 18 | InvalidOracleAccount, 19 | #[msg("Invalid oracle state")] 20 | InvalidOracleState, 21 | #[msg("Stale oracle price")] 22 | StaleOraclePrice, 23 | #[msg("Invalid oracle price")] 24 | InvalidOraclePrice, 25 | #[msg("Instruction is not allowed in production")] 26 | InvalidEnvironment, 27 | #[msg("Invalid pool state")] 28 | InvalidPoolState, 29 | #[msg("Invalid custody state")] 30 | InvalidCustodyState, 31 | #[msg("Invalid position state")] 32 | InvalidPositionState, 33 | #[msg("Invalid perpetuals config")] 34 | InvalidPerpetualsConfig, 35 | #[msg("Invalid pool config")] 36 | InvalidPoolConfig, 37 | #[msg("Invalid custody config")] 38 | InvalidCustodyConfig, 39 | #[msg("Insufficient token amount returned")] 40 | InsufficientAmountReturned, 41 | #[msg("Price slippage limit exceeded")] 42 | MaxPriceSlippage, 43 | #[msg("Position leverage limit exceeded")] 44 | MaxLeverage, 45 | #[msg("Custody amount limit exceeded")] 46 | CustodyAmountLimit, 47 | #[msg("Position amount limit exceeded")] 48 | PositionAmountLimit, 49 | #[msg("Token ratio out of range")] 50 | TokenRatioOutOfRange, 51 | #[msg("Token is not supported")] 52 | UnsupportedToken, 53 | #[msg("Instruction is not allowed at this time")] 54 | InstructionNotAllowed, 55 | #[msg("Token utilization limit exceeded")] 56 | MaxUtilization, 57 | } 58 | -------------------------------------------------------------------------------- /ui/src/components/TradeSidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarTab } from "@/components/SidebarTab"; 2 | import { TradePosition } from "@/components/TradeSidebar/TradePosition"; 3 | import { TradeSwap } from "@/components/TradeSidebar/TradeSwap"; 4 | import { Side } from "@/lib/types"; 5 | import ArrowsHorizontalIcon from "@carbon/icons-react/lib/ArrowsHorizontal"; 6 | import GrowthIcon from "@carbon/icons-react/lib/Growth"; 7 | import { useState } from "react"; 8 | import { twMerge } from "tailwind-merge"; 9 | 10 | interface Props { 11 | className?: string; 12 | } 13 | 14 | export function TradeSidebar(props: Props) { 15 | const [side, setSide] = useState(Side.Long); 16 | 17 | return ( 18 |
19 |
Place a Market Order
20 |
23 |
24 | setSide(Side.Long)} 27 | > 28 | 29 |
Long
30 |
31 | setSide(Side.Short)} 34 | > 35 | 36 |
Short
37 |
38 | setSide(Side.Swap)} 41 | > 42 | 43 |
Swap
44 |
45 |
46 | {side === Side.Long && ( 47 | 48 | )} 49 | {side === Side.Short && ( 50 | 51 | )} 52 | {side === Side.Swap && } 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /ui/src/components/SolidButton.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/Icons/LoadingSpinner"; 2 | import { forwardRef, useState } from "react"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | interface Props extends React.ButtonHTMLAttributes { 6 | pending?: boolean; 7 | } 8 | 9 | export const SolidButton = forwardRef( 10 | function SolidButton(props, ref) { 11 | const { ...rest } = props; 12 | const [loading, setLoading] = useState(false); 13 | 14 | const handleClick = async (e: any) => { 15 | setLoading(true); 16 | try { 17 | await rest.onClick?.(e); 18 | } catch (error) { 19 | console.error("ButtonWithLoading onClick error:", error); 20 | } 21 | setLoading(false); 22 | }; 23 | 24 | return ( 25 | 68 | ); 69 | } 70 | ); 71 | -------------------------------------------------------------------------------- /ui/src/hooks/storeHelpers/fetchUserData.ts: -------------------------------------------------------------------------------- 1 | import { PoolAccount } from "@/lib/PoolAccount"; 2 | import { TokenE } from "@/lib/Token"; 3 | import { UserAccount } from "@/lib/UserAccount"; 4 | import { fetchLPBalance, fetchTokenBalance } from "@/utils/retrieveData"; 5 | import { Connection, PublicKey } from "@solana/web3.js"; 6 | 7 | export default async function getUserLpAll( 8 | connection: Connection, 9 | publicKey: PublicKey, 10 | poolData: Record 11 | ): Promise> { 12 | let lpTokenAccounts: Record = {}; 13 | let promises = Object.values(poolData).map(async (pool) => { 14 | lpTokenAccounts[pool.address.toString()] = await fetchLPBalance( 15 | pool.getLpTokenMint(), 16 | publicKey, 17 | connection 18 | ); 19 | }); 20 | 21 | await Promise.all(promises); 22 | 23 | return lpTokenAccounts; 24 | } 25 | 26 | export async function getUserLpSingle() {} 27 | 28 | export async function getUserTokenAll( 29 | connection: Connection, 30 | publicKey: PublicKey, 31 | poolData: Record 32 | ): Promise> { 33 | let tokens: TokenE[] = []; 34 | 35 | Object.values(poolData).map(async (pool) => { 36 | tokens.push(...pool.getTokenList()); 37 | }); 38 | 39 | tokens = Array.from(new Set(tokens)); 40 | 41 | let tokenBalances: Record = {}; 42 | 43 | let promises = tokens.map(async (token) => { 44 | tokenBalances[token] = await fetchTokenBalance( 45 | token, 46 | publicKey, 47 | connection 48 | ); 49 | }); 50 | await Promise.all(promises); 51 | 52 | return tokenBalances; 53 | } 54 | 55 | export async function getUserTokenSingle() {} 56 | 57 | export async function getAllUserData( 58 | connection: Connection, 59 | publicKey: PublicKey, 60 | poolData: Record 61 | ): Promise { 62 | let lpBalances = await getUserLpAll(connection, publicKey, poolData); 63 | 64 | let tokenBalances = await getUserTokenAll(connection, publicKey, poolData); 65 | 66 | let userData = new UserAccount(lpBalances, tokenBalances); 67 | 68 | return userData; 69 | } 70 | -------------------------------------------------------------------------------- /ui/src/hooks/useHydrateStore.ts: -------------------------------------------------------------------------------- 1 | import { fetchAllStats } from "@/hooks/storeHelpers/fetchPrices"; 2 | import { getAllUserData } from "@/hooks/storeHelpers/fetchUserData"; 3 | import { useGlobalStore } from "@/stores/store"; 4 | import { useConnection, useWallet } from "@solana/wallet-adapter-react"; 5 | import { useEffect } from "react"; 6 | import { getCustodyData } from "./storeHelpers/fetchCustodies"; 7 | import { getPoolData } from "./storeHelpers/fetchPools"; 8 | import { getPositionData } from "./storeHelpers/fetchPositions"; 9 | 10 | export const useHydrateStore = () => { 11 | const setCustodyData = useGlobalStore((state) => state.setCustodyData); 12 | const setPoolData = useGlobalStore((state) => state.setPoolData); 13 | const setPositionData = useGlobalStore((state) => state.setPositionData); 14 | 15 | const poolData = useGlobalStore((state) => state.poolData); 16 | 17 | const setUserData = useGlobalStore((state) => state.setUserData); 18 | const setPriceStats = useGlobalStore((state) => state.setPriceStats); 19 | 20 | const { connection } = useConnection(); 21 | const { publicKey } = useWallet(); 22 | 23 | useEffect(() => { 24 | (async () => { 25 | const custodyData = await getCustodyData(); 26 | const poolData = await getPoolData(custodyData); 27 | const positionInfos = await getPositionData(custodyData); 28 | 29 | setCustodyData(custodyData); 30 | setPoolData(poolData); 31 | setPositionData(positionInfos); 32 | })(); 33 | }, []); 34 | 35 | useEffect(() => { 36 | if ( 37 | publicKey && 38 | Object.values(poolData).length > 0 39 | // && Object.values(userData.lpBalances).length == 0 40 | ) { 41 | (async () => { 42 | const userData = await getAllUserData(connection, publicKey, poolData); 43 | setUserData(userData); 44 | })(); 45 | } 46 | }, [publicKey, poolData]); 47 | 48 | useEffect(() => { 49 | const fetchAndSetStats = async () => { 50 | const priceStats = await fetchAllStats(); 51 | setPriceStats(priceStats); 52 | }; 53 | 54 | fetchAndSetStats(); 55 | 56 | const interval = setInterval(fetchAndSetStats, 30000); 57 | 58 | return () => clearInterval(interval); 59 | }, []); 60 | }; 61 | -------------------------------------------------------------------------------- /ui/src/components/PoolModal/LpSelector.tsx: -------------------------------------------------------------------------------- 1 | import { MaxButton } from "@/components/Atoms/MaxButton"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | interface Props { 5 | className?: string; 6 | label?: string; 7 | amount: number; 8 | onChangeAmount?(amount: number): void; 9 | maxBalance?: number; 10 | pendingRateConversion?: boolean; 11 | } 12 | 13 | export const LpSelector = (props: Props) => { 14 | return ( 15 |
16 |
29 |
30 |

{props.label ? props.label : "LP Tokens"}

31 | 32 | 36 |
37 |
38 | {props.pendingRateConversion ? ( 39 |
Loading...
40 | ) : ( 41 | { 62 | const value = e.currentTarget.valueAsNumber; 63 | props.onChangeAmount?.(isNaN(value) ? 0 : value); 64 | }} 65 | /> 66 | )} 67 |
68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /ui/src/components/Positions/ExistingPositions.tsx: -------------------------------------------------------------------------------- 1 | import { NoPositions } from "@/components/Positions/NoPositions"; 2 | import PoolPositionHeader from "@/components/Positions/PoolPositionHeader"; 3 | import PoolPositionRow from "@/components/Positions/PoolPositionRow"; 4 | import { useGlobalStore } from "@/stores/store"; 5 | import { countDictList, getPoolSortedPositions } from "@/utils/organizers"; 6 | import { useWallet } from "@solana/wallet-adapter-react"; 7 | import { twMerge } from "tailwind-merge"; 8 | 9 | interface Props { 10 | className?: string; 11 | } 12 | 13 | export function ExistingPositions(props: Props) { 14 | const { publicKey } = useWallet(); 15 | 16 | const positionData = useGlobalStore((state) => state.positionData); 17 | 18 | let positions; 19 | 20 | if (publicKey) { 21 | positions = getPoolSortedPositions(positionData, publicKey); 22 | } else { 23 | positions = getPoolSortedPositions(positionData); 24 | } 25 | 26 | if (countDictList(positions) === 0) { 27 | return ; 28 | } 29 | 30 | return ( 31 | <> 32 | {Object.entries(positions).map(([pool, positions]) => ( 33 |
34 |

test

35 |
45 | {/* We cannot use a real grid layout here since we have nested grids. 46 | Instead, we're going to fake a grid by assinging column widths to 47 | percentages. */} 48 | 49 |
50 | {positions.map((position, index) => ( 51 | // eslint-disable-next-line react/jsx-no-undef 52 | 60 | ))} 61 |
62 | ))} 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "perps-ui", 3 | "version": "1.0", 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start" 8 | }, 9 | "dependencies": { 10 | "@carbon/icons-react": "^11.14.0", 11 | "@coral-xyz/borsh": "^0.26.0", 12 | "@metaplex-foundation/js": "^0.17.12", 13 | "@project-serum/anchor": "^0.25.0-beta.1", 14 | "@pythnetwork/client": "^2.15.0", 15 | "@radix-ui/react-dialog": "^1.0.3", 16 | "@radix-ui/react-dropdown-menu": "^2.0.2", 17 | "@radix-ui/react-slider": "^1.1.0", 18 | "@solana/spl-token": "^0.2.0", 19 | "@solana/wallet-adapter-base": "^0.9.20", 20 | "@solana/wallet-adapter-react": "^0.15.26", 21 | "@solana/wallet-adapter-react-ui": "^0.9.24", 22 | "@solana/wallet-adapter-wallets": "^0.19.9", 23 | "@solana/web3.js": "^1.73.0", 24 | "@tailwindcss/nesting": "^0.0.0-insiders.565cd3e", 25 | "axios": "^1.2.2", 26 | "bs58": "^5.0.0", 27 | "date-fns": "^2.29.3", 28 | "next": "^13.1.2", 29 | "postcss-import": "^15.1.0", 30 | "prettier-plugin-tailwindcss": "^0.2.1", 31 | "react": "^18.1.0", 32 | "react-dom": "^18.1.0", 33 | "react-icons": "^4.4.0", 34 | "react-toastify": "^9.1.1", 35 | "react-tradingview-widget": "^1.3.2", 36 | "tailwind-merge": "^1.8.1", 37 | "zustand": "^4.3.3" 38 | }, 39 | "devDependencies": { 40 | "@types/carbon__icons-react": "^11.12.0", 41 | "@types/node": "^17.0.38", 42 | "@types/react": "^18.0.10", 43 | "@typescript-eslint/eslint-plugin": "^5.27.0", 44 | "@typescript-eslint/parser": "^5.27.0", 45 | "autoprefixer": "^10.4.7", 46 | "cssnano": "^5.1.10", 47 | "eslint": "^8.16.0", 48 | "eslint-config-next": "^12.1.6", 49 | "eslint-config-prettier": "^8.5.0", 50 | "eslint-plugin-import": "^2.26.0", 51 | "eslint-plugin-prettier": "^4.0.0", 52 | "eslint-plugin-react": "^7.30.0", 53 | "eslint-plugin-react-hooks": "^4.5.0", 54 | "eslint-plugin-simple-import-sort": "^7.0.0", 55 | "eslint-plugin-tailwindcss": "^3.5.0", 56 | "eslint-plugin-testing-library": "^5.5.1", 57 | "eslint-plugin-unused-imports": "^2.0.0", 58 | "postcss": "^8.4.14", 59 | "prettier": "^2.6.2", 60 | "tailwindcss": "^3.0.24", 61 | "typescript": "^4.7.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ui/src/components/Chart/ChartCurrency.tsx: -------------------------------------------------------------------------------- 1 | import ChevronDownIcon from "@carbon/icons-react/lib/ChevronDown"; 2 | import { useRouter } from "next/router"; 3 | import { cloneElement, useState } from "react"; 4 | import { twMerge } from "tailwind-merge"; 5 | 6 | import { TokenE, getTokenIcon, getTokenLabel } from "@/lib/Token"; 7 | 8 | import { TokenSelectorList } from "../TokenSelectorList"; 9 | 10 | interface Props { 11 | className?: string; 12 | comparisonCurrency: "usd" | "eur" | TokenE.USDC | TokenE.USDT; 13 | token: TokenE; 14 | } 15 | 16 | export function ChartCurrency(props: Props) { 17 | const tokenIcon = getTokenIcon(props.token); 18 | const [selectorOpen, setSelectorOpen] = useState(false); 19 | const router = useRouter(); 20 | 21 | return ( 22 | <> 23 | 60 | {selectorOpen && ( 61 | setSelectorOpen(false)} 63 | onSelectToken={(token) => { 64 | router.push(`/trade/${token}-usd`); 65 | }} 66 | /> 67 | )} 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /DISCLAIMER.md: -------------------------------------------------------------------------------- 1 | # Disclaimer 2 | 3 | All claims, content, designs, algorithms, estimates, roadmaps, specifications, and performance measurements described in this project are done with the good faith efforts Solana Labs, Inc. and its affiliates ("SL"). It is up to the reader to check and validate their accuracy and truthfulness. Furthermore nothing in this project constitutes a solicitation for investment. 4 | Any content produced by SL or developer resources that SL provides have not been subject to audit and are for educational and inspiration purposes only. SL does not encourage, induce or sanction the deployment, integration or use of any such applications (including the code comprising the Solana blockchain protocol) in violation of applicable laws or regulations and hereby prohibits any such deployment, integration or use. This includes use of any such applications by the reader (a) in violation of export control or sanctions laws of the United States or any other applicable jurisdiction, (b) if the reader is located in or ordinarily resident in a country or territory subject to comprehensive sanctions administered by the U.S. Office of Foreign Assets Control (OFAC), or (c) if the reader is or is working on behalf of a Specially Designated National (SDN) or a person subject to similar blocking or denied party prohibitions. 5 | The reader should be aware that U.S. export control and sanctions laws prohibit U.S. persons (and other persons that are subject to such laws) from transacting with persons in certain countries and territories or that are on the SDN list. As a project based primarily on open-source software, it is possible that such sanctioned persons may nevertheless bypass prohibitions, obtain the code comprising the Solana blockchain protocol (or other project code or applications) and deploy, integrate, or otherwise use it. Accordingly, there is a risk to individuals that other persons using the Solana blockchain protocol may be sanctioned persons and that transactions with such persons would be a violation of U.S. export controls and sanctions law. This risk applies to individuals, organizations, and other ecosystem participants that deploy, integrate, or use the Solana blockchain protocol code directly (e.g., as a node operator), and individuals that transact on the Solana blockchain through light clients, third party interfaces, and/or wallet software. 6 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_liquidation_state.rs: -------------------------------------------------------------------------------- 1 | //! GetLiquidationState instruction handler 2 | 3 | use { 4 | crate::state::{ 5 | custody::Custody, oracle::OraclePrice, perpetuals::Perpetuals, pool::Pool, 6 | position::Position, 7 | }, 8 | anchor_lang::prelude::*, 9 | }; 10 | 11 | #[derive(Accounts)] 12 | pub struct GetLiquidationState<'info> { 13 | #[account( 14 | seeds = [b"perpetuals"], 15 | bump = perpetuals.perpetuals_bump 16 | )] 17 | pub perpetuals: Box>, 18 | 19 | #[account( 20 | seeds = [b"pool", 21 | pool.name.as_bytes()], 22 | bump = pool.bump 23 | )] 24 | pub pool: Box>, 25 | 26 | #[account( 27 | seeds = [b"position", 28 | position.owner.as_ref(), 29 | pool.key().as_ref(), 30 | custody.key().as_ref(), 31 | &[position.side as u8]], 32 | bump = position.bump 33 | )] 34 | pub position: Box>, 35 | 36 | #[account( 37 | seeds = [b"custody", 38 | pool.key().as_ref(), 39 | custody.mint.as_ref()], 40 | bump = custody.bump 41 | )] 42 | pub custody: Box>, 43 | 44 | /// CHECK: oracle account for the collateral token 45 | #[account( 46 | constraint = custody_oracle_account.key() == custody.oracle.oracle_account 47 | )] 48 | pub custody_oracle_account: AccountInfo<'info>, 49 | } 50 | 51 | #[derive(AnchorSerialize, AnchorDeserialize)] 52 | pub struct GetLiquidationStateParams {} 53 | 54 | pub fn get_liquidation_state( 55 | ctx: Context, 56 | _params: &GetLiquidationStateParams, 57 | ) -> Result { 58 | let custody = ctx.accounts.custody.as_mut(); 59 | let curtime = ctx.accounts.perpetuals.get_time()?; 60 | 61 | let token_ema_price = OraclePrice::new_from_oracle( 62 | custody.oracle.oracle_type, 63 | &ctx.accounts.custody_oracle_account.to_account_info(), 64 | custody.oracle.max_price_error, 65 | custody.oracle.max_price_age_sec, 66 | curtime, 67 | custody.pricing.use_ema, 68 | )?; 69 | 70 | if ctx.accounts.pool.check_leverage( 71 | &ctx.accounts.position, 72 | &token_ema_price, 73 | custody, 74 | curtime, 75 | false, 76 | )? { 77 | Ok(0) 78 | } else { 79 | Ok(1) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /ui/src/hooks/storeHelpers/fetchPools.ts: -------------------------------------------------------------------------------- 1 | import { CustodyAccount } from "@/lib/CustodyAccount"; 2 | import { PoolAccount } from "@/lib/PoolAccount"; 3 | import { Pool } from "@/lib/types"; 4 | import { getPerpetualProgramAndProvider } from "@/utils/constants"; 5 | import { ViewHelper } from "@/utils/viewHelpers"; 6 | import { findProgramAddressSync } from "@project-serum/anchor/dist/cjs/utils/pubkey"; 7 | import { getMint } from "@solana/spl-token"; 8 | import { PublicKey } from "@solana/web3.js"; 9 | 10 | interface FetchPool { 11 | account: Pool; 12 | publicKey: PublicKey; 13 | } 14 | 15 | export async function getPoolData( 16 | custodyInfos: Record 17 | ): Promise> { 18 | let { perpetual_program, provider } = await getPerpetualProgramAndProvider(); 19 | 20 | // @ts-ignore 21 | let fetchedPools: FetchPool[] = await perpetual_program.account.pool.all(); 22 | let poolObjs: Record = {}; 23 | 24 | await Promise.all( 25 | Object.values(fetchedPools) 26 | .sort((a, b) => a.account.name.localeCompare(b.account.name)) 27 | .map(async (pool: FetchPool) => { 28 | let lpTokenMint = findProgramAddressSync( 29 | [Buffer.from("lp_token_mint"), pool.publicKey.toBuffer()], 30 | perpetual_program.programId 31 | )[0]; 32 | 33 | const lpData = await getMint(provider.connection, lpTokenMint); 34 | 35 | const View = new ViewHelper(provider.connection, provider); 36 | 37 | console.log("fetching pools actual data", pool); 38 | 39 | let poolData: Pool = { 40 | name: pool.account.name, 41 | custodies: pool.account.custodies, 42 | ratios: pool.account.ratios, 43 | aumUsd: pool.account.aumUsd, 44 | bump: pool.account.bump, 45 | lpTokenBump: pool.account.lpTokenBump, 46 | inceptionTime: pool.account.inceptionTime, 47 | }; 48 | 49 | poolObjs[pool.publicKey.toString()] = new PoolAccount( 50 | poolData, 51 | custodyInfos, 52 | pool.publicKey, 53 | lpData 54 | ); 55 | let fetchedAum; 56 | 57 | let loopStatus = true; 58 | 59 | while (loopStatus) { 60 | try { 61 | fetchedAum = await View.getAssetsUnderManagement( 62 | poolObjs[pool.publicKey.toString()] 63 | ); 64 | loopStatus = false; 65 | } catch (error) {} 66 | } 67 | 68 | poolObjs[pool.publicKey.toString()].setAum(fetchedAum); 69 | }) 70 | ); 71 | 72 | return poolObjs; 73 | } 74 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/utils/pda.rs: -------------------------------------------------------------------------------- 1 | use {perpetuals::state::position::Side, solana_sdk::pubkey::Pubkey}; 2 | 3 | pub fn get_multisig_pda() -> (Pubkey, u8) { 4 | Pubkey::find_program_address(&["multisig".as_ref()], &perpetuals::id()) 5 | } 6 | 7 | pub fn get_transfer_authority_pda() -> (Pubkey, u8) { 8 | Pubkey::find_program_address(&["transfer_authority".as_ref()], &perpetuals::id()) 9 | } 10 | 11 | pub fn get_perpetuals_pda() -> (Pubkey, u8) { 12 | Pubkey::find_program_address(&["perpetuals".as_ref()], &perpetuals::id()) 13 | } 14 | 15 | pub fn get_program_data_pda() -> (Pubkey, u8) { 16 | Pubkey::find_program_address( 17 | &[perpetuals::id().as_ref()], 18 | &solana_program::bpf_loader_upgradeable::id(), 19 | ) 20 | } 21 | 22 | pub fn get_pool_pda(name: String) -> (Pubkey, u8) { 23 | Pubkey::find_program_address(&["pool".as_ref(), name.as_bytes()], &perpetuals::id()) 24 | } 25 | 26 | pub fn get_lp_token_mint_pda(pool_pda: &Pubkey) -> (Pubkey, u8) { 27 | Pubkey::find_program_address( 28 | &["lp_token_mint".as_ref(), pool_pda.as_ref()], 29 | &perpetuals::id(), 30 | ) 31 | } 32 | 33 | pub fn get_custody_pda(pool_pda: &Pubkey, custody_token_mint: &Pubkey) -> (Pubkey, u8) { 34 | Pubkey::find_program_address( 35 | &[ 36 | "custody".as_ref(), 37 | pool_pda.as_ref(), 38 | custody_token_mint.as_ref(), 39 | ], 40 | &perpetuals::id(), 41 | ) 42 | } 43 | 44 | pub fn get_position_pda( 45 | owner: &Pubkey, 46 | pool_pda: &Pubkey, 47 | custody_pda: &Pubkey, 48 | side: Side, 49 | ) -> (Pubkey, u8) { 50 | Pubkey::find_program_address( 51 | &[ 52 | "position".as_ref(), 53 | owner.as_ref(), 54 | pool_pda.as_ref(), 55 | custody_pda.as_ref(), 56 | &[side as u8], 57 | ], 58 | &perpetuals::id(), 59 | ) 60 | } 61 | 62 | pub fn get_custody_token_account_pda( 63 | pool_pda: &Pubkey, 64 | custody_token_mint: &Pubkey, 65 | ) -> (Pubkey, u8) { 66 | Pubkey::find_program_address( 67 | &[ 68 | "custody_token_account".as_ref(), 69 | pool_pda.as_ref(), 70 | custody_token_mint.as_ref(), 71 | ], 72 | &perpetuals::id(), 73 | ) 74 | } 75 | 76 | pub fn get_test_oracle_account(pool_pda: &Pubkey, custody_mint: &Pubkey) -> (Pubkey, u8) { 77 | Pubkey::find_program_address( 78 | &[ 79 | "oracle_account".as_ref(), 80 | pool_pda.as_ref(), 81 | custody_mint.as_ref(), 82 | ], 83 | &perpetuals::id(), 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /ui/src/utils/retrieveData.ts: -------------------------------------------------------------------------------- 1 | import { PoolAccount } from "@/lib/PoolAccount"; 2 | import { getTokenAddress, TokenE } from "@/lib/Token"; 3 | import { getAssociatedTokenAddress, Mint } from "@solana/spl-token"; 4 | import { Connection, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; 5 | 6 | export async function checkIfAccountExists( 7 | account: PublicKey, 8 | connection: Connection 9 | ): Promise { 10 | let bal = await connection.getBalance(account); 11 | if (bal > 0) { 12 | return true; 13 | } else { 14 | return false; 15 | } 16 | } 17 | 18 | export async function fetchTokenBalance( 19 | payToken: TokenE, 20 | publicKey: PublicKey, 21 | connection: Connection 22 | ): Promise { 23 | console.log("fetching user token", payToken); 24 | let tokenATA = await getAssociatedTokenAddress( 25 | new PublicKey(getTokenAddress(payToken)), 26 | publicKey 27 | ); 28 | let balance = 0; 29 | 30 | if (await checkIfAccountExists(tokenATA, connection)) { 31 | balance = (await connection.getTokenAccountBalance(tokenATA)).value 32 | .uiAmount!; 33 | } 34 | 35 | if (payToken === TokenE.SOL) { 36 | let solBalance = 37 | (await connection.getBalance(publicKey)) / LAMPORTS_PER_SOL; 38 | console.log("sol balance", solBalance, "token balance", balance); 39 | return balance + solBalance; 40 | } 41 | return balance; 42 | } 43 | 44 | export async function fetchLPBalance( 45 | address: PublicKey, 46 | publicKey: PublicKey, 47 | connection: Connection 48 | ): Promise { 49 | let lpTokenAccount = await getAssociatedTokenAddress(address, publicKey); 50 | if (!(await checkIfAccountExists(lpTokenAccount, connection))) { 51 | return 0; 52 | } else { 53 | let balance = await connection.getTokenAccountBalance(lpTokenAccount); 54 | return balance.value.uiAmount!; 55 | } 56 | } 57 | 58 | export function getLiquidityBalance( 59 | pool: PoolAccount, 60 | userLpBalance: number, 61 | stats: Record 62 | ): number { 63 | let lpSupply = Number(pool.lpData.supply) / 10 ** pool.lpData.decimals; 64 | let userLiquidity = (userLpBalance! / lpSupply) * pool.getLiquidities(stats)!; 65 | 66 | if (Number.isNaN(userLiquidity)) { 67 | return 0; 68 | } 69 | 70 | return userLiquidity; 71 | } 72 | 73 | export function getLiquidityShare( 74 | pool: PoolAccount, 75 | userLpBalance: number 76 | ): number { 77 | let lpSupply = Number(pool.lpData.supply) / 10 ** pool.lpData.decimals; 78 | 79 | let userShare = (userLpBalance! / lpSupply) * 100; 80 | 81 | if (Number.isNaN(userShare)) { 82 | return 0; 83 | } 84 | return userShare; 85 | } 86 | -------------------------------------------------------------------------------- /ui/src/components/Positions/PoolPositionRow.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingDots } from "@/components/LoadingDots"; 2 | import { PositionAdditionalInfo } from "@/components/Positions/PositionAdditionalInfo"; 3 | import PositionBasicInfo from "@/components/Positions/PositionBasicInfo"; 4 | import { PositionAccount } from "@/lib/PositionAccount"; 5 | import { useGlobalStore } from "@/stores/store"; 6 | import { getPerpetualProgramAndProvider } from "@/utils/constants"; 7 | import { ViewHelper } from "@/utils/viewHelpers"; 8 | import { useConnection, useWallet } from "@solana/wallet-adapter-react"; 9 | import { useEffect, useState } from "react"; 10 | import { twMerge } from "tailwind-merge"; 11 | 12 | interface Props { 13 | className?: string; 14 | position: PositionAccount; 15 | } 16 | 17 | export default function PoolPositionRow(props: Props) { 18 | const { connection } = useConnection(); 19 | const { wallet } = useWallet(); 20 | 21 | const poolData = useGlobalStore((state) => state.poolData); 22 | 23 | const [expanded, setExpanded] = useState(false); 24 | 25 | const [pnl, setPnl] = useState(0); 26 | const [liqPrice, setLiqPrice] = useState(0); 27 | 28 | useEffect(() => { 29 | async function fetchData() { 30 | let { provider } = await getPerpetualProgramAndProvider(wallet as any); 31 | 32 | const View = new ViewHelper(connection, provider); 33 | 34 | let fetchedPnlPrice = await View.getPnl(props.position); 35 | 36 | let finalPnl = Number(fetchedPnlPrice.profit) 37 | ? Number(fetchedPnlPrice.profit) 38 | : -1 * Number(fetchedPnlPrice.loss); 39 | setPnl(finalPnl / 10 ** 6); 40 | 41 | let fetchedLiqPrice = await View.getLiquidationPrice(props.position); 42 | 43 | setLiqPrice(Number(fetchedLiqPrice) / 10 ** 6); 44 | } 45 | if (Object.keys(poolData).length > 0) { 46 | fetchData(); 47 | } 48 | }, [poolData]); 49 | 50 | if (pnl === null) { 51 | return ; 52 | } 53 | 54 | return ( 55 |
56 | setExpanded((cur) => !cur)} 63 | /> 64 | 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /ui/src/components/TradeSidebar/TradeDetails.tsx: -------------------------------------------------------------------------------- 1 | import { getTokenIcon, TokenE } from "@/lib/Token"; 2 | import { Side } from "@/lib/types"; 3 | import { 4 | formatFees, 5 | formatNumber, 6 | formatNumberLessThan, 7 | formatPrice, 8 | } from "@/utils/formatters"; 9 | import { cloneElement } from "react"; 10 | import { twMerge } from "tailwind-merge"; 11 | 12 | interface Props { 13 | className?: string; 14 | collateralToken: TokenE; 15 | positionToken: TokenE; 16 | entryPrice: number; 17 | liquidationPrice: number; 18 | fees: number; 19 | availableLiquidity: number; 20 | borrowRate: number; 21 | side: Side; 22 | onSubmit?(): void; 23 | } 24 | 25 | export function TradeDetails(props: Props) { 26 | const icon = getTokenIcon(props.positionToken); 27 | 28 | return ( 29 |
30 |
31 |
{props.side}
32 | {cloneElement(icon, { 33 | className: twMerge(icon.props.className, "h-4", "ml-1.5", "w-4"), 34 | })} 35 |
36 | {props.positionToken} 37 |
38 |
39 |
40 | {[ 41 | { 42 | label: "Collateral in", 43 | value: "USD", 44 | }, 45 | { 46 | label: "Entry Price", 47 | value: `$${formatNumber(props.entryPrice)}`, 48 | }, 49 | { 50 | label: "Liq. Price", 51 | value: `$${formatNumber(props.liquidationPrice)}`, 52 | }, 53 | { 54 | label: "Fees", 55 | value: `${formatNumberLessThan(props.fees)}`, 56 | }, 57 | { 58 | label: "Borrow Rate", 59 | value: ( 60 | <> 61 | {`${formatFees(100 * props.borrowRate)}% / hr`} 62 | 63 | 64 | ), 65 | }, 66 | { 67 | label: "Available Liquidity", 68 | value: `$${formatPrice(props.availableLiquidity)}`, 69 | }, 70 | ].map(({ label, value }, i) => ( 71 |
79 |
{label}
80 |
{value}
81 |
82 | ))} 83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/instructions/test_set_custody_config.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::utils::{self, pda}, 3 | anchor_lang::{ 4 | prelude::{AccountMeta, Pubkey}, 5 | ToAccountMetas, 6 | }, 7 | perpetuals::{ 8 | instructions::SetCustodyConfigParams, 9 | state::{custody::Custody, multisig::Multisig}, 10 | }, 11 | solana_program_test::{BanksClientError, ProgramTestContext}, 12 | solana_sdk::signer::{keypair::Keypair, Signer}, 13 | }; 14 | 15 | pub async fn test_set_custody_config( 16 | program_test_ctx: &mut ProgramTestContext, 17 | admin: &Keypair, 18 | payer: &Keypair, 19 | pool_pda: &Pubkey, 20 | custody_pda: &Pubkey, 21 | params: SetCustodyConfigParams, 22 | multisig_signers: &[&Keypair], 23 | ) -> std::result::Result<(), BanksClientError> { 24 | // ==== WHEN ============================================================== 25 | let multisig_pda = pda::get_multisig_pda().0; 26 | let multisig_account = utils::get_account::(program_test_ctx, multisig_pda).await; 27 | 28 | // One Tx per multisig signer 29 | for i in 0..multisig_account.min_signatures { 30 | let signer: &Keypair = multisig_signers[i as usize]; 31 | 32 | let accounts_meta = { 33 | let accounts = perpetuals::accounts::SetCustodyConfig { 34 | admin: admin.pubkey(), 35 | multisig: multisig_pda, 36 | pool: *pool_pda, 37 | custody: *custody_pda, 38 | }; 39 | 40 | let mut accounts_meta = accounts.to_account_metas(None); 41 | 42 | accounts_meta.push(AccountMeta { 43 | pubkey: signer.pubkey(), 44 | is_signer: true, 45 | is_writable: false, 46 | }); 47 | 48 | accounts_meta 49 | }; 50 | 51 | utils::create_and_execute_perpetuals_ix( 52 | program_test_ctx, 53 | accounts_meta, 54 | perpetuals::instruction::SetCustodyConfig { 55 | params: params.clone(), 56 | }, 57 | Some(&payer.pubkey()), 58 | &[admin, payer, signer], 59 | ) 60 | .await?; 61 | } 62 | 63 | // ==== THEN ============================================================== 64 | let custody_account = utils::get_account::(program_test_ctx, *custody_pda).await; 65 | 66 | // Check custody account 67 | { 68 | assert_eq!(custody_account.pool, *pool_pda); 69 | assert_eq!(custody_account.is_stable, params.is_stable); 70 | assert_eq!(custody_account.oracle, params.oracle); 71 | assert_eq!(custody_account.pricing, params.pricing); 72 | assert_eq!(custody_account.permissions, params.permissions); 73 | assert_eq!(custody_account.fees, params.fees); 74 | } 75 | 76 | Ok(()) 77 | } 78 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/utils/fixtures.rs: -------------------------------------------------------------------------------- 1 | // Contains fixtures values usable in tests, made to reduce boilerplate 2 | 3 | use { 4 | anchor_lang::prelude::Pubkey, 5 | perpetuals::{ 6 | instructions::InitParams, 7 | state::{ 8 | custody::{BorrowRateParams, Fees, FeesMode, OracleParams, PricingParams}, 9 | oracle::OracleType, 10 | perpetuals::Permissions, 11 | }, 12 | }, 13 | }; 14 | 15 | pub fn permissions_full() -> Permissions { 16 | Permissions { 17 | allow_swap: true, 18 | allow_add_liquidity: true, 19 | allow_remove_liquidity: true, 20 | allow_open_position: true, 21 | allow_close_position: true, 22 | allow_pnl_withdrawal: true, 23 | allow_collateral_withdrawal: true, 24 | allow_size_change: true, 25 | } 26 | } 27 | 28 | pub fn borrow_rate_regular() -> BorrowRateParams { 29 | BorrowRateParams { 30 | base_rate: 0, 31 | slope1: 80_000, 32 | slope2: 120_000, 33 | optimal_utilization: 800_000_000, 34 | } 35 | } 36 | 37 | pub fn fees_linear_regular() -> Fees { 38 | Fees { 39 | mode: FeesMode::Linear, 40 | ratio_mult: 20_000, 41 | utilization_mult: 20_000, 42 | swap_in: 100, 43 | swap_out: 100, 44 | stable_swap_in: 100, 45 | stable_swap_out: 100, 46 | add_liquidity: 200, 47 | remove_liquidity: 300, 48 | open_position: 100, 49 | close_position: 100, 50 | liquidation: 50, 51 | protocol_share: 25, 52 | } 53 | } 54 | 55 | pub fn pricing_params_regular(use_ema: bool) -> PricingParams { 56 | PricingParams { 57 | use_ema, 58 | use_unrealized_pnl_in_aum: true, 59 | trade_spread_long: 100, 60 | trade_spread_short: 100, 61 | swap_spread: 300, 62 | min_initial_leverage: 10_000, 63 | max_initial_leverage: 100_000, 64 | max_leverage: 100_000, 65 | max_payoff_mult: 10_000, 66 | max_utilization: 0, 67 | max_position_locked_usd: 0, 68 | max_total_locked_usd: 0, 69 | } 70 | } 71 | 72 | pub fn oracle_params_regular(oracle_account: Pubkey) -> OracleParams { 73 | OracleParams { 74 | oracle_account, 75 | oracle_type: OracleType::Test, 76 | max_price_error: 1_000_000, 77 | max_price_age_sec: 30, 78 | } 79 | } 80 | 81 | pub fn init_params_permissions_full(min_signatures: u8) -> InitParams { 82 | InitParams { 83 | min_signatures, 84 | allow_swap: true, 85 | allow_add_liquidity: true, 86 | allow_remove_liquidity: true, 87 | allow_open_position: true, 88 | allow_close_position: true, 89 | allow_pnl_withdrawal: true, 90 | allow_collateral_withdrawal: true, 91 | allow_size_change: true, 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/remove_pool.rs: -------------------------------------------------------------------------------- 1 | //! RemovePool instruction handler 2 | 3 | use { 4 | crate::{ 5 | error::PerpetualsError, 6 | state::{ 7 | multisig::{AdminInstruction, Multisig}, 8 | perpetuals::Perpetuals, 9 | pool::Pool, 10 | }, 11 | }, 12 | anchor_lang::prelude::*, 13 | }; 14 | 15 | #[derive(Accounts)] 16 | pub struct RemovePool<'info> { 17 | #[account(mut)] 18 | pub admin: Signer<'info>, 19 | 20 | #[account( 21 | mut, 22 | seeds = [b"multisig"], 23 | bump = multisig.load()?.bump 24 | )] 25 | pub multisig: AccountLoader<'info, Multisig>, 26 | 27 | /// CHECK: empty PDA, authority for token accounts 28 | #[account( 29 | mut, 30 | seeds = [b"transfer_authority"], 31 | bump = perpetuals.transfer_authority_bump 32 | )] 33 | pub transfer_authority: AccountInfo<'info>, 34 | 35 | #[account( 36 | mut, 37 | realloc = Perpetuals::LEN + (perpetuals.pools.len() - 1) * 32, 38 | realloc::payer = admin, 39 | realloc::zero = false, 40 | seeds = [b"perpetuals"], 41 | bump = perpetuals.perpetuals_bump 42 | )] 43 | pub perpetuals: Box>, 44 | 45 | #[account( 46 | mut, 47 | seeds = [b"pool", 48 | pool.name.as_bytes()], 49 | bump = pool.bump, 50 | close = transfer_authority 51 | )] 52 | pub pool: Box>, 53 | 54 | system_program: Program<'info, System>, 55 | } 56 | 57 | #[derive(AnchorSerialize, AnchorDeserialize)] 58 | pub struct RemovePoolParams {} 59 | 60 | pub fn remove_pool<'info>( 61 | ctx: Context<'_, '_, '_, 'info, RemovePool<'info>>, 62 | params: &RemovePoolParams, 63 | ) -> Result { 64 | // validate signatures 65 | let mut multisig = ctx.accounts.multisig.load_mut()?; 66 | 67 | let signatures_left = multisig.sign_multisig( 68 | &ctx.accounts.admin, 69 | &Multisig::get_account_infos(&ctx)[1..], 70 | &Multisig::get_instruction_data(AdminInstruction::RemovePool, params)?, 71 | )?; 72 | if signatures_left > 0 { 73 | msg!( 74 | "Instruction has been signed but more signatures are required: {}", 75 | signatures_left 76 | ); 77 | return Ok(signatures_left); 78 | } 79 | 80 | require!( 81 | ctx.accounts.pool.custodies.is_empty(), 82 | PerpetualsError::InvalidPoolState 83 | ); 84 | 85 | // remove pool from the list 86 | let perpetuals = ctx.accounts.perpetuals.as_mut(); 87 | let pool_idx = perpetuals 88 | .pools 89 | .iter() 90 | .position(|x| *x == ctx.accounts.pool.key()) 91 | .ok_or(PerpetualsError::InvalidPoolState)?; 92 | perpetuals.pools.remove(pool_idx); 93 | 94 | Ok(0) 95 | } 96 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/set_permissions.rs: -------------------------------------------------------------------------------- 1 | //! SetPermissions instruction handler 2 | 3 | use { 4 | crate::{ 5 | error::PerpetualsError, 6 | state::{ 7 | multisig::{AdminInstruction, Multisig}, 8 | perpetuals::Perpetuals, 9 | }, 10 | }, 11 | anchor_lang::prelude::*, 12 | }; 13 | 14 | #[derive(Accounts)] 15 | pub struct SetPermissions<'info> { 16 | #[account()] 17 | pub admin: Signer<'info>, 18 | 19 | #[account( 20 | mut, 21 | seeds = [b"multisig"], 22 | bump = multisig.load()?.bump 23 | )] 24 | pub multisig: AccountLoader<'info, Multisig>, 25 | 26 | #[account( 27 | mut, 28 | seeds = [b"perpetuals"], 29 | bump = perpetuals.perpetuals_bump 30 | )] 31 | pub perpetuals: Box>, 32 | } 33 | 34 | #[derive(AnchorSerialize, AnchorDeserialize)] 35 | pub struct SetPermissionsParams { 36 | pub allow_swap: bool, 37 | pub allow_add_liquidity: bool, 38 | pub allow_remove_liquidity: bool, 39 | pub allow_open_position: bool, 40 | pub allow_close_position: bool, 41 | pub allow_pnl_withdrawal: bool, 42 | pub allow_collateral_withdrawal: bool, 43 | pub allow_size_change: bool, 44 | } 45 | 46 | pub fn set_permissions<'info>( 47 | ctx: Context<'_, '_, '_, 'info, SetPermissions<'info>>, 48 | params: &SetPermissionsParams, 49 | ) -> Result { 50 | // validate signatures 51 | let mut multisig = ctx.accounts.multisig.load_mut()?; 52 | 53 | let signatures_left = multisig.sign_multisig( 54 | &ctx.accounts.admin, 55 | &Multisig::get_account_infos(&ctx)[1..], 56 | &Multisig::get_instruction_data(AdminInstruction::SetPermissions, params)?, 57 | )?; 58 | if signatures_left > 0 { 59 | msg!( 60 | "Instruction has been signed but more signatures are required: {}", 61 | signatures_left 62 | ); 63 | return Ok(signatures_left); 64 | } 65 | 66 | // update permissions 67 | let perpetuals = ctx.accounts.perpetuals.as_mut(); 68 | perpetuals.permissions.allow_swap = params.allow_swap; 69 | perpetuals.permissions.allow_add_liquidity = params.allow_add_liquidity; 70 | perpetuals.permissions.allow_remove_liquidity = params.allow_remove_liquidity; 71 | perpetuals.permissions.allow_open_position = params.allow_open_position; 72 | perpetuals.permissions.allow_close_position = params.allow_close_position; 73 | perpetuals.permissions.allow_pnl_withdrawal = params.allow_pnl_withdrawal; 74 | perpetuals.permissions.allow_collateral_withdrawal = params.allow_collateral_withdrawal; 75 | perpetuals.permissions.allow_size_change = params.allow_size_change; 76 | 77 | if !perpetuals.validate() { 78 | err!(PerpetualsError::InvalidPerpetualsConfig) 79 | } else { 80 | Ok(0) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_pnl.rs: -------------------------------------------------------------------------------- 1 | //! GetPnl instruction handler 2 | 3 | use { 4 | crate::state::{ 5 | custody::Custody, 6 | oracle::OraclePrice, 7 | perpetuals::{Perpetuals, ProfitAndLoss}, 8 | pool::Pool, 9 | position::Position, 10 | }, 11 | anchor_lang::prelude::*, 12 | }; 13 | 14 | #[derive(Accounts)] 15 | pub struct GetPnl<'info> { 16 | #[account( 17 | seeds = [b"perpetuals"], 18 | bump = perpetuals.perpetuals_bump 19 | )] 20 | pub perpetuals: Box>, 21 | 22 | #[account( 23 | seeds = [b"pool", 24 | pool.name.as_bytes()], 25 | bump = pool.bump 26 | )] 27 | pub pool: Box>, 28 | 29 | #[account( 30 | seeds = [b"position", 31 | position.owner.as_ref(), 32 | pool.key().as_ref(), 33 | custody.key().as_ref(), 34 | &[position.side as u8]], 35 | bump = position.bump 36 | )] 37 | pub position: Box>, 38 | 39 | #[account( 40 | seeds = [b"custody", 41 | pool.key().as_ref(), 42 | custody.mint.as_ref()], 43 | bump = custody.bump 44 | )] 45 | pub custody: Box>, 46 | 47 | /// CHECK: oracle account for the collateral token 48 | #[account( 49 | constraint = custody_oracle_account.key() == custody.oracle.oracle_account 50 | )] 51 | pub custody_oracle_account: AccountInfo<'info>, 52 | } 53 | 54 | #[derive(AnchorSerialize, AnchorDeserialize)] 55 | pub struct GetPnlParams {} 56 | 57 | pub fn get_pnl(ctx: Context, _params: &GetPnlParams) -> Result { 58 | // get oracle prices 59 | let position = &ctx.accounts.position; 60 | let pool = &ctx.accounts.pool; 61 | let curtime = ctx.accounts.perpetuals.get_time()?; 62 | let custody = ctx.accounts.custody.as_mut(); 63 | 64 | let token_price = OraclePrice::new_from_oracle( 65 | custody.oracle.oracle_type, 66 | &ctx.accounts.custody_oracle_account.to_account_info(), 67 | custody.oracle.max_price_error, 68 | custody.oracle.max_price_age_sec, 69 | curtime, 70 | false, 71 | )?; 72 | 73 | let token_ema_price = OraclePrice::new_from_oracle( 74 | custody.oracle.oracle_type, 75 | &ctx.accounts.custody_oracle_account.to_account_info(), 76 | custody.oracle.max_price_error, 77 | custody.oracle.max_price_age_sec, 78 | curtime, 79 | custody.pricing.use_ema, 80 | )?; 81 | 82 | // compute pnl 83 | let (profit, loss, _) = pool.get_pnl_usd( 84 | position, 85 | &token_price, 86 | &token_ema_price, 87 | custody, 88 | curtime, 89 | false, 90 | )?; 91 | 92 | Ok(ProfitAndLoss { profit, loss }) 93 | } 94 | -------------------------------------------------------------------------------- /ui/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import * as PerpetualsJson from "@/target/idl/perpetuals.json"; 2 | import { IDL as PERPETUALS_IDL, Perpetuals } from "@/target/types/perpetuals"; 3 | import { getProvider } from "@/utils/provider"; 4 | import { AnchorProvider, Program, Wallet } from "@project-serum/anchor"; 5 | import NodeWallet from "@project-serum/anchor/dist/cjs/nodewallet"; 6 | import { findProgramAddressSync } from "@project-serum/anchor/dist/cjs/utils/pubkey"; 7 | import { WalletContextState } from "@solana/wallet-adapter-react"; 8 | import { Keypair, PublicKey, Transaction } from "@solana/web3.js"; 9 | 10 | export const PERPETUALS_PROGRAM_ID = new PublicKey( 11 | PerpetualsJson["metadata"]["address"] 12 | ); 13 | 14 | class DefaultWallet implements Wallet { 15 | constructor(readonly payer: Keypair) {} 16 | 17 | static local(): NodeWallet | never { 18 | throw new Error("Local wallet not supported"); 19 | } 20 | 21 | async signTransaction(tx: Transaction): Promise { 22 | return tx; 23 | } 24 | 25 | async signAllTransactions(txs: Transaction[]): Promise { 26 | return txs; 27 | } 28 | 29 | get publicKey(): PublicKey { 30 | return this.payer.publicKey; 31 | } 32 | } 33 | 34 | export async function getPerpetualProgramAndProvider( 35 | walletContextState?: WalletContextState 36 | ): Promise<{ 37 | perpetual_program: Program; 38 | provider: AnchorProvider; 39 | }> { 40 | let provider; 41 | 42 | let perpetual_program; 43 | 44 | if (walletContextState) { 45 | let wallet: Wallet = { 46 | // @ts-ignore 47 | signTransaction: walletContextState.signTransaction, 48 | // @ts-ignore 49 | signAllTransactions: walletContextState.signAllTransactions, 50 | // @ts-ignore 51 | publicKey: walletContextState.publicKey, 52 | }; 53 | 54 | provider = await getProvider(wallet); 55 | } else { 56 | provider = await getProvider(new DefaultWallet(DEFAULT_PERPS_USER)); 57 | } 58 | 59 | perpetual_program = new Program( 60 | PERPETUALS_IDL, 61 | PERPETUALS_PROGRAM_ID, 62 | provider 63 | ); 64 | 65 | return { perpetual_program, provider }; 66 | } 67 | 68 | export const TRANSFER_AUTHORITY = findProgramAddressSync( 69 | [Buffer.from("transfer_authority")], 70 | PERPETUALS_PROGRAM_ID 71 | )[0]; 72 | 73 | export const PERPETUALS_ADDRESS = findProgramAddressSync( 74 | [Buffer.from("perpetuals")], 75 | PERPETUALS_PROGRAM_ID 76 | )[0]; 77 | 78 | // default user to launch show basic pool data, etc 79 | export const DEFAULT_PERPS_USER = Keypair.fromSecretKey( 80 | Uint8Array.from([ 81 | 130, 82, 70, 109, 220, 141, 128, 34, 238, 5, 80, 156, 116, 150, 24, 45, 33, 82 | 132, 119, 244, 40, 40, 201, 182, 195, 179, 90, 172, 51, 27, 110, 208, 61, 83 | 23, 43, 217, 131, 209, 127, 113, 93, 139, 35, 156, 34, 16, 94, 236, 175, 84 | 232, 174, 79, 209, 223, 86, 131, 148, 188, 126, 217, 19, 248, 236, 107, 85 | ]) 86 | ); 87 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/instructions/test_set_test_oracle_price.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::utils::{self, pda}, 3 | anchor_lang::{ 4 | prelude::{AccountMeta, Pubkey}, 5 | ToAccountMetas, 6 | }, 7 | perpetuals::{ 8 | instructions::SetTestOraclePriceParams, 9 | state::{multisig::Multisig, oracle::TestOracle}, 10 | }, 11 | solana_program_test::{BanksClientError, ProgramTestContext}, 12 | solana_sdk::signer::{keypair::Keypair, Signer}, 13 | }; 14 | 15 | #[allow(clippy::too_many_arguments)] 16 | pub async fn test_set_test_oracle_price( 17 | program_test_ctx: &mut ProgramTestContext, 18 | admin: &Keypair, 19 | payer: &Keypair, 20 | pool_pda: &Pubkey, 21 | custody_pda: &Pubkey, 22 | oracle_pda: &Pubkey, 23 | params: SetTestOraclePriceParams, 24 | multisig_signers: &[&Keypair], 25 | ) -> std::result::Result<(), BanksClientError> { 26 | // ==== WHEN ============================================================== 27 | let multisig_pda = pda::get_multisig_pda().0; 28 | let perpetuals_pda = pda::get_perpetuals_pda().0; 29 | 30 | let multisig_account = utils::get_account::(program_test_ctx, multisig_pda).await; 31 | 32 | // One Tx per multisig signer 33 | for i in 0..multisig_account.min_signatures { 34 | let signer: &Keypair = multisig_signers[i as usize]; 35 | 36 | let accounts_meta = { 37 | let accounts = perpetuals::accounts::SetTestOraclePrice { 38 | admin: admin.pubkey(), 39 | multisig: multisig_pda, 40 | perpetuals: perpetuals_pda, 41 | pool: *pool_pda, 42 | custody: *custody_pda, 43 | oracle_account: *oracle_pda, 44 | system_program: anchor_lang::system_program::ID, 45 | }; 46 | 47 | let mut accounts_meta = accounts.to_account_metas(None); 48 | 49 | accounts_meta.push(AccountMeta { 50 | pubkey: signer.pubkey(), 51 | is_signer: true, 52 | is_writable: false, 53 | }); 54 | 55 | accounts_meta 56 | }; 57 | 58 | utils::create_and_execute_perpetuals_ix( 59 | program_test_ctx, 60 | accounts_meta, 61 | perpetuals::instruction::SetTestOraclePrice { params }, 62 | Some(&payer.pubkey()), 63 | &[admin, payer, signer], 64 | ) 65 | .await?; 66 | } 67 | 68 | // ==== THEN ============================================================== 69 | let test_oracle_account = utils::get_account::(program_test_ctx, *oracle_pda).await; 70 | 71 | assert_eq!(test_oracle_account.price, params.price); 72 | assert_eq!(test_oracle_account.expo, params.expo); 73 | assert_eq!(test_oracle_account.conf, params.conf); 74 | assert_eq!(test_oracle_account.publish_time, params.publish_time); 75 | 76 | Ok(()) 77 | } 78 | -------------------------------------------------------------------------------- /ui/src/lib/PositionAccount.tsx: -------------------------------------------------------------------------------- 1 | import { CustodyAccount } from "@/lib/CustodyAccount"; 2 | import { TokenE } from "@/lib/Token"; 3 | import { Position, Side } from "@/lib/types"; 4 | import { BN } from "@project-serum/anchor"; 5 | import { PublicKey } from "@solana/web3.js"; 6 | 7 | export class PositionAccount { 8 | public owner: PublicKey; 9 | public pool: PublicKey; 10 | public custody: PublicKey; 11 | public lockCustody: PublicKey; 12 | 13 | public openTime: BN; 14 | public updateTime: BN; 15 | 16 | public side: Side; 17 | public price: BN; 18 | public sizeUsd: BN; 19 | public collateralUsd: BN; 20 | public unrealizedProfitUsd: BN; 21 | public unrealizedLossUsd: BN; 22 | public cumulativeInterestSnapshot: BN; 23 | public lockedAmount: BN; 24 | public collateralAmount: BN; 25 | 26 | public token: TokenE; 27 | public address: PublicKey; 28 | public oracleAccount: PublicKey; 29 | 30 | constructor( 31 | position: Position, 32 | address: PublicKey, 33 | custodies: Record 34 | ) { 35 | // console.log("printing entier new consturcture", position.openTime); 36 | this.owner = position.owner; 37 | this.pool = position.pool; 38 | this.custody = position.custody; 39 | this.lockCustody = position.lockCustody; 40 | 41 | this.openTime = position.openTime; 42 | this.updateTime = position.updateTime; 43 | 44 | this.side = position.side.hasOwnProperty("long") ? Side.Long : Side.Short; 45 | this.price = position.price; 46 | this.sizeUsd = position.sizeUsd; 47 | this.collateralUsd = position.collateralUsd; 48 | this.unrealizedProfitUsd = position.unrealizedProfitUsd; 49 | this.unrealizedLossUsd = position.unrealizedLossUsd; 50 | this.cumulativeInterestSnapshot = position.cumulativeInterestSnapshot; 51 | this.lockedAmount = position.lockedAmount; 52 | this.collateralAmount = position.collateralAmount; 53 | 54 | this.token = custodies[this.custody.toString()]?.getTokenE()!; 55 | this.address = address; 56 | this.oracleAccount = 57 | custodies[this.custody.toString()]?.oracle.oracleAccount!; 58 | } 59 | 60 | // TODO update leverage with pnl? 61 | getLeverage(): number { 62 | return this.sizeUsd.toNumber() / this.collateralUsd.toNumber(); 63 | } 64 | 65 | // TODO fix getTimestamp to proper date 66 | getTimestamp(): string { 67 | const date = new Date(Number(this.openTime) * 1000); 68 | const dateString = date.toLocaleString(); 69 | 70 | return dateString; 71 | } 72 | 73 | getCollateralUsd(): number { 74 | return Number(this.collateralUsd) / 10 ** 6; 75 | } 76 | 77 | getPrice(): number { 78 | return Number(this.price) / 10 ** 6; 79 | } 80 | 81 | getSizeUsd(): number { 82 | return Number(this.sizeUsd) / 10 ** 6; 83 | } 84 | 85 | getNetValue(pnl: number): number { 86 | // return this.getSizeUsd() - this.getCollateralUsd(); 87 | return Number(this.getCollateralUsd()) + pnl; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /ui/src/components/TokenSelectorList.tsx: -------------------------------------------------------------------------------- 1 | import { twMerge } from "tailwind-merge"; 2 | import CloseIcon from "@carbon/icons-react/lib/Close"; 3 | import { cloneElement } from "react"; 4 | import { TokenE, getTokenLabel, getTokenIcon, TOKEN_LIST } from "@/lib/Token"; 5 | import { useGlobalStore } from "@/stores/store"; 6 | 7 | function formatNumber(num: number) { 8 | const formatter = Intl.NumberFormat("en", { 9 | maximumFractionDigits: 2, 10 | minimumFractionDigits: 2, 11 | }); 12 | return formatter.format(num); 13 | } 14 | 15 | interface Props { 16 | className?: string; 17 | onClose?(): void; 18 | onSelectToken?(token: TokenE): void; 19 | tokenList?: TokenE[]; 20 | } 21 | 22 | export function TokenSelectorList(props: Props) { 23 | const stats = useGlobalStore((state) => state.priceStats); 24 | 25 | return ( 26 |
30 |
e.stopPropagation()} 33 | > 34 |
35 |
You Pay
36 | 39 |
40 |
41 | {(props.tokenList ? props.tokenList : TOKEN_LIST).map((token) => { 42 | const icon = getTokenIcon(token); 43 | 44 | return ( 45 | 78 | ); 79 | })} 80 |
81 |
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/set_test_oracle_price.rs: -------------------------------------------------------------------------------- 1 | //! SetTestOraclePrice instruction handler 2 | 3 | use { 4 | crate::state::{ 5 | custody::Custody, 6 | multisig::{AdminInstruction, Multisig}, 7 | oracle::TestOracle, 8 | perpetuals::Perpetuals, 9 | pool::Pool, 10 | }, 11 | anchor_lang::prelude::*, 12 | }; 13 | 14 | #[derive(Accounts)] 15 | pub struct SetTestOraclePrice<'info> { 16 | #[account(mut)] 17 | pub admin: Signer<'info>, 18 | 19 | #[account( 20 | mut, 21 | seeds = [b"multisig"], 22 | bump = multisig.load()?.bump 23 | )] 24 | pub multisig: AccountLoader<'info, Multisig>, 25 | 26 | #[account( 27 | seeds = [b"perpetuals"], 28 | bump = perpetuals.perpetuals_bump 29 | )] 30 | pub perpetuals: Box>, 31 | 32 | #[account( 33 | seeds = [b"pool", 34 | pool.name.as_bytes()], 35 | bump = pool.bump 36 | )] 37 | pub pool: Box>, 38 | 39 | #[account( 40 | seeds = [b"custody", 41 | pool.key().as_ref(), 42 | custody.mint.as_ref()], 43 | bump = custody.bump 44 | )] 45 | pub custody: Box>, 46 | 47 | #[account( 48 | init_if_needed, 49 | payer = admin, 50 | space = TestOracle::LEN, 51 | //constraint = oracle_account.key() == custody.oracle.oracle_account, 52 | seeds = [b"oracle_account", 53 | pool.key().as_ref(), 54 | custody.mint.as_ref()], 55 | bump 56 | )] 57 | pub oracle_account: Box>, 58 | 59 | system_program: Program<'info, System>, 60 | } 61 | 62 | #[derive(AnchorSerialize, AnchorDeserialize, Copy, Clone)] 63 | pub struct SetTestOraclePriceParams { 64 | pub price: u64, 65 | pub expo: i32, 66 | pub conf: u64, 67 | pub publish_time: i64, 68 | } 69 | 70 | pub fn set_test_oracle_price<'info>( 71 | ctx: Context<'_, '_, '_, 'info, SetTestOraclePrice<'info>>, 72 | params: &SetTestOraclePriceParams, 73 | ) -> Result { 74 | // validate signatures 75 | let mut multisig = ctx.accounts.multisig.load_mut()?; 76 | 77 | let signatures_left = multisig.sign_multisig( 78 | &ctx.accounts.admin, 79 | &Multisig::get_account_infos(&ctx)[1..], 80 | &Multisig::get_instruction_data(AdminInstruction::SetTestOraclePrice, params)?, 81 | )?; 82 | if signatures_left > 0 { 83 | msg!( 84 | "Instruction has been signed but more signatures are required: {}", 85 | signatures_left 86 | ); 87 | return Ok(signatures_left); 88 | } 89 | 90 | // update oracle data 91 | let oracle_account = ctx.accounts.oracle_account.as_mut(); 92 | oracle_account.price = params.price; 93 | oracle_account.expo = params.expo; 94 | oracle_account.conf = params.conf; 95 | oracle_account.publish_time = params.publish_time; 96 | 97 | Ok(0) 98 | } 99 | -------------------------------------------------------------------------------- /ui/src/components/PoolModal/PoolGeneralStats.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/Icons/LoadingSpinner"; 2 | import { PoolAccount } from "@/lib/PoolAccount"; 3 | import { useGlobalStore } from "@/stores/store"; 4 | import { formatNumberCommas } from "@/utils/formatters"; 5 | import { getLiquidityBalance, getLiquidityShare } from "@/utils/retrieveData"; 6 | import { twMerge } from "tailwind-merge"; 7 | 8 | interface Props { 9 | pool: PoolAccount; 10 | className?: string; 11 | } 12 | 13 | export default function PoolGeneralStats(props: Props) { 14 | const stats = useGlobalStore((state) => state.priceStats); 15 | 16 | const userData = useGlobalStore((state) => state.userData); 17 | 18 | if (Object.keys(stats).length === 0 || props.pool.lpData === null) { 19 | return ; 20 | } else { 21 | return ( 22 |
31 | {[ 32 | { 33 | label: "Liquidity", 34 | value: `$${formatNumberCommas(props.pool.getLiquidities(stats))}`, 35 | }, 36 | { 37 | label: "Volume", 38 | value: `$${formatNumberCommas(props.pool.getTradeVolumes())}`, 39 | }, 40 | { 41 | label: "OI Long", 42 | value: ( 43 | <> 44 | {`$${formatNumberCommas(props.pool.getOiLong())} `} 45 | 46 | 47 | ), 48 | }, 49 | { 50 | label: "OI Short", 51 | value: `$${formatNumberCommas(props.pool.getOiShort())}`, 52 | }, 53 | { 54 | label: "Fees", 55 | value: `$${formatNumberCommas(props.pool.getFees())}`, 56 | }, 57 | { 58 | label: "Your Liquidity", 59 | value: `$${formatNumberCommas( 60 | getLiquidityBalance( 61 | props.pool, 62 | userData.getUserLpBalance(props.pool.address.toString()), 63 | stats 64 | ) 65 | )}`, 66 | }, 67 | { 68 | label: "Your Share", 69 | value: `${formatNumberCommas( 70 | Number( 71 | getLiquidityShare( 72 | props.pool, 73 | userData.getUserLpBalance(props.pool.address.toString()) 74 | ) 75 | ) 76 | )}%`, 77 | }, 78 | ].map(({ label, value }, i) => ( 79 |
83 |
{label}
84 |
{value}
85 |
86 | ))} 87 |
88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_exit_price_and_fee.rs: -------------------------------------------------------------------------------- 1 | //! GetExitPriceAndFee instruction handler 2 | 3 | use { 4 | crate::state::{ 5 | custody::Custody, 6 | oracle::OraclePrice, 7 | perpetuals::{Perpetuals, PriceAndFee}, 8 | pool::Pool, 9 | position::Position, 10 | }, 11 | anchor_lang::prelude::*, 12 | }; 13 | 14 | #[derive(Accounts)] 15 | pub struct GetExitPriceAndFee<'info> { 16 | #[account( 17 | seeds = [b"perpetuals"], 18 | bump = perpetuals.perpetuals_bump 19 | )] 20 | pub perpetuals: Box>, 21 | 22 | #[account( 23 | seeds = [b"pool", 24 | pool.name.as_bytes()], 25 | bump = pool.bump 26 | )] 27 | pub pool: Box>, 28 | 29 | #[account( 30 | seeds = [b"position", 31 | position.owner.as_ref(), 32 | pool.key().as_ref(), 33 | custody.key().as_ref(), 34 | &[position.side as u8]], 35 | bump = position.bump 36 | )] 37 | pub position: Box>, 38 | 39 | #[account( 40 | seeds = [b"custody", 41 | pool.key().as_ref(), 42 | custody.mint.as_ref()], 43 | bump = custody.bump 44 | )] 45 | pub custody: Box>, 46 | 47 | /// CHECK: oracle account for the collateral token 48 | #[account( 49 | constraint = custody_oracle_account.key() == custody.oracle.oracle_account 50 | )] 51 | pub custody_oracle_account: AccountInfo<'info>, 52 | } 53 | 54 | #[derive(AnchorSerialize, AnchorDeserialize)] 55 | pub struct GetExitPriceAndFeeParams {} 56 | 57 | pub fn get_exit_price_and_fee( 58 | ctx: Context, 59 | _params: &GetExitPriceAndFeeParams, 60 | ) -> Result { 61 | // compute exit price and fee 62 | let position = &ctx.accounts.position; 63 | let pool = &ctx.accounts.pool; 64 | let curtime = ctx.accounts.perpetuals.get_time()?; 65 | let custody = ctx.accounts.custody.as_mut(); 66 | 67 | let token_price = OraclePrice::new_from_oracle( 68 | custody.oracle.oracle_type, 69 | &ctx.accounts.custody_oracle_account.to_account_info(), 70 | custody.oracle.max_price_error, 71 | custody.oracle.max_price_age_sec, 72 | curtime, 73 | false, 74 | )?; 75 | 76 | let token_ema_price = OraclePrice::new_from_oracle( 77 | custody.oracle.oracle_type, 78 | &ctx.accounts.custody_oracle_account.to_account_info(), 79 | custody.oracle.max_price_error, 80 | custody.oracle.max_price_age_sec, 81 | curtime, 82 | custody.pricing.use_ema, 83 | )?; 84 | 85 | let price = pool.get_exit_price(&token_price, &token_ema_price, position.side, custody)?; 86 | 87 | let size = token_ema_price.get_token_amount(position.size_usd, custody.decimals)?; 88 | 89 | let fee = pool.get_exit_fee(size, custody)?; 90 | 91 | Ok(PriceAndFee { price, fee }) 92 | } 93 | -------------------------------------------------------------------------------- /ui/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import { WalletAdapterNetwork } from "@solana/wallet-adapter-base"; 3 | import { 4 | ConnectionProvider, 5 | WalletProvider, 6 | } from "@solana/wallet-adapter-react"; 7 | import { WalletModalProvider } from "@solana/wallet-adapter-react-ui"; 8 | import { 9 | BackpackWalletAdapter, 10 | BraveWalletAdapter, 11 | CloverWalletAdapter, 12 | CoinbaseWalletAdapter, 13 | ExodusWalletAdapter, 14 | GlowWalletAdapter, 15 | HuobiWalletAdapter, 16 | LedgerWalletAdapter, 17 | PhantomWalletAdapter, 18 | SlopeWalletAdapter, 19 | SolletWalletAdapter, 20 | SolongWalletAdapter, 21 | TorusWalletAdapter, 22 | TrustWalletAdapter, 23 | } from "@solana/wallet-adapter-wallets"; 24 | import { clusterApiUrl } from "@solana/web3.js"; 25 | import { AppProps } from "next/app"; 26 | import React, { FC, ReactNode, useMemo } from "react"; 27 | import { ToastContainer } from "react-toastify"; 28 | import "react-toastify/dist/ReactToastify.css"; 29 | 30 | require("@solana/wallet-adapter-react-ui/styles.css"); 31 | 32 | import { Navbar } from "@/components/Navbar"; 33 | import { useHydrateStore } from "@/hooks/useHydrateStore"; 34 | 35 | const StoreUpdater = () => { 36 | useHydrateStore(); 37 | return null; 38 | }; 39 | 40 | export default function MyApp({ Component, pageProps }: AppProps) { 41 | return ( 42 | 43 | 54 | 55 | 56 | 57 | 58 | ); 59 | } 60 | 61 | const Context: FC<{ children: ReactNode }> = ({ children }) => { 62 | // Can be set to 'devnet', 'testnet', or 'mainnet-beta' 63 | const network = WalletAdapterNetwork.Devnet; 64 | const endpoint = useMemo(() => clusterApiUrl(network), [network]); 65 | // const endpoint = useMemo(() => "http://localhost:8899"); 66 | 67 | const wallets = useMemo( 68 | () => [ 69 | new PhantomWalletAdapter(), 70 | new SlopeWalletAdapter(), 71 | new TorusWalletAdapter(), 72 | new LedgerWalletAdapter(), 73 | new BackpackWalletAdapter(), 74 | new BraveWalletAdapter(), 75 | new CloverWalletAdapter(), 76 | new CoinbaseWalletAdapter(), 77 | new ExodusWalletAdapter(), 78 | new GlowWalletAdapter(), 79 | new HuobiWalletAdapter(), 80 | new SolletWalletAdapter(), 81 | new SolongWalletAdapter(), 82 | new TrustWalletAdapter(), 83 | ], 84 | [] 85 | ); 86 | 87 | return ( 88 |
89 | 90 | 91 | {children} 92 | 93 | 94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/withdraw_sol_fees.rs: -------------------------------------------------------------------------------- 1 | //! WithdrawSolFees instruction handler 2 | 3 | use { 4 | crate::{ 5 | math, 6 | state::{ 7 | multisig::{AdminInstruction, Multisig}, 8 | perpetuals::Perpetuals, 9 | }, 10 | }, 11 | anchor_lang::prelude::*, 12 | solana_program::sysvar, 13 | }; 14 | 15 | #[derive(Accounts)] 16 | pub struct WithdrawSolFees<'info> { 17 | #[account()] 18 | pub admin: Signer<'info>, 19 | 20 | #[account( 21 | mut, 22 | seeds = [b"multisig"], 23 | bump = multisig.load()?.bump 24 | )] 25 | pub multisig: AccountLoader<'info, Multisig>, 26 | 27 | /// CHECK: empty PDA, authority for token accounts 28 | #[account( 29 | seeds = [b"transfer_authority"], 30 | bump = perpetuals.transfer_authority_bump 31 | )] 32 | pub transfer_authority: AccountInfo<'info>, 33 | 34 | #[account( 35 | seeds = [b"perpetuals"], 36 | bump = perpetuals.perpetuals_bump 37 | )] 38 | pub perpetuals: Box>, 39 | 40 | /// CHECK: SOL fees receiving account 41 | #[account( 42 | mut, 43 | constraint = receiving_account.data_is_empty() 44 | )] 45 | pub receiving_account: AccountInfo<'info>, 46 | } 47 | 48 | #[derive(AnchorSerialize, AnchorDeserialize)] 49 | pub struct WithdrawSolFeesParams { 50 | pub amount: u64, 51 | } 52 | 53 | pub fn withdraw_sol_fees<'info>( 54 | ctx: Context<'_, '_, '_, 'info, WithdrawSolFees<'info>>, 55 | params: &WithdrawSolFeesParams, 56 | ) -> Result { 57 | // validate inputs 58 | if params.amount == 0 { 59 | return Err(ProgramError::InvalidArgument.into()); 60 | } 61 | 62 | // validate signatures 63 | let mut multisig = ctx.accounts.multisig.load_mut()?; 64 | 65 | let signatures_left = multisig.sign_multisig( 66 | &ctx.accounts.admin, 67 | &Multisig::get_account_infos(&ctx)[1..], 68 | &Multisig::get_instruction_data(AdminInstruction::WithdrawSolFees, params)?, 69 | )?; 70 | if signatures_left > 0 { 71 | msg!( 72 | "Instruction has been signed but more signatures are required: {}", 73 | signatures_left 74 | ); 75 | return Ok(signatures_left); 76 | } 77 | 78 | // transfer sol fees from the custody to the receiver 79 | let balance = ctx.accounts.transfer_authority.try_lamports()?; 80 | let min_balance = sysvar::rent::Rent::get().unwrap().minimum_balance(0); 81 | let available_balance = if balance > min_balance { 82 | math::checked_sub(balance, min_balance)? 83 | } else { 84 | 0 85 | }; 86 | 87 | msg!( 88 | "Withdraw SOL fees: {} / {}", 89 | params.amount, 90 | available_balance 91 | ); 92 | 93 | if available_balance < params.amount { 94 | return Err(ProgramError::InsufficientFunds.into()); 95 | } 96 | 97 | Perpetuals::transfer_sol_from_owned( 98 | ctx.accounts.transfer_authority.to_account_info(), 99 | ctx.accounts.receiving_account.to_account_info(), 100 | params.amount, 101 | )?; 102 | 103 | Ok(0) 104 | } 105 | -------------------------------------------------------------------------------- /ui/src/utils/transactionHelpers.ts: -------------------------------------------------------------------------------- 1 | import { checkIfAccountExists } from "@/utils/retrieveData"; 2 | import { 3 | createAssociatedTokenAccountInstruction, 4 | createCloseAccountInstruction, 5 | createSyncNativeInstruction, 6 | getAssociatedTokenAddress, 7 | NATIVE_MINT, 8 | TOKEN_PROGRAM_ID, 9 | } from "@solana/spl-token"; 10 | import { 11 | Connection, 12 | LAMPORTS_PER_SOL, 13 | PublicKey, 14 | SystemProgram, 15 | TransactionInstruction, 16 | } from "@solana/web3.js"; 17 | 18 | export async function createAtaIfNeeded( 19 | publicKey: PublicKey, 20 | payer: PublicKey, 21 | mint: PublicKey, 22 | connection: Connection 23 | ): Promise { 24 | const associatedTokenAccount = await getAssociatedTokenAddress( 25 | mint, 26 | publicKey 27 | ); 28 | 29 | console.log("creating ata", associatedTokenAccount.toString()); 30 | if (!(await checkIfAccountExists(associatedTokenAccount, connection))) { 31 | console.log("ata doesn't exist"); 32 | return createAssociatedTokenAccountInstruction( 33 | payer, 34 | associatedTokenAccount, 35 | publicKey, 36 | mint 37 | ); 38 | } 39 | 40 | return null; 41 | } 42 | 43 | export async function wrapSolIfNeeded( 44 | publicKey: PublicKey, 45 | payer: PublicKey, 46 | connection: Connection, 47 | payAmount: number 48 | ): Promise { 49 | console.log("in wrap sol if needed"); 50 | let preInstructions: TransactionInstruction[] = []; 51 | 52 | const associatedTokenAccount = await getAssociatedTokenAddress( 53 | NATIVE_MINT, 54 | publicKey 55 | ); 56 | 57 | const balance = 58 | (await connection.getBalance(associatedTokenAccount)) / LAMPORTS_PER_SOL; 59 | 60 | if (balance < payAmount) { 61 | console.log("balance insufficient"); 62 | 63 | preInstructions.push( 64 | SystemProgram.transfer({ 65 | fromPubkey: publicKey, 66 | toPubkey: associatedTokenAccount, 67 | lamports: Math.floor((payAmount - balance) * LAMPORTS_PER_SOL * 3), 68 | }) 69 | ); 70 | preInstructions.push( 71 | createSyncNativeInstruction(associatedTokenAccount, TOKEN_PROGRAM_ID) 72 | ); 73 | } 74 | 75 | return preInstructions.length > 0 ? preInstructions : null; 76 | } 77 | 78 | export async function unwrapSolIfNeeded( 79 | publicKey: PublicKey, 80 | payer: PublicKey, 81 | connection: Connection 82 | ): Promise { 83 | console.log("in unwrap sol if needed"); 84 | let preInstructions: TransactionInstruction[] = []; 85 | 86 | const associatedTokenAccount = await getAssociatedTokenAddress( 87 | NATIVE_MINT, 88 | publicKey 89 | ); 90 | 91 | // const balance = 92 | // (await connection.getBalance(associatedTokenAccount)) / LAMPORTS_PER_SOL; 93 | const balance = 1; 94 | 95 | if (balance > 0) { 96 | preInstructions.push( 97 | createCloseAccountInstruction( 98 | associatedTokenAccount, 99 | publicKey, 100 | publicKey 101 | ) 102 | ); 103 | } 104 | 105 | console.log("unwrap sol ix", preInstructions); 106 | 107 | return preInstructions.length > 0 ? preInstructions : null; 108 | } 109 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/set_custody_config.rs: -------------------------------------------------------------------------------- 1 | //! SetCustodyConfig instruction handler 2 | 3 | use { 4 | crate::{ 5 | error::PerpetualsError, 6 | state::{ 7 | custody::{BorrowRateParams, Custody, Fees, OracleParams, PricingParams}, 8 | multisig::{AdminInstruction, Multisig}, 9 | perpetuals::Permissions, 10 | pool::{Pool, TokenRatios}, 11 | }, 12 | }, 13 | anchor_lang::prelude::*, 14 | }; 15 | 16 | #[derive(Accounts)] 17 | pub struct SetCustodyConfig<'info> { 18 | #[account()] 19 | pub admin: Signer<'info>, 20 | 21 | #[account( 22 | mut, 23 | seeds = [b"multisig"], 24 | bump = multisig.load()?.bump 25 | )] 26 | pub multisig: AccountLoader<'info, Multisig>, 27 | 28 | #[account( 29 | mut, 30 | seeds = [b"pool", 31 | pool.name.as_bytes()], 32 | bump = pool.bump 33 | )] 34 | pub pool: Box>, 35 | 36 | #[account( 37 | mut, 38 | seeds = [b"custody", 39 | pool.key().as_ref(), 40 | custody.mint.as_ref()], 41 | bump 42 | )] 43 | pub custody: Box>, 44 | } 45 | 46 | #[derive(AnchorSerialize, AnchorDeserialize, Clone)] 47 | pub struct SetCustodyConfigParams { 48 | pub is_stable: bool, 49 | pub oracle: OracleParams, 50 | pub pricing: PricingParams, 51 | pub permissions: Permissions, 52 | pub fees: Fees, 53 | pub borrow_rate: BorrowRateParams, 54 | pub ratios: Vec, 55 | } 56 | 57 | pub fn set_custody_config<'info>( 58 | ctx: Context<'_, '_, '_, 'info, SetCustodyConfig<'info>>, 59 | params: &SetCustodyConfigParams, 60 | ) -> Result { 61 | // validate inputs 62 | if params.ratios.len() != ctx.accounts.pool.ratios.len() { 63 | return Err(ProgramError::InvalidArgument.into()); 64 | } 65 | 66 | // validate signatures 67 | let mut multisig = ctx.accounts.multisig.load_mut()?; 68 | 69 | let signatures_left = multisig.sign_multisig( 70 | &ctx.accounts.admin, 71 | &Multisig::get_account_infos(&ctx)[1..], 72 | &Multisig::get_instruction_data(AdminInstruction::SetCustodyConfig, params)?, 73 | )?; 74 | if signatures_left > 0 { 75 | msg!( 76 | "Instruction has been signed but more signatures are required: {}", 77 | signatures_left 78 | ); 79 | return Ok(signatures_left); 80 | } 81 | 82 | // update pool data 83 | let pool = ctx.accounts.pool.as_mut(); 84 | pool.ratios = params.ratios.clone(); 85 | if !pool.validate() { 86 | return err!(PerpetualsError::InvalidPoolConfig); 87 | } 88 | 89 | // update custody data 90 | let custody = ctx.accounts.custody.as_mut(); 91 | custody.is_stable = params.is_stable; 92 | custody.oracle = params.oracle; 93 | custody.pricing = params.pricing; 94 | custody.permissions = params.permissions; 95 | custody.fees = params.fees; 96 | custody.borrow_rate = params.borrow_rate; 97 | 98 | if !custody.validate() { 99 | err!(PerpetualsError::InvalidCustodyConfig) 100 | } else { 101 | Ok(0) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /ui/src/actions/closePosition.ts: -------------------------------------------------------------------------------- 1 | import { CustodyAccount } from "@/lib/CustodyAccount"; 2 | import { PoolAccount } from "@/lib/PoolAccount"; 3 | import { PositionAccount } from "@/lib/PositionAccount"; 4 | import { TokenE } from "@/lib/Token"; 5 | import { 6 | getPerpetualProgramAndProvider, 7 | PERPETUALS_ADDRESS, 8 | TRANSFER_AUTHORITY, 9 | } from "@/utils/constants"; 10 | import { 11 | automaticSendTransaction, 12 | manualSendTransaction, 13 | } from "@/utils/TransactionHandlers"; 14 | import { 15 | createAtaIfNeeded, 16 | unwrapSolIfNeeded, 17 | } from "@/utils/transactionHelpers"; 18 | import { BN } from "@project-serum/anchor"; 19 | import { getAssociatedTokenAddress, TOKEN_PROGRAM_ID } from "@solana/spl-token"; 20 | import { WalletContextState } from "@solana/wallet-adapter-react"; 21 | import { Connection, TransactionInstruction } from "@solana/web3.js"; 22 | 23 | export async function closePosition( 24 | walletContextState: WalletContextState, 25 | connection: Connection, 26 | pool: PoolAccount, 27 | position: PositionAccount, 28 | custody: CustodyAccount, 29 | price: BN 30 | ) { 31 | let { perpetual_program } = await getPerpetualProgramAndProvider( 32 | walletContextState 33 | ); 34 | let publicKey = walletContextState.publicKey!; 35 | 36 | // TODO: need to take slippage as param , this is now for testing 37 | const adjustedPrice = 38 | position.side.toString() == "Long" 39 | ? price.mul(new BN(50)).div(new BN(100)) 40 | : price.mul(new BN(150)).div(new BN(100)); 41 | 42 | let userCustodyTokenAccount = await getAssociatedTokenAddress( 43 | custody.mint, 44 | publicKey 45 | ); 46 | 47 | let preInstructions: TransactionInstruction[] = []; 48 | 49 | let ataIx = await createAtaIfNeeded( 50 | publicKey, 51 | publicKey, 52 | custody.mint, 53 | connection 54 | ); 55 | 56 | if (ataIx) preInstructions.push(ataIx); 57 | 58 | let postInstructions: TransactionInstruction[] = []; 59 | let unwrapTx = await unwrapSolIfNeeded(publicKey, publicKey, connection); 60 | if (unwrapTx) postInstructions.push(...unwrapTx); 61 | 62 | let methodBuilder = await perpetual_program.methods 63 | .closePosition({ 64 | price: adjustedPrice, 65 | }) 66 | .accounts({ 67 | owner: publicKey, 68 | receivingAccount: userCustodyTokenAccount, 69 | transferAuthority: TRANSFER_AUTHORITY, 70 | perpetuals: PERPETUALS_ADDRESS, 71 | pool: pool.address, 72 | position: position.address, 73 | custody: custody.address, 74 | custodyOracleAccount: custody.oracle.oracleAccount, 75 | custodyTokenAccount: custody.tokenAccount, 76 | tokenProgram: TOKEN_PROGRAM_ID, 77 | }) 78 | .preInstructions(preInstructions); 79 | 80 | if (position.token == TokenE.SOL) 81 | methodBuilder = methodBuilder.postInstructions(postInstructions); 82 | 83 | try { 84 | // await automaticSendTransaction( 85 | // methodBuilder, 86 | // perpetual_program.provider.connection 87 | // ); 88 | let tx = await methodBuilder.transaction(); 89 | await manualSendTransaction( 90 | tx, 91 | publicKey, 92 | connection, 93 | walletContextState.signTransaction 94 | ); 95 | } catch (err) { 96 | console.log(err); 97 | throw err; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/instructions/test_close_position.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::utils::{self, pda}, 3 | anchor_lang::{prelude::Pubkey, ToAccountMetas}, 4 | bonfida_test_utils::ProgramTestContextExt, 5 | perpetuals::{instructions::ClosePositionParams, state::custody::Custody}, 6 | solana_program_test::{BanksClientError, ProgramTestContext}, 7 | solana_sdk::signer::{keypair::Keypair, Signer}, 8 | }; 9 | 10 | pub async fn test_close_position( 11 | program_test_ctx: &mut ProgramTestContext, 12 | owner: &Keypair, 13 | payer: &Keypair, 14 | pool_pda: &Pubkey, 15 | custody_token_mint: &Pubkey, 16 | position_pda: &Pubkey, 17 | params: ClosePositionParams, 18 | ) -> std::result::Result<(), BanksClientError> { 19 | // ==== WHEN ============================================================== 20 | 21 | // Prepare PDA and addresses 22 | let transfer_authority_pda = pda::get_transfer_authority_pda().0; 23 | let perpetuals_pda = pda::get_perpetuals_pda().0; 24 | let custody_pda = pda::get_custody_pda(pool_pda, custody_token_mint).0; 25 | let custody_token_account_pda = 26 | pda::get_custody_token_account_pda(pool_pda, custody_token_mint).0; 27 | 28 | let receiving_account_address = 29 | utils::find_associated_token_account(&owner.pubkey(), custody_token_mint).0; 30 | 31 | let custody_account = utils::get_account::(program_test_ctx, custody_pda).await; 32 | let custody_oracle_account_address = custody_account.oracle.oracle_account; 33 | 34 | // Save account state before tx execution 35 | let owner_receiving_account_before = program_test_ctx 36 | .get_token_account(receiving_account_address) 37 | .await 38 | .unwrap(); 39 | let custody_token_account_before = program_test_ctx 40 | .get_token_account(custody_token_account_pda) 41 | .await 42 | .unwrap(); 43 | 44 | utils::create_and_execute_perpetuals_ix( 45 | program_test_ctx, 46 | perpetuals::accounts::ClosePosition { 47 | owner: owner.pubkey(), 48 | receiving_account: receiving_account_address, 49 | transfer_authority: transfer_authority_pda, 50 | perpetuals: perpetuals_pda, 51 | pool: *pool_pda, 52 | position: *position_pda, 53 | custody: custody_pda, 54 | custody_oracle_account: custody_oracle_account_address, 55 | custody_token_account: custody_token_account_pda, 56 | token_program: anchor_spl::token::ID, 57 | } 58 | .to_account_metas(None), 59 | perpetuals::instruction::ClosePosition { params }, 60 | Some(&payer.pubkey()), 61 | &[owner, payer], 62 | ) 63 | .await?; 64 | 65 | // ==== THEN ============================================================== 66 | // Check the balance change 67 | { 68 | let owner_receiving_account_after = program_test_ctx 69 | .get_token_account(receiving_account_address) 70 | .await 71 | .unwrap(); 72 | let custody_token_account_after = program_test_ctx 73 | .get_token_account(custody_token_account_pda) 74 | .await 75 | .unwrap(); 76 | 77 | assert!(owner_receiving_account_after.amount > owner_receiving_account_before.amount); 78 | assert!(custody_token_account_after.amount < custody_token_account_before.amount); 79 | } 80 | 81 | Ok(()) 82 | } 83 | -------------------------------------------------------------------------------- /ui/src/components/LeverageSlider.tsx: -------------------------------------------------------------------------------- 1 | import CloseIcon from "@carbon/icons-react/lib/Close"; 2 | import * as Slider from "@radix-ui/react-slider"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | function clamp(num: number, min: number, max: number) { 6 | return Math.min(max, Math.max(num, min)); 7 | } 8 | 9 | interface Props { 10 | className?: string; 11 | value: number; 12 | minLeverage: number; 13 | maxLeverage: number; 14 | onChange?(value: number): void; 15 | } 16 | 17 | export function LeverageSlider(props: Props) { 18 | return ( 19 |
27 |
Leverage
28 |
1x
29 |
30 | props.onChange?.(values[0] || 1)} 36 | > 37 | 38 | 39 | 55 | 56 | 57 |
58 |
59 | {props.maxLeverage}x 60 |
61 |
73 | { 78 | const text = e.currentTarget.value; 79 | const number = parseFloat(text); 80 | props.onChange?.( 81 | Number.isNaN(number) ? 0 : clamp(number, 1, props.maxLeverage) 82 | ); 83 | }} 84 | onBlur={(e) => { 85 | const text = e.currentTarget.value; 86 | const number = parseFloat(text); 87 | props.onChange?.( 88 | Number.isNaN(number) ? 1 : clamp(number, 1, props.maxLeverage) 89 | ); 90 | }} 91 | /> 92 | 103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /ui/src/lib/CustodyAccount.tsx: -------------------------------------------------------------------------------- 1 | import { PublicKey } from "@solana/web3.js"; 2 | import { tokenAddressToToken, TokenE } from "./Token"; 3 | import { 4 | Assets, 5 | BorrowRateParams, 6 | BorrowRateState, 7 | Custody, 8 | Fees, 9 | OracleParams, 10 | PositionStats, 11 | PricingParams, 12 | Stats, 13 | TradeStats, 14 | Permissions, 15 | PriceStat, 16 | PriceStats, 17 | } from "./types"; 18 | 19 | export class CustodyAccount { 20 | public pool: PublicKey; 21 | public mint: PublicKey; 22 | public tokenAccount: PublicKey; 23 | public decimals: number; 24 | public isStable: boolean; 25 | public oracle: OracleParams; 26 | public pricing: PricingParams; 27 | public permissions: Permissions; 28 | public fees: Fees; 29 | public borrowRate: BorrowRateParams; 30 | 31 | // dynamic variable; 32 | public assets: Assets; 33 | public collectedFees: Stats; 34 | public volumeStats: Stats; 35 | public tradeStats: TradeStats; 36 | public longPositions: PositionStats; 37 | public shortPositions: PositionStats; 38 | public borrowRateState: BorrowRateState; 39 | 40 | // bumps for address validatio; 41 | public bump: number; 42 | public tokenAccountBump: number; 43 | 44 | public address: PublicKey; 45 | 46 | constructor(custody: Custody, address: PublicKey) { 47 | this.pool = custody.pool; 48 | this.mint = custody.mint; 49 | this.tokenAccount = custody.tokenAccount; 50 | this.decimals = custody.decimals; 51 | this.isStable = custody.isStable; 52 | this.oracle = custody.oracle; 53 | this.pricing = custody.pricing; 54 | this.permissions = custody.permissions; 55 | this.fees = custody.fees; 56 | this.borrowRate = custody.borrowRate; 57 | 58 | console.log("custody assets", custody.assets); 59 | this.assets = custody.assets; 60 | this.collectedFees = custody.collectedFees; 61 | this.volumeStats = custody.volumeStats; 62 | this.tradeStats = custody.tradeStats; 63 | this.longPositions = custody.longPositions; 64 | this.shortPositions = custody.shortPositions; 65 | this.borrowRateState = custody.borrowRateState; 66 | 67 | this.bump = custody.bump; 68 | this.tokenAccountBump = custody.tokenAccountBump; 69 | 70 | this.address = address; 71 | } 72 | 73 | getTokenE(): TokenE { 74 | return tokenAddressToToken(this.mint.toString())!; 75 | } 76 | 77 | getCustodyLiquidity(stats: PriceStats): number { 78 | if (Object.values(stats).length === 0) { 79 | throw new Error("stats not loaded"); 80 | } 81 | try { 82 | return ( 83 | (stats[this.getTokenE()].currentPrice * 84 | Number(this.assets.owned.sub(this.assets.locked))) / 85 | 10 ** this.decimals 86 | ); 87 | } catch (e) { 88 | console.log("stats error", e, stats); 89 | throw e; 90 | } 91 | } 92 | 93 | getCurrentWeight(stats: PriceStat, liquidity: number): number { 94 | let weight = 95 | (100 * 96 | stats.currentPrice * 97 | (Number(this.assets.owned) / 10 ** this.decimals)) / 98 | liquidity; 99 | 100 | return weight ? weight : 0; 101 | } 102 | 103 | getAmount(): number { 104 | return Number(this.assets.owned) / 10 ** this.decimals; 105 | } 106 | 107 | getAddFee(): number { 108 | return Number(this.fees.addLiquidity) / 100; 109 | } 110 | getUtilizationRate(): number { 111 | return Number(this.assets.owned) != 0 112 | ? 100 * Number(this.assets.locked.div(this.assets.owned)) 113 | : 0; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/instructions/test_add_pool.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::utils::{self, pda}, 3 | anchor_lang::{prelude::AccountMeta, ToAccountMetas}, 4 | perpetuals::{ 5 | instructions::AddPoolParams, 6 | state::{multisig::Multisig, perpetuals::Perpetuals, pool::Pool}, 7 | }, 8 | solana_program_test::{BanksClientError, ProgramTestContext}, 9 | solana_sdk::signer::{keypair::Keypair, Signer}, 10 | std::str::FromStr, 11 | }; 12 | 13 | pub async fn test_add_pool( 14 | program_test_ctx: &mut ProgramTestContext, 15 | // Admin must be a part of the multisig 16 | admin: &Keypair, 17 | payer: &Keypair, 18 | pool_name: &str, 19 | multisig_signers: &[&Keypair], 20 | ) -> std::result::Result< 21 | ( 22 | anchor_lang::prelude::Pubkey, 23 | u8, 24 | anchor_lang::prelude::Pubkey, 25 | u8, 26 | ), 27 | BanksClientError, 28 | > { 29 | // ==== WHEN ============================================================== 30 | let multisig_pda = pda::get_multisig_pda().0; 31 | let transfer_authority_pda = pda::get_transfer_authority_pda().0; 32 | let perpetuals_pda = pda::get_perpetuals_pda().0; 33 | let (pool_pda, pool_bump) = pda::get_pool_pda(String::from_str(pool_name).unwrap()); 34 | let (lp_token_mint_pda, lp_token_mint_bump) = pda::get_lp_token_mint_pda(&pool_pda); 35 | 36 | let multisig_account = utils::get_account::(program_test_ctx, multisig_pda).await; 37 | 38 | // One Tx per multisig signer 39 | for i in 0..multisig_account.min_signatures { 40 | let signer: &Keypair = multisig_signers[i as usize]; 41 | 42 | let accounts_meta = { 43 | let accounts = perpetuals::accounts::AddPool { 44 | admin: admin.pubkey(), 45 | multisig: multisig_pda, 46 | transfer_authority: transfer_authority_pda, 47 | perpetuals: perpetuals_pda, 48 | pool: pool_pda, 49 | lp_token_mint: lp_token_mint_pda, 50 | system_program: anchor_lang::system_program::ID, 51 | token_program: anchor_spl::token::ID, 52 | rent: solana_program::sysvar::rent::ID, 53 | }; 54 | 55 | let mut accounts_meta = accounts.to_account_metas(None); 56 | 57 | accounts_meta.push(AccountMeta { 58 | pubkey: signer.pubkey(), 59 | is_signer: true, 60 | is_writable: false, 61 | }); 62 | 63 | accounts_meta 64 | }; 65 | 66 | utils::create_and_execute_perpetuals_ix( 67 | program_test_ctx, 68 | accounts_meta, 69 | perpetuals::instruction::AddPool { 70 | params: AddPoolParams { 71 | name: String::from_str(pool_name).unwrap(), 72 | }, 73 | }, 74 | Some(&payer.pubkey()), 75 | &[admin, payer, signer], 76 | ) 77 | .await?; 78 | } 79 | 80 | // ==== THEN ============================================================== 81 | let pool_account = utils::get_account::(program_test_ctx, pool_pda).await; 82 | 83 | assert_eq!(pool_account.name.as_str(), pool_name); 84 | assert_eq!(pool_account.bump, pool_bump); 85 | assert_eq!(pool_account.lp_token_bump, lp_token_mint_bump); 86 | 87 | let perpetuals_account = 88 | utils::get_account::(program_test_ctx, perpetuals_pda).await; 89 | 90 | assert_eq!(*perpetuals_account.pools.last().unwrap(), pool_pda); 91 | assert_eq!( 92 | perpetuals_account.inception_time, 93 | pool_account.inception_time 94 | ); 95 | 96 | Ok((pool_pda, pool_bump, lp_token_mint_pda, lp_token_mint_bump)) 97 | } 98 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_entry_price_and_fee.rs: -------------------------------------------------------------------------------- 1 | //! GetEntryPriceAndFee instruction handler 2 | 3 | use { 4 | crate::state::{ 5 | custody::Custody, 6 | oracle::OraclePrice, 7 | perpetuals::{NewPositionPricesAndFee, Perpetuals}, 8 | pool::Pool, 9 | position::{Position, Side}, 10 | }, 11 | anchor_lang::prelude::*, 12 | solana_program::program_error::ProgramError, 13 | }; 14 | 15 | #[derive(Accounts)] 16 | pub struct GetEntryPriceAndFee<'info> { 17 | #[account( 18 | seeds = [b"perpetuals"], 19 | bump = perpetuals.perpetuals_bump 20 | )] 21 | pub perpetuals: Box>, 22 | 23 | #[account( 24 | seeds = [b"pool", 25 | pool.name.as_bytes()], 26 | bump = pool.bump 27 | )] 28 | pub pool: Box>, 29 | 30 | #[account( 31 | seeds = [b"custody", 32 | pool.key().as_ref(), 33 | custody.mint.as_ref()], 34 | bump = custody.bump 35 | )] 36 | pub custody: Box>, 37 | 38 | /// CHECK: oracle account for the collateral token 39 | #[account( 40 | constraint = custody_oracle_account.key() == custody.oracle.oracle_account 41 | )] 42 | pub custody_oracle_account: AccountInfo<'info>, 43 | } 44 | 45 | #[derive(AnchorSerialize, AnchorDeserialize)] 46 | pub struct GetEntryPriceAndFeeParams { 47 | collateral: u64, 48 | size: u64, 49 | side: Side, 50 | } 51 | 52 | pub fn get_entry_price_and_fee( 53 | ctx: Context, 54 | params: &GetEntryPriceAndFeeParams, 55 | ) -> Result { 56 | // validate inputs 57 | if params.collateral == 0 || params.size == 0 || params.side == Side::None { 58 | return Err(ProgramError::InvalidArgument.into()); 59 | } 60 | let pool = &ctx.accounts.pool; 61 | let custody = ctx.accounts.custody.as_mut(); 62 | 63 | // compute position price 64 | let curtime = ctx.accounts.perpetuals.get_time()?; 65 | 66 | let token_price = OraclePrice::new_from_oracle( 67 | custody.oracle.oracle_type, 68 | &ctx.accounts.custody_oracle_account.to_account_info(), 69 | custody.oracle.max_price_error, 70 | custody.oracle.max_price_age_sec, 71 | curtime, 72 | false, 73 | )?; 74 | 75 | let token_ema_price = OraclePrice::new_from_oracle( 76 | custody.oracle.oracle_type, 77 | &ctx.accounts.custody_oracle_account.to_account_info(), 78 | custody.oracle.max_price_error, 79 | custody.oracle.max_price_age_sec, 80 | curtime, 81 | custody.pricing.use_ema, 82 | )?; 83 | 84 | let min_price = if token_price < token_ema_price { 85 | token_price 86 | } else { 87 | token_ema_price 88 | }; 89 | 90 | let entry_price = pool.get_entry_price(&token_price, &token_ema_price, params.side, custody)?; 91 | 92 | let size_usd = min_price.get_asset_amount_usd(params.size, custody.decimals)?; 93 | let collateral_usd = min_price.get_asset_amount_usd(params.collateral, custody.decimals)?; 94 | 95 | let position = Position { 96 | side: params.side, 97 | price: entry_price, 98 | size_usd, 99 | collateral_usd, 100 | cumulative_interest_snapshot: custody.get_cumulative_interest(curtime)?, 101 | ..Position::default() 102 | }; 103 | 104 | let liquidation_price = 105 | pool.get_liquidation_price(&position, &token_ema_price, custody, curtime)?; 106 | 107 | let fee = pool.get_entry_fee(params.size, custody)?; 108 | 109 | Ok(NewPositionPricesAndFee { 110 | entry_price, 111 | liquidation_price, 112 | fee, 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/test_init.rs: -------------------------------------------------------------------------------- 1 | //! TestInit instruction handler 2 | 3 | use { 4 | crate::{ 5 | error::PerpetualsError, 6 | state::{multisig::Multisig, perpetuals::Perpetuals}, 7 | }, 8 | anchor_lang::prelude::*, 9 | anchor_spl::token::Token, 10 | solana_program::program_error::ProgramError, 11 | }; 12 | 13 | #[derive(Accounts)] 14 | pub struct TestInit<'info> { 15 | #[account(mut)] 16 | pub upgrade_authority: Signer<'info>, 17 | 18 | #[account( 19 | init, 20 | payer = upgrade_authority, 21 | space = Multisig::LEN, 22 | seeds = [b"multisig"], 23 | bump 24 | )] 25 | pub multisig: AccountLoader<'info, Multisig>, 26 | 27 | /// CHECK: empty PDA, will be set as authority for token accounts 28 | #[account( 29 | init, 30 | payer = upgrade_authority, 31 | space = 0, 32 | seeds = [b"transfer_authority"], 33 | bump 34 | )] 35 | pub transfer_authority: AccountInfo<'info>, 36 | 37 | #[account( 38 | init, 39 | payer = upgrade_authority, 40 | space = Perpetuals::LEN, 41 | seeds = [b"perpetuals"], 42 | bump 43 | )] 44 | pub perpetuals: Box>, 45 | 46 | system_program: Program<'info, System>, 47 | token_program: Program<'info, Token>, 48 | // remaining accounts: 1 to Multisig::MAX_SIGNERS admin signers (read-only, unsigned) 49 | } 50 | 51 | #[derive(AnchorSerialize, AnchorDeserialize)] 52 | pub struct TestInitParams { 53 | pub min_signatures: u8, 54 | pub allow_swap: bool, 55 | pub allow_add_liquidity: bool, 56 | pub allow_remove_liquidity: bool, 57 | pub allow_open_position: bool, 58 | pub allow_close_position: bool, 59 | pub allow_pnl_withdrawal: bool, 60 | pub allow_collateral_withdrawal: bool, 61 | pub allow_size_change: bool, 62 | } 63 | 64 | pub fn test_init(ctx: Context, params: &TestInitParams) -> Result<()> { 65 | if !cfg!(feature = "test") { 66 | return err!(PerpetualsError::InvalidEnvironment); 67 | } 68 | 69 | // initialize multisig, this will fail if account is already initialized 70 | let mut multisig = ctx.accounts.multisig.load_init()?; 71 | 72 | multisig.set_signers(ctx.remaining_accounts, params.min_signatures)?; 73 | 74 | // record multisig PDA bump 75 | multisig.bump = *ctx 76 | .bumps 77 | .get("multisig") 78 | .ok_or(ProgramError::InvalidSeeds)?; 79 | 80 | // record perpetuals 81 | let perpetuals = ctx.accounts.perpetuals.as_mut(); 82 | perpetuals.permissions.allow_swap = params.allow_swap; 83 | perpetuals.permissions.allow_add_liquidity = params.allow_add_liquidity; 84 | perpetuals.permissions.allow_remove_liquidity = params.allow_remove_liquidity; 85 | perpetuals.permissions.allow_open_position = params.allow_open_position; 86 | perpetuals.permissions.allow_close_position = params.allow_close_position; 87 | perpetuals.permissions.allow_pnl_withdrawal = params.allow_pnl_withdrawal; 88 | perpetuals.permissions.allow_collateral_withdrawal = params.allow_collateral_withdrawal; 89 | perpetuals.permissions.allow_size_change = params.allow_size_change; 90 | perpetuals.transfer_authority_bump = *ctx 91 | .bumps 92 | .get("transfer_authority") 93 | .ok_or(ProgramError::InvalidSeeds)?; 94 | perpetuals.perpetuals_bump = *ctx 95 | .bumps 96 | .get("perpetuals") 97 | .ok_or(ProgramError::InvalidSeeds)?; 98 | perpetuals.inception_time = if cfg!(feature = "test") { 99 | 0 100 | } else { 101 | perpetuals.get_time()? 102 | }; 103 | 104 | if !perpetuals.validate() { 105 | return err!(PerpetualsError::InvalidPerpetualsConfig); 106 | } 107 | 108 | Ok(()) 109 | } 110 | -------------------------------------------------------------------------------- /ui/src/components/AirdropButton.tsx: -------------------------------------------------------------------------------- 1 | import { getAllUserData } from "@/hooks/storeHelpers/fetchUserData"; 2 | import { CustodyAccount } from "@/lib/CustodyAccount"; 3 | import { getTokenLabel, TokenE } from "@/lib/Token"; 4 | import { useGlobalStore } from "@/stores/store"; 5 | import { DEFAULT_PERPS_USER } from "@/utils/constants"; 6 | import { checkIfAccountExists } from "@/utils/retrieveData"; 7 | import { 8 | createAssociatedTokenAccountInstruction, 9 | createMintToCheckedInstruction, 10 | getAssociatedTokenAddress, 11 | } from "@solana/spl-token"; 12 | import { useConnection, useWallet } from "@solana/wallet-adapter-react"; 13 | import { Transaction } from "@solana/web3.js"; 14 | import { SolidButton } from "./SolidButton"; 15 | 16 | interface Props { 17 | className?: string; 18 | custody: CustodyAccount; 19 | } 20 | export default function AirdropButton(props: Props) { 21 | const { publicKey, signTransaction } = useWallet(); 22 | const { connection } = useConnection(); 23 | 24 | let mint = props.custody.mint; 25 | 26 | const poolData = useGlobalStore((state) => state.poolData); 27 | const setUserData = useGlobalStore((state) => state.setUserData); 28 | 29 | async function handleAirdrop() { 30 | if (!publicKey) return; 31 | if (mint.toString() === "So11111111111111111111111111111111111111112") { 32 | await connection.requestAirdrop(publicKey!, 1 * 10 ** 9); 33 | } else { 34 | let transaction = new Transaction(); 35 | 36 | let associatedAccount = await getAssociatedTokenAddress(mint, publicKey); 37 | 38 | if (!(await checkIfAccountExists(associatedAccount, connection))) { 39 | transaction = transaction.add( 40 | createAssociatedTokenAccountInstruction( 41 | publicKey, 42 | associatedAccount, 43 | publicKey, 44 | mint 45 | ) 46 | ); 47 | } 48 | 49 | transaction = transaction.add( 50 | createMintToCheckedInstruction( 51 | mint, // mint 52 | associatedAccount, // ata 53 | DEFAULT_PERPS_USER.publicKey, // payer 54 | 100 * 10 ** 9, // amount 55 | 9 // decimals 56 | ) 57 | ); 58 | 59 | transaction.feePayer = publicKey; 60 | transaction.recentBlockhash = ( 61 | await connection.getRecentBlockhash("finalized") 62 | ).blockhash; 63 | 64 | transaction.sign(DEFAULT_PERPS_USER); 65 | 66 | transaction = await signTransaction(transaction); 67 | const rawTransaction = transaction.serialize(); 68 | let signature = await connection.sendRawTransaction(rawTransaction, { 69 | skipPreflight: false, 70 | }); 71 | console.log( 72 | `sent raw, waiting : https://explorer.solana.com/tx/${signature}?cluster=devnet` 73 | ); 74 | await connection.confirmTransaction(signature, "confirmed"); 75 | console.log( 76 | `sent tx!!! :https://explorer.solana.com/tx/${signature}?cluster=devnet` 77 | ); 78 | } 79 | 80 | const userData = await getAllUserData(connection, publicKey!, poolData); 81 | setUserData(userData); 82 | } 83 | 84 | if (props.custody.getTokenE() === TokenE.USDC) { 85 | return ( 86 | 91 | 92 | Airdrop {'"'} 93 | {getTokenLabel(props.custody.getTokenE())} 94 | {'"'} 95 | 96 | 97 | ); 98 | } 99 | 100 | return ( 101 | 105 | Airdrop {'"'} 106 | {getTokenLabel(props.custody.getTokenE())} 107 | {'"'} 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_remove_liquidity_amount_and_fee.rs: -------------------------------------------------------------------------------- 1 | //! GetRemoveLiquidityAmountAndFee instruction handler 2 | 3 | use { 4 | crate::{ 5 | math, 6 | state::{ 7 | custody::Custody, 8 | oracle::OraclePrice, 9 | perpetuals::{AmountAndFee, Perpetuals}, 10 | pool::{AumCalcMode, Pool}, 11 | }, 12 | }, 13 | anchor_lang::prelude::*, 14 | anchor_spl::token::Mint, 15 | solana_program::program_error::ProgramError, 16 | }; 17 | 18 | #[derive(Accounts)] 19 | pub struct GetRemoveLiquidityAmountAndFee<'info> { 20 | #[account( 21 | seeds = [b"perpetuals"], 22 | bump = perpetuals.perpetuals_bump 23 | )] 24 | pub perpetuals: Box>, 25 | 26 | #[account( 27 | seeds = [b"pool", 28 | pool.name.as_bytes()], 29 | bump = pool.bump 30 | )] 31 | pub pool: Box>, 32 | 33 | #[account( 34 | seeds = [b"custody", 35 | pool.key().as_ref(), 36 | custody.mint.as_ref()], 37 | bump = custody.bump 38 | )] 39 | pub custody: Box>, 40 | 41 | /// CHECK: oracle account for the collateral token 42 | #[account( 43 | constraint = custody_oracle_account.key() == custody.oracle.oracle_account 44 | )] 45 | pub custody_oracle_account: AccountInfo<'info>, 46 | 47 | #[account( 48 | seeds = [b"lp_token_mint", 49 | pool.key().as_ref()], 50 | bump = pool.lp_token_bump 51 | )] 52 | pub lp_token_mint: Box>, 53 | } 54 | 55 | #[derive(AnchorSerialize, AnchorDeserialize)] 56 | pub struct GetRemoveLiquidityAmountAndFeeParams { 57 | lp_amount_in: u64, 58 | } 59 | 60 | pub fn get_remove_liquidity_amount_and_fee( 61 | ctx: Context, 62 | params: &GetRemoveLiquidityAmountAndFeeParams, 63 | ) -> Result { 64 | // validate inputs 65 | if params.lp_amount_in == 0 { 66 | return Err(ProgramError::InvalidArgument.into()); 67 | } 68 | let pool = &ctx.accounts.pool; 69 | let custody = ctx.accounts.custody.as_mut(); 70 | let token_id = pool.get_token_id(&custody.key())?; 71 | 72 | // compute position price 73 | let curtime = ctx.accounts.perpetuals.get_time()?; 74 | 75 | let token_price = OraclePrice::new_from_oracle( 76 | custody.oracle.oracle_type, 77 | &ctx.accounts.custody_oracle_account.to_account_info(), 78 | custody.oracle.max_price_error, 79 | custody.oracle.max_price_age_sec, 80 | curtime, 81 | false, 82 | )?; 83 | 84 | let token_ema_price = OraclePrice::new_from_oracle( 85 | custody.oracle.oracle_type, 86 | &ctx.accounts.custody_oracle_account.to_account_info(), 87 | custody.oracle.max_price_error, 88 | custody.oracle.max_price_age_sec, 89 | curtime, 90 | custody.pricing.use_ema, 91 | )?; 92 | 93 | let pool_amount_usd = 94 | pool.get_assets_under_management_usd(AumCalcMode::Min, ctx.remaining_accounts, curtime)?; 95 | 96 | let remove_amount_usd = math::checked_as_u64(math::checked_div( 97 | math::checked_mul(pool_amount_usd, params.lp_amount_in as u128)?, 98 | ctx.accounts.lp_token_mint.supply as u128, 99 | )?)?; 100 | 101 | let max_price = if token_price > token_ema_price { 102 | token_price 103 | } else { 104 | token_ema_price 105 | }; 106 | let remove_amount = max_price.get_token_amount(remove_amount_usd, custody.decimals)?; 107 | 108 | let fee_amount = 109 | pool.get_remove_liquidity_fee(token_id, remove_amount, custody, &token_price)?; 110 | 111 | let transfer_amount = math::checked_sub(remove_amount, fee_amount)?; 112 | 113 | Ok(AmountAndFee { 114 | amount: transfer_amount, 115 | fee: fee_amount, 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/instructions/test_init.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::utils::{self, pda}, 3 | anchor_lang::{prelude::AccountMeta, ToAccountMetas}, 4 | perpetuals::{ 5 | instructions::InitParams, 6 | state::{multisig::Multisig, perpetuals::Perpetuals}, 7 | }, 8 | solana_program_test::{BanksClientError, ProgramTestContext}, 9 | solana_sdk::signer::{keypair::Keypair, Signer}, 10 | }; 11 | 12 | pub async fn test_init( 13 | program_test_ctx: &mut ProgramTestContext, 14 | upgrade_authority: &Keypair, 15 | params: InitParams, 16 | multisig_signers: &[&Keypair], 17 | ) -> std::result::Result<(), BanksClientError> { 18 | // ==== WHEN ============================================================== 19 | let perpetuals_program_data_pda = pda::get_program_data_pda().0; 20 | let (multisig_pda, multisig_bump) = pda::get_multisig_pda(); 21 | let (transfer_authority_pda, transfer_authority_bump) = pda::get_transfer_authority_pda(); 22 | let (perpetuals_pda, perpetuals_bump) = pda::get_perpetuals_pda(); 23 | 24 | let accounts_meta = { 25 | let accounts = perpetuals::accounts::Init { 26 | upgrade_authority: upgrade_authority.pubkey(), 27 | multisig: multisig_pda, 28 | transfer_authority: transfer_authority_pda, 29 | perpetuals: perpetuals_pda, 30 | perpetuals_program: perpetuals::ID, 31 | perpetuals_program_data: perpetuals_program_data_pda, 32 | system_program: anchor_lang::system_program::ID, 33 | token_program: anchor_spl::token::ID, 34 | }; 35 | 36 | let mut accounts_meta = accounts.to_account_metas(None); 37 | 38 | for signer in multisig_signers { 39 | accounts_meta.push(AccountMeta { 40 | pubkey: signer.pubkey(), 41 | is_signer: true, 42 | is_writable: false, 43 | }); 44 | } 45 | 46 | accounts_meta 47 | }; 48 | 49 | utils::create_and_execute_perpetuals_ix( 50 | program_test_ctx, 51 | accounts_meta, 52 | perpetuals::instruction::Init { params }, 53 | Some(&upgrade_authority.pubkey()), 54 | &[&[upgrade_authority], multisig_signers].concat(), 55 | ) 56 | .await?; 57 | 58 | // ==== THEN ============================================================== 59 | let perpetuals_account = 60 | utils::get_account::(program_test_ctx, perpetuals_pda).await; 61 | 62 | // Assert permissions 63 | { 64 | let p = perpetuals_account.permissions; 65 | 66 | assert_eq!(p.allow_swap, params.allow_swap); 67 | assert_eq!(p.allow_add_liquidity, params.allow_add_liquidity); 68 | assert_eq!(p.allow_remove_liquidity, params.allow_remove_liquidity); 69 | assert_eq!(p.allow_open_position, params.allow_open_position); 70 | assert_eq!(p.allow_close_position, params.allow_close_position); 71 | assert_eq!(p.allow_pnl_withdrawal, params.allow_pnl_withdrawal); 72 | assert_eq!( 73 | p.allow_collateral_withdrawal, 74 | params.allow_collateral_withdrawal 75 | ); 76 | assert_eq!(p.allow_size_change, params.allow_size_change); 77 | } 78 | 79 | assert_eq!( 80 | perpetuals_account.transfer_authority_bump, 81 | transfer_authority_bump 82 | ); 83 | assert_eq!(perpetuals_account.perpetuals_bump, perpetuals_bump); 84 | 85 | let multisig_account = utils::get_account::(program_test_ctx, multisig_pda).await; 86 | 87 | // Assert multisig 88 | { 89 | assert_eq!(multisig_account.bump, multisig_bump); 90 | assert_eq!(multisig_account.min_signatures, params.min_signatures); 91 | 92 | // Check signers 93 | { 94 | for (i, signer) in multisig_signers.iter().enumerate() { 95 | assert_eq!(multisig_account.signers[i], signer.pubkey()); 96 | } 97 | } 98 | } 99 | 100 | Ok(()) 101 | } 102 | -------------------------------------------------------------------------------- /ui/src/components/PoolSelector.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingSpinner } from "@/components/Icons/LoadingSpinner"; 2 | import { PoolTokens } from "@/components/PoolTokens"; 3 | import { PoolAccount } from "@/lib/PoolAccount"; 4 | import { useGlobalStore } from "@/stores/store"; 5 | import CheckmarkIcon from "@carbon/icons-react/lib/Checkmark"; 6 | import ChevronDownIcon from "@carbon/icons-react/lib/ChevronDown"; 7 | import * as Dropdown from "@radix-ui/react-dropdown-menu"; 8 | import { useState } from "react"; 9 | import { twMerge } from "tailwind-merge"; 10 | 11 | interface Props { 12 | className?: string; 13 | pool: PoolAccount; 14 | onSelectPool?(pool: PoolAccount): void; 15 | } 16 | 17 | export function PoolSelector(props: Props) { 18 | const [open, setOpen] = useState(false); 19 | 20 | const poolData = useGlobalStore((state) => state.poolData); 21 | 22 | if (!props.pool) { 23 | return ; 24 | } 25 | 26 | return ( 27 | 28 | 44 | 45 |
46 | {props.pool.name} 47 |
48 |
61 | 62 |
63 |
64 | 65 | 69 | 70 | {Object.values(poolData).map((pool) => ( 71 | props.onSelectPool?.(pool)} 88 | > 89 | 90 |
91 |
92 | {pool.name} 93 |
94 |
95 | {pool.getTokenList().slice(0, 3).join(", ")} 96 | {pool.getTokenList().length > 3 97 | ? ` +${pool.getTokenList().length - 3} more` 98 | : ""} 99 |
100 |
101 | {pool.address === props.pool.address ? ( 102 | 103 | ) : ( 104 |
105 | )} 106 | 107 | ))} 108 | 109 | 110 | 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/init.rs: -------------------------------------------------------------------------------- 1 | //! Init instruction handler 2 | 3 | use { 4 | crate::{ 5 | error::PerpetualsError, 6 | state::{multisig::Multisig, perpetuals::Perpetuals}, 7 | }, 8 | anchor_lang::prelude::*, 9 | anchor_spl::token::Token, 10 | solana_program::program_error::ProgramError, 11 | }; 12 | 13 | #[derive(Accounts)] 14 | pub struct Init<'info> { 15 | #[account(mut)] 16 | pub upgrade_authority: Signer<'info>, 17 | 18 | #[account( 19 | init, 20 | payer = upgrade_authority, 21 | space = Multisig::LEN, 22 | seeds = [b"multisig"], 23 | bump 24 | )] 25 | pub multisig: AccountLoader<'info, Multisig>, 26 | 27 | /// CHECK: empty PDA, will be set as authority for token accounts 28 | #[account( 29 | init, 30 | payer = upgrade_authority, 31 | space = 0, 32 | seeds = [b"transfer_authority"], 33 | bump 34 | )] 35 | pub transfer_authority: AccountInfo<'info>, 36 | 37 | #[account( 38 | init, 39 | payer = upgrade_authority, 40 | space = Perpetuals::LEN, 41 | seeds = [b"perpetuals"], 42 | bump 43 | )] 44 | pub perpetuals: Box>, 45 | 46 | #[account( 47 | constraint = perpetuals_program.programdata_address()? == Some(perpetuals_program_data.key()) 48 | )] 49 | pub perpetuals_program: Program<'info, Perpetuals>, 50 | 51 | #[account( 52 | constraint = perpetuals_program_data.upgrade_authority_address == Some(upgrade_authority.key()) 53 | )] 54 | pub perpetuals_program_data: Account<'info, ProgramData>, 55 | 56 | system_program: Program<'info, System>, 57 | token_program: Program<'info, Token>, 58 | // remaining accounts: 1 to Multisig::MAX_SIGNERS admin signers (read-only, unsigned) 59 | } 60 | 61 | #[derive(AnchorSerialize, AnchorDeserialize, Copy, Clone)] 62 | pub struct InitParams { 63 | pub min_signatures: u8, 64 | pub allow_swap: bool, 65 | pub allow_add_liquidity: bool, 66 | pub allow_remove_liquidity: bool, 67 | pub allow_open_position: bool, 68 | pub allow_close_position: bool, 69 | pub allow_pnl_withdrawal: bool, 70 | pub allow_collateral_withdrawal: bool, 71 | pub allow_size_change: bool, 72 | } 73 | 74 | pub fn init(ctx: Context, params: &InitParams) -> Result<()> { 75 | // initialize multisig, this will fail if account is already initialized 76 | let mut multisig = ctx.accounts.multisig.load_init()?; 77 | 78 | multisig.set_signers(ctx.remaining_accounts, params.min_signatures)?; 79 | 80 | // record multisig PDA bump 81 | multisig.bump = *ctx 82 | .bumps 83 | .get("multisig") 84 | .ok_or(ProgramError::InvalidSeeds)?; 85 | 86 | // record perpetuals 87 | let perpetuals = ctx.accounts.perpetuals.as_mut(); 88 | perpetuals.permissions.allow_swap = params.allow_swap; 89 | perpetuals.permissions.allow_add_liquidity = params.allow_add_liquidity; 90 | perpetuals.permissions.allow_remove_liquidity = params.allow_remove_liquidity; 91 | perpetuals.permissions.allow_open_position = params.allow_open_position; 92 | perpetuals.permissions.allow_close_position = params.allow_close_position; 93 | perpetuals.permissions.allow_pnl_withdrawal = params.allow_pnl_withdrawal; 94 | perpetuals.permissions.allow_collateral_withdrawal = params.allow_collateral_withdrawal; 95 | perpetuals.permissions.allow_size_change = params.allow_size_change; 96 | perpetuals.transfer_authority_bump = *ctx 97 | .bumps 98 | .get("transfer_authority") 99 | .ok_or(ProgramError::InvalidSeeds)?; 100 | perpetuals.perpetuals_bump = *ctx 101 | .bumps 102 | .get("perpetuals") 103 | .ok_or(ProgramError::InvalidSeeds)?; 104 | perpetuals.inception_time = perpetuals.get_time()?; 105 | 106 | if !perpetuals.validate() { 107 | return err!(PerpetualsError::InvalidPerpetualsConfig); 108 | } 109 | 110 | Ok(()) 111 | } 112 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_add_liquidity_amount_and_fee.rs: -------------------------------------------------------------------------------- 1 | //! GetAddLiquidityAmountAndFee instruction handler 2 | 3 | use { 4 | crate::{ 5 | math, 6 | state::{ 7 | custody::Custody, 8 | oracle::OraclePrice, 9 | perpetuals::{AmountAndFee, Perpetuals}, 10 | pool::{AumCalcMode, Pool}, 11 | }, 12 | }, 13 | anchor_lang::prelude::*, 14 | anchor_spl::token::Mint, 15 | solana_program::program_error::ProgramError, 16 | }; 17 | 18 | #[derive(Accounts)] 19 | pub struct GetAddLiquidityAmountAndFee<'info> { 20 | #[account( 21 | seeds = [b"perpetuals"], 22 | bump = perpetuals.perpetuals_bump 23 | )] 24 | pub perpetuals: Box>, 25 | 26 | #[account( 27 | seeds = [b"pool", 28 | pool.name.as_bytes()], 29 | bump = pool.bump 30 | )] 31 | pub pool: Box>, 32 | 33 | #[account( 34 | seeds = [b"custody", 35 | pool.key().as_ref(), 36 | custody.mint.as_ref()], 37 | bump = custody.bump 38 | )] 39 | pub custody: Box>, 40 | 41 | /// CHECK: oracle account for the collateral token 42 | #[account( 43 | constraint = custody_oracle_account.key() == custody.oracle.oracle_account 44 | )] 45 | pub custody_oracle_account: AccountInfo<'info>, 46 | 47 | #[account( 48 | seeds = [b"lp_token_mint", 49 | pool.key().as_ref()], 50 | bump = pool.lp_token_bump 51 | )] 52 | pub lp_token_mint: Box>, 53 | } 54 | 55 | #[derive(AnchorSerialize, AnchorDeserialize)] 56 | pub struct GetAddLiquidityAmountAndFeeParams { 57 | amount_in: u64, 58 | } 59 | 60 | pub fn get_add_liquidity_amount_and_fee( 61 | ctx: Context, 62 | params: &GetAddLiquidityAmountAndFeeParams, 63 | ) -> Result { 64 | // validate inputs 65 | if params.amount_in == 0 { 66 | return Err(ProgramError::InvalidArgument.into()); 67 | } 68 | let pool = &ctx.accounts.pool; 69 | let custody = ctx.accounts.custody.as_mut(); 70 | let token_id = pool.get_token_id(&custody.key())?; 71 | 72 | // compute position price 73 | let curtime = ctx.accounts.perpetuals.get_time()?; 74 | 75 | let token_price = OraclePrice::new_from_oracle( 76 | custody.oracle.oracle_type, 77 | &ctx.accounts.custody_oracle_account.to_account_info(), 78 | custody.oracle.max_price_error, 79 | custody.oracle.max_price_age_sec, 80 | curtime, 81 | false, 82 | )?; 83 | 84 | let token_ema_price = OraclePrice::new_from_oracle( 85 | custody.oracle.oracle_type, 86 | &ctx.accounts.custody_oracle_account.to_account_info(), 87 | custody.oracle.max_price_error, 88 | custody.oracle.max_price_age_sec, 89 | curtime, 90 | custody.pricing.use_ema, 91 | )?; 92 | 93 | let fee_amount = 94 | pool.get_add_liquidity_fee(token_id, params.amount_in, custody, &token_price)?; 95 | let no_fee_amount = math::checked_sub(params.amount_in, fee_amount)?; 96 | 97 | let pool_amount_usd = 98 | pool.get_assets_under_management_usd(AumCalcMode::Max, ctx.remaining_accounts, curtime)?; 99 | 100 | let min_price = if token_price < token_ema_price { 101 | token_price 102 | } else { 103 | token_ema_price 104 | }; 105 | let token_amount_usd = min_price.get_asset_amount_usd(no_fee_amount, custody.decimals)?; 106 | 107 | let lp_amount = if pool_amount_usd == 0 { 108 | token_amount_usd 109 | } else { 110 | math::checked_as_u64(math::checked_div( 111 | math::checked_mul( 112 | token_amount_usd as u128, 113 | ctx.accounts.lp_token_mint.supply as u128, 114 | )?, 115 | pool_amount_usd, 116 | )?)? 117 | }; 118 | 119 | Ok(AmountAndFee { 120 | amount: lp_amount, 121 | fee: fee_amount, 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/withdraw_fees.rs: -------------------------------------------------------------------------------- 1 | //! WithdrawFees instruction handler 2 | 3 | use { 4 | crate::{ 5 | math, 6 | state::{ 7 | custody::Custody, 8 | multisig::{AdminInstruction, Multisig}, 9 | perpetuals::Perpetuals, 10 | pool::Pool, 11 | }, 12 | }, 13 | anchor_lang::prelude::*, 14 | anchor_spl::token::{Token, TokenAccount}, 15 | }; 16 | 17 | #[derive(Accounts)] 18 | pub struct WithdrawFees<'info> { 19 | #[account()] 20 | pub admin: Signer<'info>, 21 | 22 | #[account( 23 | mut, 24 | seeds = [b"multisig"], 25 | bump = multisig.load()?.bump 26 | )] 27 | pub multisig: AccountLoader<'info, Multisig>, 28 | 29 | /// CHECK: empty PDA, authority for token accounts 30 | #[account( 31 | seeds = [b"transfer_authority"], 32 | bump = perpetuals.transfer_authority_bump 33 | )] 34 | pub transfer_authority: AccountInfo<'info>, 35 | 36 | #[account( 37 | seeds = [b"perpetuals"], 38 | bump = perpetuals.perpetuals_bump 39 | )] 40 | pub perpetuals: Box>, 41 | 42 | #[account( 43 | mut, 44 | seeds = [b"pool", 45 | pool.name.as_bytes()], 46 | bump = pool.bump 47 | )] 48 | pub pool: Box>, 49 | 50 | #[account( 51 | mut, 52 | seeds = [b"custody", 53 | pool.key().as_ref(), 54 | custody.mint.key().as_ref()], 55 | bump = custody.bump 56 | )] 57 | pub custody: Box>, 58 | 59 | #[account( 60 | mut, 61 | seeds = [b"custody_token_account", 62 | pool.key().as_ref(), 63 | custody.mint.as_ref()], 64 | bump = custody.token_account_bump 65 | )] 66 | pub custody_token_account: Box>, 67 | 68 | #[account( 69 | mut, 70 | constraint = receiving_token_account.mint == custody_token_account.mint 71 | )] 72 | pub receiving_token_account: Box>, 73 | 74 | token_program: Program<'info, Token>, 75 | } 76 | 77 | #[derive(AnchorSerialize, AnchorDeserialize)] 78 | pub struct WithdrawFeesParams { 79 | pub amount: u64, 80 | } 81 | 82 | pub fn withdraw_fees<'info>( 83 | ctx: Context<'_, '_, '_, 'info, WithdrawFees<'info>>, 84 | params: &WithdrawFeesParams, 85 | ) -> Result { 86 | // validate inputs 87 | if params.amount == 0 { 88 | return Err(ProgramError::InvalidArgument.into()); 89 | } 90 | 91 | // validate signatures 92 | let mut multisig = ctx.accounts.multisig.load_mut()?; 93 | 94 | let signatures_left = multisig.sign_multisig( 95 | &ctx.accounts.admin, 96 | &Multisig::get_account_infos(&ctx)[1..], 97 | &Multisig::get_instruction_data(AdminInstruction::WithdrawFees, params)?, 98 | )?; 99 | if signatures_left > 0 { 100 | msg!( 101 | "Instruction has been signed but more signatures are required: {}", 102 | signatures_left 103 | ); 104 | return Ok(signatures_left); 105 | } 106 | 107 | // transfer token fees from the custody to the receiver 108 | let custody = ctx.accounts.custody.as_mut(); 109 | 110 | msg!( 111 | "Withdraw token fees: {} / {}", 112 | params.amount, 113 | custody.assets.protocol_fees 114 | ); 115 | 116 | if custody.assets.protocol_fees < params.amount { 117 | return Err(ProgramError::InsufficientFunds.into()); 118 | } 119 | custody.assets.protocol_fees = math::checked_sub(custody.assets.protocol_fees, params.amount)?; 120 | 121 | ctx.accounts.perpetuals.transfer_tokens( 122 | ctx.accounts.custody_token_account.to_account_info(), 123 | ctx.accounts.receiving_token_account.to_account_info(), 124 | ctx.accounts.transfer_authority.to_account_info(), 125 | ctx.accounts.token_program.to_account_info(), 126 | params.amount, 127 | )?; 128 | 129 | Ok(0) 130 | } 131 | -------------------------------------------------------------------------------- /programs/perpetuals/src/instructions/get_liquidation_price.rs: -------------------------------------------------------------------------------- 1 | //! GetLiquidationPrice instruction handler 2 | 3 | use { 4 | crate::{ 5 | math, 6 | state::{ 7 | custody::Custody, oracle::OraclePrice, perpetuals::Perpetuals, pool::Pool, 8 | position::Position, 9 | }, 10 | }, 11 | anchor_lang::prelude::*, 12 | }; 13 | 14 | #[derive(Accounts)] 15 | pub struct GetLiquidationPrice<'info> { 16 | #[account( 17 | seeds = [b"perpetuals"], 18 | bump = perpetuals.perpetuals_bump 19 | )] 20 | pub perpetuals: Box>, 21 | 22 | #[account( 23 | seeds = [b"pool", 24 | pool.name.as_bytes()], 25 | bump = pool.bump 26 | )] 27 | pub pool: Box>, 28 | 29 | #[account( 30 | seeds = [b"position", 31 | position.owner.as_ref(), 32 | pool.key().as_ref(), 33 | custody.key().as_ref(), 34 | &[position.side as u8]], 35 | bump = position.bump 36 | )] 37 | pub position: Box>, 38 | 39 | #[account( 40 | seeds = [b"custody", 41 | pool.key().as_ref(), 42 | custody.mint.as_ref()], 43 | bump = custody.bump 44 | )] 45 | pub custody: Box>, 46 | 47 | /// CHECK: oracle account for the collateral token 48 | #[account( 49 | constraint = custody_oracle_account.key() == custody.oracle.oracle_account 50 | )] 51 | pub custody_oracle_account: AccountInfo<'info>, 52 | } 53 | 54 | #[derive(AnchorSerialize, AnchorDeserialize)] 55 | pub struct GetLiquidationPriceParams { 56 | add_collateral: u64, 57 | remove_collateral: u64, 58 | } 59 | 60 | pub fn get_liquidation_price( 61 | ctx: Context, 62 | params: &GetLiquidationPriceParams, 63 | ) -> Result { 64 | let custody = ctx.accounts.custody.as_mut(); 65 | let curtime = ctx.accounts.perpetuals.get_time()?; 66 | 67 | let token_price = OraclePrice::new_from_oracle( 68 | custody.oracle.oracle_type, 69 | &ctx.accounts.custody_oracle_account.to_account_info(), 70 | custody.oracle.max_price_error, 71 | custody.oracle.max_price_age_sec, 72 | curtime, 73 | false, 74 | )?; 75 | 76 | let token_ema_price = OraclePrice::new_from_oracle( 77 | custody.oracle.oracle_type, 78 | &ctx.accounts.custody_oracle_account.to_account_info(), 79 | custody.oracle.max_price_error, 80 | custody.oracle.max_price_age_sec, 81 | curtime, 82 | custody.pricing.use_ema, 83 | )?; 84 | 85 | let min_price = if token_price < token_ema_price { 86 | token_price 87 | } else { 88 | token_ema_price 89 | }; 90 | 91 | let mut position = ctx.accounts.position.clone(); 92 | position.update_time = ctx.accounts.perpetuals.get_time()?; 93 | 94 | if params.add_collateral > 0 { 95 | let collateral_usd = 96 | min_price.get_asset_amount_usd(params.add_collateral, custody.decimals)?; 97 | position.collateral_usd = math::checked_add(position.collateral_usd, collateral_usd)?; 98 | position.collateral_amount = 99 | math::checked_add(position.collateral_amount, params.add_collateral)?; 100 | } 101 | if params.remove_collateral > 0 { 102 | let collateral_usd = 103 | min_price.get_asset_amount_usd(params.remove_collateral, custody.decimals)?; 104 | if collateral_usd >= position.collateral_usd 105 | || params.remove_collateral >= position.collateral_amount 106 | { 107 | return Err(ProgramError::InsufficientFunds.into()); 108 | } 109 | position.collateral_usd = math::checked_sub(position.collateral_usd, collateral_usd)?; 110 | position.collateral_amount = 111 | math::checked_sub(position.collateral_amount, params.remove_collateral)?; 112 | } 113 | 114 | ctx.accounts 115 | .pool 116 | .get_liquidation_price(&position, &token_ema_price, custody, curtime) 117 | } 118 | -------------------------------------------------------------------------------- /ui/src/lib/types.tsx: -------------------------------------------------------------------------------- 1 | import { TokenE } from "@/lib/Token"; 2 | import { BN } from "@project-serum/anchor"; 3 | import { PublicKey } from "@solana/web3.js"; 4 | 5 | export interface Pool { 6 | name: string; 7 | custodies: PublicKey[]; 8 | ratios: TokenRatios[]; 9 | aumUsd: BN; 10 | bump: number; 11 | lpTokenBump: number; 12 | inceptionTime: BN; 13 | } 14 | 15 | export interface TokenRatios { 16 | target: BN; 17 | min: BN; 18 | max: BN; 19 | } 20 | 21 | export interface Custody { 22 | pool: PublicKey; 23 | mint: PublicKey; 24 | tokenAccount: PublicKey; 25 | decimals: number; 26 | isStable: boolean; 27 | oracle: OracleParams; 28 | pricing: PricingParams; 29 | permissions: Permissions; 30 | fees: Fees; 31 | borrowRate: BorrowRateParams; 32 | 33 | assets: Assets; 34 | collectedFees: Stats; 35 | volumeStats: Stats; 36 | tradeStats: TradeStats; 37 | longPositions: PositionStats; 38 | shortPositions: PositionStats; 39 | borrowRateState: BorrowRateState; 40 | 41 | bump: number; 42 | tokenAccountBump: number; 43 | } 44 | 45 | export interface BorrowRateParams { 46 | baseRate: BN; 47 | slope1: BN; 48 | slope2: BN; 49 | optimalUtilization: BN; 50 | } 51 | 52 | export interface BorrowRateState { 53 | currentRate: BN; 54 | cumulativeRate: BN; 55 | lastUpdate: BN; 56 | } 57 | 58 | export interface PositionStats { 59 | openPositions: BN; 60 | collateralUsd: BN; 61 | sizeUsd: BN; 62 | lockedAmount: BN; 63 | weightedLeverage: BN; 64 | totalLeverage: BN; 65 | cumulativeInterest: BN; 66 | cumulativeInterestSnapshot: BN; 67 | } 68 | 69 | export interface Assets { 70 | collateral: BN; 71 | protocolFees: BN; 72 | owned: BN; 73 | locked: BN; 74 | } 75 | 76 | export interface Stats { 77 | swapUsd: BN; 78 | addLiquidityUsd: BN; 79 | removeLiquidityUsd: BN; 80 | openPositionUsd: BN; 81 | closePositionUsd: BN; 82 | liquidationUsd: BN; 83 | } 84 | 85 | export interface Fees { 86 | mode: FeesMode; 87 | maxIncrease: BN; 88 | maxDecrease: BN; 89 | swap: BN; 90 | addLiquidity: BN; 91 | removeLiquidity: BN; 92 | openPosition: BN; 93 | closePosition: BN; 94 | liquidation: BN; 95 | protocolShare: BN; 96 | } 97 | 98 | export enum FeesMode { 99 | Fixed, 100 | Linear, 101 | } 102 | 103 | export interface OracleParams { 104 | oracleAccount: PublicKey; 105 | oracleType: OracleType; 106 | maxPriceError: BN; 107 | maxPriceAgeSec: number; 108 | } 109 | 110 | export enum OracleType { 111 | None, 112 | Test, 113 | Pyth, 114 | } 115 | 116 | export interface Permissions { 117 | allowSwap: boolean; 118 | allowAddLiquidity: boolean; 119 | allowRemoveLiquidity: boolean; 120 | allowOpenPosition: boolean; 121 | allowClosePosition: boolean; 122 | allowPnlWithdrawal: boolean; 123 | allowCollateralWithdrawal: boolean; 124 | allowSizeChange: boolean; 125 | } 126 | 127 | export interface PricingParams { 128 | useEma: boolean; 129 | tradeSpreadLong: BN; 130 | tradeSpreadShort: BN; 131 | swapSpread: BN; 132 | minInitialLeverage: BN; 133 | maxLeverage: BN; 134 | maxPayoffMult: BN; 135 | } 136 | 137 | export interface TradeStats { 138 | profitUsd: BN; 139 | lossUsd: BN; 140 | oiLongUsd: BN; 141 | oiShortUsd: BN; 142 | } 143 | 144 | export enum Side { 145 | None = "None", 146 | Long = "Long", 147 | Short = "Short", 148 | Swap = "Swap", 149 | } 150 | 151 | export enum Tab { 152 | Add, 153 | Remove, 154 | } 155 | 156 | export interface AccountMeta { 157 | pubkey: PublicKey; 158 | isSigner: boolean; 159 | isWritable: boolean; 160 | } 161 | 162 | export class TradeSide { 163 | static Long = { long: {} }; 164 | static Short = { short: {} }; 165 | } 166 | 167 | export interface Position { 168 | owner: PublicKey; 169 | pool: PublicKey; 170 | custody: PublicKey; 171 | lockCustody: PublicKey; 172 | 173 | openTime: BN; 174 | updateTime: BN; 175 | 176 | side: Side; 177 | price: BN; 178 | sizeUsd: BN; 179 | collateralUsd: BN; 180 | unrealizedProfitUsd: BN; 181 | unrealizedLossUsd: BN; 182 | cumulativeInterestSnapshot: BN; 183 | lockedAmount: BN; 184 | collateralAmount: BN; 185 | } 186 | 187 | export interface PriceStat { 188 | change24hr: number; 189 | currentPrice: number; 190 | high24hr: number; 191 | low24hr: number; 192 | } 193 | 194 | export type PriceStats = Record; 195 | -------------------------------------------------------------------------------- /programs/perpetuals/tests/native/instructions/test_liquidate.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::utils::{self, pda}, 3 | anchor_lang::{prelude::Pubkey, ToAccountMetas}, 4 | bonfida_test_utils::ProgramTestContextExt, 5 | perpetuals::{ 6 | instructions::LiquidateParams, 7 | state::{custody::Custody, position::Position}, 8 | }, 9 | solana_program_test::{BanksClientError, ProgramTestContext}, 10 | solana_sdk::signer::{keypair::Keypair, Signer}, 11 | }; 12 | 13 | pub async fn test_liquidate( 14 | program_test_ctx: &mut ProgramTestContext, 15 | liquidator: &Keypair, 16 | payer: &Keypair, 17 | pool_pda: &Pubkey, 18 | custody_token_mint: &Pubkey, 19 | position_pda: &Pubkey, 20 | ) -> std::result::Result<(), BanksClientError> { 21 | // ==== WHEN ============================================================== 22 | let owner = { 23 | let position_account = 24 | utils::get_account::(program_test_ctx, *position_pda).await; 25 | position_account.owner 26 | }; 27 | 28 | // Prepare PDA and addresses 29 | let transfer_authority_pda = pda::get_transfer_authority_pda().0; 30 | let perpetuals_pda = pda::get_perpetuals_pda().0; 31 | let custody_pda = pda::get_custody_pda(pool_pda, custody_token_mint).0; 32 | let custody_token_account_pda = 33 | pda::get_custody_token_account_pda(pool_pda, custody_token_mint).0; 34 | 35 | let receiving_account_address = 36 | utils::find_associated_token_account(&owner, custody_token_mint).0; 37 | 38 | let rewards_receiving_account_address = 39 | utils::find_associated_token_account(&liquidator.pubkey(), custody_token_mint).0; 40 | 41 | let custody_account = utils::get_account::(program_test_ctx, custody_pda).await; 42 | let custody_oracle_account_address = custody_account.oracle.oracle_account; 43 | 44 | // Save account state before tx execution 45 | let receiving_account_before = program_test_ctx 46 | .get_token_account(receiving_account_address) 47 | .await 48 | .unwrap(); 49 | let custody_token_account_before = program_test_ctx 50 | .get_token_account(custody_token_account_pda) 51 | .await 52 | .unwrap(); 53 | let rewards_receiving_account_before = program_test_ctx 54 | .get_token_account(rewards_receiving_account_address) 55 | .await 56 | .unwrap(); 57 | 58 | utils::create_and_execute_perpetuals_ix( 59 | program_test_ctx, 60 | perpetuals::accounts::Liquidate { 61 | signer: liquidator.pubkey(), 62 | rewards_receiving_account: rewards_receiving_account_address, 63 | receiving_account: receiving_account_address, 64 | transfer_authority: transfer_authority_pda, 65 | perpetuals: perpetuals_pda, 66 | pool: *pool_pda, 67 | position: *position_pda, 68 | custody: custody_pda, 69 | custody_oracle_account: custody_oracle_account_address, 70 | custody_token_account: custody_token_account_pda, 71 | token_program: anchor_spl::token::ID, 72 | } 73 | .to_account_metas(None), 74 | perpetuals::instruction::Liquidate { 75 | params: LiquidateParams {}, 76 | }, 77 | Some(&payer.pubkey()), 78 | &[liquidator, payer], 79 | ) 80 | .await?; 81 | 82 | // ==== THEN ============================================================== 83 | // Check the balance change 84 | { 85 | let receiving_account_after = program_test_ctx 86 | .get_token_account(receiving_account_address) 87 | .await 88 | .unwrap(); 89 | let custody_token_account_after = program_test_ctx 90 | .get_token_account(custody_token_account_pda) 91 | .await 92 | .unwrap(); 93 | let rewards_receiving_account_after = program_test_ctx 94 | .get_token_account(rewards_receiving_account_address) 95 | .await 96 | .unwrap(); 97 | 98 | assert!(receiving_account_after.amount >= receiving_account_before.amount); 99 | assert!(custody_token_account_after.amount <= custody_token_account_before.amount); 100 | assert!(rewards_receiving_account_after.amount > rewards_receiving_account_before.amount); 101 | } 102 | 103 | Ok(()) 104 | } 105 | --------------------------------------------------------------------------------