From 07ae8a555e1cf5dc1cfa4ad1462082efc3047c82 Mon Sep 17 00:00:00 2001 From: Venom <184024755+0xcryptovenom@users.noreply.github.com> Date: Sun, 24 Nov 2024 01:54:52 +0100 Subject: [PATCH 01/14] contrib: live position & oracle price updates v1 --- src/AdrenaClient.ts | 18 +- src/actions/thunks.ts | 189 ++++++++++++++- .../pages/trading/Positions/PositionBlock.tsx | 113 ++++++--- .../trading/Positions/PositionsBlocks.tsx | 5 +- .../pages/trading/TradingChart/streaming.ts | 22 +- src/hooks/useAllPositions.tsx | 4 +- src/hooks/usePositions.ts | 224 ++++++++---------- src/hooks/usePositionsByAddress.ts | 4 +- src/pages/_app.tsx | 3 - src/pages/my_dashboard/index.tsx | 6 +- src/pages/trade/index.tsx | 57 +++-- src/selectors/positions.ts | 12 + src/selectors/streamingTokenPrices.ts | 52 ++++ src/selectors/tokenPrices.ts | 4 + src/types.d.ts | 1 - 15 files changed, 511 insertions(+), 203 deletions(-) create mode 100644 src/selectors/positions.ts create mode 100644 src/selectors/streamingTokenPrices.ts create mode 100644 src/selectors/tokenPrices.ts diff --git a/src/AdrenaClient.ts b/src/AdrenaClient.ts index 27cb104ad..8e3ee399e 100644 --- a/src/AdrenaClient.ts +++ b/src/AdrenaClient.ts @@ -4036,12 +4036,15 @@ export class AdrenaClient { // Positions PDA can be found by deriving each mints supported by the pool for 2 sides // DO NOT LOAD PNL OR LIQUIDATION PRICE - public async loadUserPositions(user: PublicKey): Promise { - const possiblePositionAddresses = this.getPossiblePositionAddresses(user); - + public async loadUserPositions( + user: PublicKey, + positionAddresses?: Array, + ): Promise { + const actualPositionAddresses = + positionAddresses || this.getPossiblePositionAddresses(user); const positions = (await this.readonlyAdrenaProgram.account.position.fetchMultiple( - possiblePositionAddresses, + actualPositionAddresses, 'recent', )) as (Position | null)[]; @@ -4054,14 +4057,15 @@ export class AdrenaClient { const positionExtended = this.extendPosition( position, - possiblePositionAddresses[index], + actualPositionAddresses[index], ); - if (!positionExtended) { + if (positionExtended) { + acc.push(positionExtended); return acc; } - return [...acc, positionExtended]; + return acc; }, [] as PositionExtended[], ); diff --git a/src/actions/thunks.ts b/src/actions/thunks.ts index 572635243..460638035 100644 --- a/src/actions/thunks.ts +++ b/src/actions/thunks.ts @@ -1,10 +1,15 @@ import { BN } from '@coral-xyz/anchor'; import { NATIVE_MINT } from '@solana/spl-token'; +import type { AccountInfo, PublicKey } from '@solana/web3.js'; import { SOL_DECIMALS } from '@/constant'; +import type { TokenPricesState } from '@/reducers/streamingTokenPricesReducer'; +import { selectPossibleUserPositions } from '@/selectors/positions'; +import { selectStreamingTokenPrices } from '@/selectors/streamingTokenPrices'; +import { selectTokenPrices } from '@/selectors/tokenPrices'; import { selectWalletPublicKey } from '@/selectors/wallet'; import type { Dispatch, RootState } from '@/store/store'; -import type { TokenSymbol } from '@/types'; +import type { PositionExtended, TokenSymbol } from '@/types'; import { findATAAddressSync, nativeToUi } from '@/utils'; import { setWalletTokenBalances } from './walletBalances'; @@ -69,3 +74,185 @@ export const fetchWalletTokenBalances = ), ); }; + +export const calculatePnLandLiquidationPrice = ( + position: PositionExtended, + tokenPrices: TokenPricesState, +) => { + const pnl = window.adrena.client.calculatePositionPnL({ + position, + tokenPrices, + }); + + if (pnl === null) { + return; + } + + const { profitUsd, lossUsd, borrowFeeUsd } = pnl; + + position.profitUsd = profitUsd; + position.lossUsd = lossUsd; + position.borrowFeeUsd = borrowFeeUsd; + position.pnl = profitUsd + -lossUsd; + position.pnlMinusFees = position.pnl + borrowFeeUsd + position.exitFeeUsd; + position.currentLeverage = + position.sizeUsd / (position.collateralUsd + position.pnl); + + // Calculate liquidation price + const liquidationPrice = window.adrena.client.calculateLiquidationPrice({ + position, + }); + + if (liquidationPrice) { + return; + } +}; + +export const fetchUserPositions = + () => async (dispatch: Dispatch, getState: () => RootState) => { + const connection = window.adrena.client.connection; + const walletPublicKey = selectWalletPublicKey(getState()); + const possibleUserPositions = selectPossibleUserPositions(getState()); + + if ( + !connection || + !walletPublicKey || + !Array.isArray(possibleUserPositions) + ) { + return null; + } + + try { + const positions = await window.adrena.client.loadUserPositions( + walletPublicKey, + possibleUserPositions, + ); + + let tokenPrices = selectStreamingTokenPrices(getState()); + // Fallback if Pyth price streaming is not active, ie: profile/dashboard page. + if (!tokenPrices) { + tokenPrices = selectTokenPrices(getState()); + } + for (const position of positions) { + calculatePnLandLiquidationPrice(position, tokenPrices); + } + + return positions; + } catch (err) { + console.error('Unexpected error fetching user positions', err); + + return null; + } + }; + +let subscribed = false; +export const subscribeToUserPositions = + ({ + onPositionUpdated, + onPositionDeleted, + }: { + onPositionUpdated: (position: PositionExtended) => unknown; + onPositionDeleted: (userPosition: PublicKey) => unknown; + }) => + (dispatch: Dispatch, getState: () => RootState) => { + const connection = window.adrena.client.connection; + const walletPublicKey = selectWalletPublicKey(getState()); + const possibleUserPositions = selectPossibleUserPositions(getState()); + + if ( + !connection || + !walletPublicKey || + !Array.isArray(possibleUserPositions) + ) { + return; + } + + // Ensuring we only subscribe once to user position changes. + if (subscribed) { + return; + } + + const subscriptions = new Map(); + const removeSubscription = (subscriptionId: number) => { + connection.removeAccountChangeListener(subscriptionId); + subscriptions.delete(subscriptionId); + }; + const unsubscribe = () => { + subscribed = false; + for (const [subscriptionId] of subscriptions) { + removeSubscription(subscriptionId); + } + }; + + for (const possibleUserPosition of possibleUserPositions) { + const subscriptionId = connection.onAccountChange( + possibleUserPosition, + (accountInfo: AccountInfo) => { + const userPosition = possibleUserPosition; + + // Position got deleted + if (!accountInfo.data.length) { + removeSubscription(subscriptionId); + onPositionDeleted(userPosition); + return; + } + + try { + const positionData = window.adrena.client + .getReadonlyAdrenaProgram() + .coder.accounts.decode('position', accountInfo.data); + const extendedPosition = window.adrena.client.extendPosition( + positionData, + userPosition, + ); + + if (!extendedPosition) { + throw new Error('Unexpected null extended user position'); + } + + onPositionUpdated(extendedPosition); + } catch (err) { + console.error( + 'Unexpected error decoding / extending user position, removing subscription', + err, + ); + removeSubscription(subscriptionId); + } + }, + ); + subscriptions.set(subscriptionId, possibleUserPosition); + } + + return unsubscribe; + }; + +export const fetchAndSubscribeToFullUserPositions = + ( + onPositionUpdated: (position: PositionExtended) => unknown, + onPositionDeleted: (userPosition: PublicKey) => unknown, + ) => + (dispatch: Dispatch, getState: () => RootState) => { + // Fetch the initial user positions + // keep the promise to be returned synchronously, do not await it. + const positionsPromise = dispatch(fetchUserPositions()); + + const unsubscribe = dispatch( + subscribeToUserPositions({ + onPositionUpdated: function augmentPositionWithPnL(position) { + let tokenPrices = selectStreamingTokenPrices(getState()); + // Fallback if Pyth price streaming is not active, ie: profile/dashboard page. + if (!tokenPrices) { + tokenPrices = selectTokenPrices(getState()); + } + // PositionExtended objects are augmented in place. + calculatePnLandLiquidationPrice(position, tokenPrices); + onPositionUpdated(position); + }, + onPositionDeleted, + }), + ); + + // We must return the unsubscribe function synchronously + // so effect-based react component / hook consumer can clean-up. + return [positionsPromise, unsubscribe] as const; + }; diff --git a/src/components/pages/trading/Positions/PositionBlock.tsx b/src/components/pages/trading/Positions/PositionBlock.tsx index 566a91d64..29ecf6e7f 100644 --- a/src/components/pages/trading/Positions/PositionBlock.tsx +++ b/src/components/pages/trading/Positions/PositionBlock.tsx @@ -2,7 +2,7 @@ import Tippy from '@tippyjs/react'; import { AnimatePresence } from 'framer-motion'; import Image from 'next/image'; import Link from 'next/link'; -import { useEffect, useRef, useState } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { twMerge } from 'tailwind-merge'; import Button from '@/components/common/Button/Button'; @@ -12,6 +12,7 @@ import { Congrats } from '@/components/Congrats/Congrats'; import FormatNumber from '@/components/Number/FormatNumber'; import { MINIMUM_POSITION_OPEN_TIME } from '@/constant'; import useBetterMediaQuery from '@/hooks/useBetterMediaQuery'; +import { selectStreamingTokenPrice } from '@/selectors/streamingTokenPrices'; import { useSelector } from '@/store/store'; import { PositionExtended } from '@/types'; import { getTokenImage, getTokenSymbol } from '@/utils'; @@ -21,7 +22,7 @@ import OnchainAccountInfo from '../../monitoring/OnchainAccountInfo'; import NetValueTooltip from '../TradingInputs/NetValueTooltip'; import SharePositionModal from './SharePositionModal'; -export default function PositionBlock({ +function PositionBlock({ bodyClassName, borderColor, position, @@ -38,7 +39,10 @@ export default function PositionBlock({ triggerEditPositionCollateral: (p: PositionExtended) => void; showFeesInPnl: boolean; }) { - const tokenPrices = useSelector((s) => s.tokenPrices); + // Only subscribe to the price for the token of this position. + const positionTokenPrice = useSelector((s) => + selectStreamingTokenPrice(s, getTokenSymbol(position.token.symbol)), + ); const blockRef = useRef(null); const [isOpen, setIsOpen] = useState(false); @@ -56,7 +60,7 @@ export default function PositionBlock({ } const interval = setInterval(() => { - console.log('interval') + console.log('interval'); const openedDuration = Date.now() - openedTime; const diff = MINIMUM_POSITION_OPEN_TIME - openedDuration; @@ -74,22 +78,20 @@ export default function PositionBlock({ }, [position.nativeObject.openTime.toNumber()]); const liquidable = (() => { - const tokenPrice = tokenPrices[getTokenSymbol(position.token.symbol)]; - if ( - tokenPrice === null || - typeof position.liquidationPrice === 'undefined' || + positionTokenPrice === null || + position.liquidationPrice === undefined || position.liquidationPrice === null ) return; - if (position.side === 'long') return tokenPrice < position.liquidationPrice; + if (position.side === 'long') + return positionTokenPrice < position.liquidationPrice; // Short - return tokenPrice > position.liquidationPrice; + return positionTokenPrice > position.liquidationPrice; })(); - const isSmallSize = useBetterMediaQuery('(max-width: 800px)'); const positionName = (
@@ -168,16 +170,23 @@ export default function PositionBlock({ 0 - ? 'green' - : 'redbright' - }`} + className={`mr-0.5 font-bold text-${ + (showAfterFees ? position.pnl : position.pnl - fees) > 0 + ? 'green' + : 'redbright' + }`} isDecimalDimmed={false} /> - 0 - ? 'text-green' - : 'text-redbright')}>{"("} + 0 + ? 'text-green' + : 'text-redbright', + )} + > + {'('} + 0 - ? 'green' - : 'redbright' - }`} + className={`text-xs text-${ + (showAfterFees ? position.pnl : position.pnl - fees) > 0 + ? 'green' + : 'redbright' + }`} /> - 0 - ? 'text-green' - : 'text-redbright')}>{")"} + 0 + ? 'text-green' + : 'text-redbright', + )} + > + {')'} +