From 783887937bc83631f34c44ed8366b66cbd104877 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Tue, 14 Jan 2025 19:28:12 +0400 Subject: [PATCH 1/8] Split positions into multiple files --- src/pages/trade/model/positions.ts | 2 + .../trade/ui/positions/action-button.tsx | 34 ++++ src/pages/trade/ui/positions/error-notice.tsx | 12 ++ .../ui/positions/header-action-button.tsx | 56 ++++++ .../ui/{positions.tsx => positions/index.tsx} | 177 +++--------------- src/pages/trade/ui/positions/no-positions.tsx | 11 ++ .../ui/positions/not-connected-notice.tsx | 19 ++ 7 files changed, 162 insertions(+), 149 deletions(-) create mode 100644 src/pages/trade/ui/positions/action-button.tsx create mode 100644 src/pages/trade/ui/positions/error-notice.tsx create mode 100644 src/pages/trade/ui/positions/header-action-button.tsx rename src/pages/trade/ui/{positions.tsx => positions/index.tsx} (64%) create mode 100644 src/pages/trade/ui/positions/no-positions.tsx create mode 100644 src/pages/trade/ui/positions/not-connected-notice.tsx diff --git a/src/pages/trade/model/positions.ts b/src/pages/trade/model/positions.ts index affeda79..c9015e97 100644 --- a/src/pages/trade/model/positions.ts +++ b/src/pages/trade/model/positions.ts @@ -35,6 +35,7 @@ export interface DisplayPosition { }[]; fee: string; isActive: boolean; + isOpen: boolean; state: PositionState_PositionStateEnum; } @@ -417,6 +418,7 @@ class PositionsStore { // feedback about execution. This is probably best later replaced by either a notification, or a // dedicated view. Fine for now. isActive: state.state !== PositionState_PositionStateEnum.WITHDRAWN, + isOpen: state.state === PositionState_PositionStateEnum.OPENED, state: state.state, }; }); diff --git a/src/pages/trade/ui/positions/action-button.tsx b/src/pages/trade/ui/positions/action-button.tsx new file mode 100644 index 00000000..a68b4b34 --- /dev/null +++ b/src/pages/trade/ui/positions/action-button.tsx @@ -0,0 +1,34 @@ +import { observer } from 'mobx-react-lite'; +import { Button } from '@penumbra-zone/ui/Button'; +import { Text } from '@penumbra-zone/ui/Text'; +import { + PositionId, + PositionState_PositionStateEnum, +} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { positionsStore } from '@/pages/trade/model/positions'; + +export const ActionButton = observer( + ({ state, id }: { state: PositionState_PositionStateEnum; id: PositionId }) => { + const { loading, closePositions, withdrawPositions } = positionsStore; + + if (state === PositionState_PositionStateEnum.OPENED) { + return ( + + ); + } else if (state === PositionState_PositionStateEnum.CLOSED) { + return ( + + ); + } else { + return ( + + - + + ); + } + }, +); diff --git a/src/pages/trade/ui/positions/error-notice.tsx b/src/pages/trade/ui/positions/error-notice.tsx new file mode 100644 index 00000000..1b5630d5 --- /dev/null +++ b/src/pages/trade/ui/positions/error-notice.tsx @@ -0,0 +1,12 @@ +import { BlockchainError } from '@/shared/ui/blockchain-error'; + +export const ErrorNotice = () => { + return ( +
+ +
+ ); +}; diff --git a/src/pages/trade/ui/positions/header-action-button.tsx b/src/pages/trade/ui/positions/header-action-button.tsx new file mode 100644 index 00000000..7130afc7 --- /dev/null +++ b/src/pages/trade/ui/positions/header-action-button.tsx @@ -0,0 +1,56 @@ +import { observer } from 'mobx-react-lite'; +import { Button } from '@penumbra-zone/ui/Button'; +import { PositionState_PositionStateEnum } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { positionsStore, DisplayPosition } from '@/pages/trade/model/positions'; + +const MAX_ACTION_COUNT = 15; + +export const HeaderActionButton = observer( + ({ displayPositions }: { displayPositions: DisplayPosition[] }) => { + const { loading, closePositions, withdrawPositions } = positionsStore; + + const openedPositions = displayPositions.filter( + position => position.state === PositionState_PositionStateEnum.OPENED, + ); + + if (openedPositions.length > 1) { + return ( + + ); + } + + const closedPositions = displayPositions.filter( + position => position.state === PositionState_PositionStateEnum.CLOSED, + ); + + if (closedPositions.length > 1) { + return ( + + ); + } + + return 'Actions'; + }, +); diff --git a/src/pages/trade/ui/positions.tsx b/src/pages/trade/ui/positions/index.tsx similarity index 64% rename from src/pages/trade/ui/positions.tsx rename to src/pages/trade/ui/positions/index.tsx index d4bf1ec0..3327389f 100644 --- a/src/pages/trade/ui/positions.tsx +++ b/src/pages/trade/ui/positions/index.tsx @@ -2,158 +2,26 @@ import Link from 'next/link'; import { useEffect } from 'react'; -import { LoadingCell } from './market-trades'; import { connectionStore } from '@/shared/model/connection'; import { observer } from 'mobx-react-lite'; -import { Text, TextProps } from '@penumbra-zone/ui/Text'; +import { Text } from '@penumbra-zone/ui/Text'; import { Table } from '@penumbra-zone/ui/Table'; import { ValueViewComponent } from '@penumbra-zone/ui/ValueView'; import { Density } from '@penumbra-zone/ui/Density'; import { Tooltip, TooltipProvider } from '@penumbra-zone/ui/Tooltip'; import { stateToString, usePositions } from '@/pages/trade/api/positions.ts'; -import { Button } from '@penumbra-zone/ui/Button'; -import { - PositionId, - PositionState_PositionStateEnum, -} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; -import { DisplayPosition, positionsStore } from '@/pages/trade/model/positions'; +import { positionsStore } from '@/pages/trade/model/positions'; import { pnum } from '@penumbra-zone/types/pnum'; import { useRegistryAssets } from '@/shared/api/registry'; -import { usePathToMetadata } from '../model/use-path'; -import { PositionsCurrentValue } from './positions-current-value'; -import { SquareArrowOutUpRight, Wallet2 } from 'lucide-react'; -import { ConnectButton } from '@/features/connect/connect-button'; -import { BlockchainError } from '@/shared/ui/blockchain-error'; - -const NotConnectedNotice = () => { - return ( -
-
- -
- - Connect wallet to see your positions - -
- -
-
- ); -}; - -const NoPositions = () => { - return ( -
- - No liquidity positions opened - -
- ); -}; - -const ErrorNotice = () => { - return ( -
- -
- ); -}; - -const getStateLabel = ( - state: PositionState_PositionStateEnum, - direction: DisplayPosition['orders'][number]['direction'], -): { label: string; color: TextProps['color'] } => { - if (state === PositionState_PositionStateEnum.OPENED) { - if (direction === 'Buy') { - return { label: direction, color: 'success.light' }; - } else { - return { label: direction, color: 'destructive.light' }; - } - } else { - return { label: stateToString(state), color: 'neutral.light' }; - } -}; - -const ActionButton = observer( - ({ state, id }: { state: PositionState_PositionStateEnum; id: PositionId }) => { - const { loading, closePositions, withdrawPositions } = positionsStore; - - if (state === PositionState_PositionStateEnum.OPENED) { - return ( - - ); - } else if (state === PositionState_PositionStateEnum.CLOSED) { - return ( - - ); - } else { - return ( - - - - - ); - } - }, -); - -const MAX_ACTION_COUNT = 15; - -const HeaderActionButton = observer( - ({ displayPositions }: { displayPositions: DisplayPosition[] }) => { - const { loading, closePositions, withdrawPositions } = positionsStore; - - const openedPositions = displayPositions.filter( - position => position.state === PositionState_PositionStateEnum.OPENED, - ); - - if (openedPositions.length > 1) { - return ( - - ); - } - - const closedPositions = displayPositions.filter( - position => position.state === PositionState_PositionStateEnum.CLOSED, - ); - - if (closedPositions.length > 1) { - return ( - - ); - } - - return 'Actions'; - }, -); +import { SquareArrowOutUpRight } from 'lucide-react'; +import { usePathToMetadata } from '../../model/use-path'; +import { PositionsCurrentValue } from '../positions-current-value'; +import { LoadingCell } from '../market-trades'; +import { NotConnectedNotice } from './not-connected-notice'; +import { ErrorNotice } from './error-notice'; +import { NoPositions } from './no-positions'; +import { HeaderActionButton } from './header-action-button'; +import { ActionButton } from './action-button'; const Positions = observer(({ showInactive }: { showInactive: boolean }) => { const { connected } = connectionStore; @@ -243,13 +111,24 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => {
- {position.orders - .map(order => getStateLabel(position.state, order.direction)) - .map(({ label, color }, i) => ( - - {label} + {position.orders.map((order, i) => + position.isOpen ? ( + + {order.direction} + + ) : ( + + {stateToString(position.state)} - ))} + ), + )}
diff --git a/src/pages/trade/ui/positions/no-positions.tsx b/src/pages/trade/ui/positions/no-positions.tsx new file mode 100644 index 00000000..1ef06a73 --- /dev/null +++ b/src/pages/trade/ui/positions/no-positions.tsx @@ -0,0 +1,11 @@ +import { Text } from '@penumbra-zone/ui/Text'; + +export const NoPositions = () => { + return ( +
+ + No liquidity positions opened + +
+ ); +}; diff --git a/src/pages/trade/ui/positions/not-connected-notice.tsx b/src/pages/trade/ui/positions/not-connected-notice.tsx new file mode 100644 index 00000000..f5c62bdb --- /dev/null +++ b/src/pages/trade/ui/positions/not-connected-notice.tsx @@ -0,0 +1,19 @@ +import { Wallet2 } from 'lucide-react'; +import { ConnectButton } from '@/features/connect/connect-button'; +import { Text } from '@penumbra-zone/ui/Text'; + +export const NotConnectedNotice = () => { + return ( +
+
+ +
+ + Connect wallet to see your positions + +
+ +
+
+ ); +}; From 84e0d96bfd2a822aeb3b5ce4c1511d019d4546f2 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Wed, 15 Jan 2025 20:00:17 +0400 Subject: [PATCH 2/8] Refactor and add position closes --- src/pages/trade/model/positions.ts | 32 ++++++++++++++++--- .../ui/positions/header-action-button.tsx | 31 +++++++----------- src/pages/trade/ui/positions/index.tsx | 2 +- 3 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/pages/trade/model/positions.ts b/src/pages/trade/model/positions.ts index c9015e97..df639483 100644 --- a/src/pages/trade/model/positions.ts +++ b/src/pages/trade/model/positions.ts @@ -18,7 +18,7 @@ import { penumbra } from '@/shared/const/penumbra.ts'; import { DexService } from '@penumbra-zone/protobuf'; import { openToast } from '@penumbra-zone/ui/Toast'; import { pnum } from '@penumbra-zone/types/pnum'; -import { positionIdFromBech32 } from '@penumbra-zone/bech32m/plpid'; +import { bech32mPositionId, positionIdFromBech32 } from '@penumbra-zone/bech32m/plpid'; import { updatePositionsQuery } from '@/pages/trade/api/positions'; import { BigNumber } from 'bignumber.js'; @@ -35,7 +35,8 @@ export interface DisplayPosition { }[]; fee: string; isActive: boolean; - isOpen: boolean; + isOpened: boolean; + isClosed: boolean; state: PositionState_PositionStateEnum; } @@ -108,6 +109,23 @@ class PositionsStore { penumbra.service(DexService).liquidityPositionById({ positionId }), ); const latestPositionData = await Promise.all(promises); + console.log( + 'TCL: PositionsStore -> latestPositionData', + positions.map((positionId, i) => ({ + id: bech32mPositionId(positionId), + position: latestPositionData[i], + })), + ); + console.log( + 'TCL: PositionsStore -> latestPositionData is closed', + latestPositionData + .map((position, i) => [position, i]) + .filter(([position, i]) => position.data?.closeOnFill) + .map(([position, i]) => ({ + position, + positionId: bech32mPositionId(positions[i]), + })), + ); const planReq = new TransactionPlannerRequest({ positionWithdraws: positions.map((positionId, i) => ({ @@ -115,6 +133,11 @@ class PositionsStore { tradingPair: latestPositionData[i]?.data?.phi?.pair, reserves: latestPositionData[i]?.data?.reserves, })), + positionCloses: positions.map((positionId, i) => ({ + positionId, + tradingPair: latestPositionData[i]?.data?.phi?.pair, + reserves: latestPositionData[i]?.data?.reserves, + })), source: new AddressIndex({ account: connectionStore.subaccount }), }); @@ -418,12 +441,11 @@ class PositionsStore { // feedback about execution. This is probably best later replaced by either a notification, or a // dedicated view. Fine for now. isActive: state.state !== PositionState_PositionStateEnum.WITHDRAWN, - isOpen: state.state === PositionState_PositionStateEnum.OPENED, + isOpened: state.state === PositionState_PositionStateEnum.OPENED, + isClosed: state.state === PositionState_PositionStateEnum.CLOSED, state: state.state, }; }); - // TODO: filter position view using trading pair route. - // .filter(displayPosition => displayPosition !== undefined) } } diff --git a/src/pages/trade/ui/positions/header-action-button.tsx b/src/pages/trade/ui/positions/header-action-button.tsx index 7130afc7..60455987 100644 --- a/src/pages/trade/ui/positions/header-action-button.tsx +++ b/src/pages/trade/ui/positions/header-action-button.tsx @@ -1,6 +1,5 @@ import { observer } from 'mobx-react-lite'; import { Button } from '@penumbra-zone/ui/Button'; -import { PositionState_PositionStateEnum } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; import { positionsStore, DisplayPosition } from '@/pages/trade/model/positions'; const MAX_ACTION_COUNT = 15; @@ -9,9 +8,10 @@ export const HeaderActionButton = observer( ({ displayPositions }: { displayPositions: DisplayPosition[] }) => { const { loading, closePositions, withdrawPositions } = positionsStore; - const openedPositions = displayPositions.filter( - position => position.state === PositionState_PositionStateEnum.OPENED, - ); + const openedPositions = displayPositions + .filter(position => position.isOpened) + .slice(0, MAX_ACTION_COUNT) + .map(position => position.id); if (openedPositions.length > 1) { return ( @@ -19,20 +19,17 @@ export const HeaderActionButton = observer( density='slim' actionType='destructive' disabled={loading} - onClick={() => - void closePositions( - openedPositions.slice(0, MAX_ACTION_COUNT).map(position => position.id), - ) - } + onClick={() => void closePositions(openedPositions)} > - Close Batch + Close Batch ({openedPositions.length}) ); } - const closedPositions = displayPositions.filter( - position => position.state === PositionState_PositionStateEnum.CLOSED, - ); + const closedPositions = displayPositions + .filter(position => position.isClosed) + .slice(0, MAX_ACTION_COUNT) + .map(position => position.id); if (closedPositions.length > 1) { return ( @@ -40,13 +37,9 @@ export const HeaderActionButton = observer( density='slim' actionType='destructive' disabled={loading} - onClick={() => - void withdrawPositions( - closedPositions.map(position => position.id).slice(0, MAX_ACTION_COUNT), - ) - } + onClick={() => void withdrawPositions(closedPositions)} > - Withdraw Batch + Withdraw Batch ({closedPositions.length}) ); } diff --git a/src/pages/trade/ui/positions/index.tsx b/src/pages/trade/ui/positions/index.tsx index 3327389f..50175697 100644 --- a/src/pages/trade/ui/positions/index.tsx +++ b/src/pages/trade/ui/positions/index.tsx @@ -112,7 +112,7 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => {
{position.orders.map((order, i) => - position.isOpen ? ( + position.isOpened ? ( Date: Thu, 16 Jan 2025 21:00:11 +0400 Subject: [PATCH 3/8] Include positionCloses --- src/pages/trade/model/positions.ts | 68 ++++++++++--------- .../ui/positions/header-action-button.tsx | 10 ++- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/pages/trade/model/positions.ts b/src/pages/trade/model/positions.ts index df639483..a4096359 100644 --- a/src/pages/trade/model/positions.ts +++ b/src/pages/trade/model/positions.ts @@ -25,6 +25,7 @@ import { BigNumber } from 'bignumber.js'; export interface DisplayPosition { id: PositionId; idString: string; + position: Position; orders: { direction: string; amount: ValueView; @@ -78,12 +79,12 @@ class PositionsStore { this.loading = state; } - closePositions = async (positions: PositionId[]): Promise => { + closePositions = async (positions: { id: PositionId; position: Position }[]): Promise => { try { this.setLoading(true); const planReq = new TransactionPlannerRequest({ - positionCloses: positions.map(positionId => ({ positionId })), + positionCloses: positions.map(({ id }) => ({ positionId: id })), source: new AddressIndex({ account: connectionStore.subaccount }), }); @@ -100,44 +101,48 @@ class PositionsStore { } }; - withdrawPositions = async (positions: PositionId[]): Promise => { + withdrawPositions = async ( + positions: { id: PositionId; position: Position }[], + ): Promise => { try { this.setLoading(true); // Fetching latest position data as the planner request requires current reserves + pair - const promises = positions.map(positionId => - penumbra.service(DexService).liquidityPositionById({ positionId }), + const promises = positions.map(({ id }) => + penumbra.service(DexService).liquidityPositionById({ positionId: id }), ); const latestPositionData = await Promise.all(promises); - console.log( - 'TCL: PositionsStore -> latestPositionData', - positions.map((positionId, i) => ({ - id: bech32mPositionId(positionId), - position: latestPositionData[i], - })), - ); - console.log( - 'TCL: PositionsStore -> latestPositionData is closed', - latestPositionData - .map((position, i) => [position, i]) - .filter(([position, i]) => position.data?.closeOnFill) - .map(([position, i]) => ({ - position, - positionId: bech32mPositionId(positions[i]), - })), + + // a position can be closed remotely, but not yet closed locally + // in this case we need to generate an nft to be able to withdraw + // it and include it in the planner request + const positionIdsToClose = positions + .map(({ id, position }, i) => ({ + positionId: id, + prevState: position.state, + nextState: latestPositionData[i]?.data?.state, + })) + .filter( + ({ prevState, nextState }) => + prevState?.state === PositionState_PositionStateEnum.OPENED && + nextState?.state === PositionState_PositionStateEnum.CLOSED, + ) + .map(({ positionId }) => positionId); + + const positionWithdraws = positions.map(({ id }, i) => ({ + positionId: id, + tradingPair: latestPositionData[i]?.data?.phi?.pair, + reserves: latestPositionData[i]?.data?.reserves, + state: latestPositionData[i]?.data?.state, + })); + + const positionCloses = positionWithdraws.filter(position => + positionIdsToClose.some(id => id.equals(position.positionId)), ); const planReq = new TransactionPlannerRequest({ - positionWithdraws: positions.map((positionId, i) => ({ - positionId, - tradingPair: latestPositionData[i]?.data?.phi?.pair, - reserves: latestPositionData[i]?.data?.reserves, - })), - positionCloses: positions.map((positionId, i) => ({ - positionId, - tradingPair: latestPositionData[i]?.data?.phi?.pair, - reserves: latestPositionData[i]?.data?.reserves, - })), + positionWithdraws, + positionCloses, source: new AddressIndex({ account: connectionStore.subaccount }), }); @@ -434,6 +439,7 @@ class PositionsStore { return { id: new PositionId(positionIdFromBech32(id)), idString: id, + position, orders, fee: `${pnum(component.fee / 100).toFormattedString({ decimals: 2 })}%`, // TODO: diff --git a/src/pages/trade/ui/positions/header-action-button.tsx b/src/pages/trade/ui/positions/header-action-button.tsx index 60455987..e20d4e2a 100644 --- a/src/pages/trade/ui/positions/header-action-button.tsx +++ b/src/pages/trade/ui/positions/header-action-button.tsx @@ -11,7 +11,10 @@ export const HeaderActionButton = observer( const openedPositions = displayPositions .filter(position => position.isOpened) .slice(0, MAX_ACTION_COUNT) - .map(position => position.id); + .map(position => ({ + id: position.id, + position: position.position, + })); if (openedPositions.length > 1) { return ( @@ -29,7 +32,10 @@ export const HeaderActionButton = observer( const closedPositions = displayPositions .filter(position => position.isClosed) .slice(0, MAX_ACTION_COUNT) - .map(position => position.id); + .map(position => ({ + id: position.id, + position: position.position, + })); if (closedPositions.length > 1) { return ( From 15e46e82195663bd19c1c9c67e5feb23a2be3cbe Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Thu, 16 Jan 2025 21:01:28 +0400 Subject: [PATCH 4/8] Remove state from payload --- src/pages/trade/model/positions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/trade/model/positions.ts b/src/pages/trade/model/positions.ts index a4096359..12bb244f 100644 --- a/src/pages/trade/model/positions.ts +++ b/src/pages/trade/model/positions.ts @@ -133,7 +133,6 @@ class PositionsStore { positionId: id, tradingPair: latestPositionData[i]?.data?.phi?.pair, reserves: latestPositionData[i]?.data?.reserves, - state: latestPositionData[i]?.data?.state, })); const positionCloses = positionWithdraws.filter(position => From 9aecb7741f7810cabb79d12f582befd8b1daadb9 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Thu, 16 Jan 2025 21:02:31 +0400 Subject: [PATCH 5/8] Set positionCloses to undefined when there are nonoe --- src/pages/trade/model/positions.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/trade/model/positions.ts b/src/pages/trade/model/positions.ts index 12bb244f..3c5f84aa 100644 --- a/src/pages/trade/model/positions.ts +++ b/src/pages/trade/model/positions.ts @@ -135,9 +135,11 @@ class PositionsStore { reserves: latestPositionData[i]?.data?.reserves, })); - const positionCloses = positionWithdraws.filter(position => - positionIdsToClose.some(id => id.equals(position.positionId)), - ); + const positionCloses = positionIdsToClose.length + ? positionWithdraws.filter(position => + positionIdsToClose.some(id => id.equals(position.positionId)), + ) + : undefined; const planReq = new TransactionPlannerRequest({ positionWithdraws, From 8fc380d186dc0ae51cf591ff667db57742326681 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Thu, 16 Jan 2025 21:11:30 +0400 Subject: [PATCH 6/8] Fix lint issues --- src/pages/trade/model/positions.ts | 2 +- .../trade/ui/positions/action-button.tsx | 56 +++++++++++-------- src/pages/trade/ui/positions/index.tsx | 2 +- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/pages/trade/model/positions.ts b/src/pages/trade/model/positions.ts index 3c5f84aa..eed74af2 100644 --- a/src/pages/trade/model/positions.ts +++ b/src/pages/trade/model/positions.ts @@ -18,7 +18,7 @@ import { penumbra } from '@/shared/const/penumbra.ts'; import { DexService } from '@penumbra-zone/protobuf'; import { openToast } from '@penumbra-zone/ui/Toast'; import { pnum } from '@penumbra-zone/types/pnum'; -import { bech32mPositionId, positionIdFromBech32 } from '@penumbra-zone/bech32m/plpid'; +import { positionIdFromBech32 } from '@penumbra-zone/bech32m/plpid'; import { updatePositionsQuery } from '@/pages/trade/api/positions'; import { BigNumber } from 'bignumber.js'; diff --git a/src/pages/trade/ui/positions/action-button.tsx b/src/pages/trade/ui/positions/action-button.tsx index a68b4b34..da7c226f 100644 --- a/src/pages/trade/ui/positions/action-button.tsx +++ b/src/pages/trade/ui/positions/action-button.tsx @@ -2,33 +2,41 @@ import { observer } from 'mobx-react-lite'; import { Button } from '@penumbra-zone/ui/Button'; import { Text } from '@penumbra-zone/ui/Text'; import { + Position, PositionId, PositionState_PositionStateEnum, } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; import { positionsStore } from '@/pages/trade/model/positions'; -export const ActionButton = observer( - ({ state, id }: { state: PositionState_PositionStateEnum; id: PositionId }) => { - const { loading, closePositions, withdrawPositions } = positionsStore; +export const ActionButton = observer(({ id, position }: { id: PositionId; position: Position }) => { + const { loading, closePositions, withdrawPositions } = positionsStore; + const state = position.state; - if (state === PositionState_PositionStateEnum.OPENED) { - return ( - - ); - } else if (state === PositionState_PositionStateEnum.CLOSED) { - return ( - - ); - } else { - return ( - - - - - ); - } - }, -); + if (state?.state === PositionState_PositionStateEnum.OPENED) { + return ( + + ); + } else if (state?.state === PositionState_PositionStateEnum.CLOSED) { + return ( + + ); + } else { + return ( + + - + + ); + } +}); diff --git a/src/pages/trade/ui/positions/index.tsx b/src/pages/trade/ui/positions/index.tsx index 50175697..0432e104 100644 --- a/src/pages/trade/ui/positions/index.tsx +++ b/src/pages/trade/ui/positions/index.tsx @@ -215,7 +215,7 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => {
- +
); From 8e18ad96ee42cfe64b5698e67eca540323a90f09 Mon Sep 17 00:00:00 2001 From: Tal Derei <70081547+TalDerei@users.noreply.github.com> Date: Thu, 16 Jan 2025 13:37:13 -0800 Subject: [PATCH 7/8] gas fees: display alternative gas fee metadata (#272) * display alternative gas fee metadata * use react query * cleanup --- src/pages/trade/ui/order-form/helpers.tsx | 1 + .../order-form/store/MarketOrderFormStore.ts | 6 +- .../ui/order-form/store/OrderFormStore.ts | 91 +++++++++++++------ src/shared/api/metadata.ts | 26 ++++++ src/shared/api/registry.ts | 14 +++ 5 files changed, 108 insertions(+), 30 deletions(-) create mode 100644 src/shared/api/metadata.ts diff --git a/src/pages/trade/ui/order-form/helpers.tsx b/src/pages/trade/ui/order-form/helpers.tsx index 7580ec4e..72b7907d 100644 --- a/src/pages/trade/ui/order-form/helpers.tsx +++ b/src/pages/trade/ui/order-form/helpers.tsx @@ -178,6 +178,7 @@ export const plan = async ( const build = async ( req: PartialMessage | PartialMessage, + // TODO: investigate @connectrpc/connect versions (1.6.1 vs 1.4.0) buildFn: PromiseClient['authorizeAndBuild' | 'witnessAndBuild'], onStatusUpdate: ( status?: (AuthorizeAndBuildResponse | WitnessAndBuildResponse)['status'], diff --git a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts index 9887641d..c04e238b 100644 --- a/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts @@ -228,8 +228,12 @@ export class MarketOrderFormStore { } get plan(): undefined | MarketOrderPlan { + // necessary for clearing the gas fee when the input for market orders is cleared if (!this._baseAsset || !this._quoteAsset) { - return; + return undefined; + } + if (!this._baseAssetInput || !this._quoteAssetInput) { + return undefined; } const { inputAsset, inputAmount, output } = this.direction === 'buy' diff --git a/src/pages/trade/ui/order-form/store/OrderFormStore.ts b/src/pages/trade/ui/order-form/store/OrderFormStore.ts index db07da7b..d8d30c2e 100644 --- a/src/pages/trade/ui/order-form/store/OrderFormStore.ts +++ b/src/pages/trade/ui/order-form/store/OrderFormStore.ts @@ -21,7 +21,7 @@ import { useMarketPrice } from '@/pages/trade/model/useMarketPrice'; import { getSwapCommitmentFromTx } from '@penumbra-zone/getters/transaction'; import { pnum } from '@penumbra-zone/types/pnum'; import debounce from 'lodash/debounce'; -import { useRegistryAssets } from '@/shared/api/registry'; +import { useStakingTokenMetadata } from '@/shared/api/registry'; import { plan, planBuildBroadcast } from '../helpers'; import { openToast } from '@penumbra-zone/ui/Toast'; import { @@ -30,7 +30,8 @@ import { getAddressIndex, } from '@penumbra-zone/getters/balances-response'; import { isMetadataEqual } from '@/shared/utils/is-metadata-equal'; -import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { AssetId, Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { getAssetMetadataById } from '@/shared/api/metadata'; export type WhichForm = 'Market' | 'Limit' | 'Range'; @@ -49,7 +50,7 @@ export class OrderFormStore { private _marketPrice: number | undefined = undefined; address?: Address; subAccountIndex?: AddressIndex; - private _umAsset?: AssetInfo; + private _feeAsset?: AssetInfo; private _gasFee: { symbol: string; display: string } = { symbol: 'UM', display: '--' }; private _gasFeeLoading = false; @@ -63,9 +64,11 @@ export class OrderFormStore { } private estimateGasFee = async (): Promise => { - if (!this.plan || !this._umAsset) { + if (!this.plan) { + this.resetGasFee(); return; } + runInAction(() => { this._gasFeeLoading = true; }); @@ -73,16 +76,24 @@ export class OrderFormStore { const res = await plan(this.plan); const fee = res.transactionParameters?.fee; if (!fee) { + this.resetGasFee(); return; } - runInAction(() => { - if (!this._umAsset) { + await runInAction(async () => { + // If the fee asset is the staking token, do nothing since it’s already handled in the useEffect + // below. Otherwise, set the fee to an alternative asset. + const feeAssetId = res.transactionParameters?.fee?.assetId; + if (feeAssetId) { + await this.setAlternativeFee(feeAssetId); + } + + if (!this._feeAsset) { return; } this._gasFee = { - symbol: this._umAsset.symbol, - display: pnum(fee.amount, this._umAsset.exponent).toNumber().toString(), + symbol: this._feeAsset.symbol, + display: pnum(fee.amount, this._feeAsset.exponent).toNumber().toString(), }; }); } catch (e) { @@ -94,8 +105,15 @@ export class OrderFormStore { } }; - setUmAsset = (x: AssetInfo) => { - this._umAsset = x; + resetGasFee() { + runInAction(() => { + this._gasFee = { symbol: 'UM', display: '--' }; + this._gasFeeLoading = false; + }); + } + + setFeeAsset = (x: AssetInfo) => { + this._feeAsset = x; }; setSubAccountIndex = (x: AddressIndex) => { @@ -106,8 +124,8 @@ export class OrderFormStore { this.address = x; }; - get umAsset(): AssetInfo | undefined { - return this._umAsset; + get feeAsset(): AssetInfo | undefined { + return this._feeAsset; } get gasFee(): { symbol: string; display: string } { @@ -118,6 +136,21 @@ export class OrderFormStore { return this._gasFeeLoading; } + async setAlternativeFee(feeAssetId: AssetId) { + const metadata = await getAssetMetadataById(feeAssetId); + if (!metadata) { + return; + } + + const assetInfo = AssetInfo.fromMetadata(metadata); + if (!assetInfo) { + return; + } + + // Update the order form store so that it uses this asset as the fee asset + orderFormStore.setFeeAsset(assetInfo); + } + setAssets(base: AssetInfo, quote: AssetInfo, unsetInputs: boolean) { this._market.setAssets(base, quote, unsetInputs); this._limit.setAssets(base, quote, unsetInputs); @@ -161,6 +194,7 @@ export class OrderFormStore { if (this._whichForm === 'Market') { const plan = this._market.plan; if (!plan) { + this.resetGasFee(); return undefined; } return new TransactionPlannerRequest({ @@ -171,6 +205,7 @@ export class OrderFormStore { if (this._whichForm === 'Limit') { const plan = this._limit.plan; if (!plan) { + this.resetGasFee(); return undefined; } return new TransactionPlannerRequest({ @@ -179,7 +214,8 @@ export class OrderFormStore { }); } const plan = this._range.plan; - if (plan === undefined) { + if (!plan) { + this.resetGasFee(); return undefined; } return new TransactionPlannerRequest({ @@ -198,6 +234,7 @@ export class OrderFormStore { const source = this.subAccountIndex; // Redundant, but makes typescript happier. if (!plan || !source) { + this.resetGasFee(); return; } @@ -286,7 +323,7 @@ const orderFormStore = new OrderFormStore(); export const useOrderFormStore = () => { const { subaccount } = connectionStore; - const { data: assets } = useRegistryAssets(); + const { data: registryUM } = useStakingTokenMetadata(); const { data: subAccounts } = useSubaccounts(); const { address, addressIndex } = getAccountAddress(subAccounts); const { data: balances } = useBalances(addressIndex?.account ?? subaccount); @@ -346,8 +383,18 @@ export const useOrderFormStore = () => { if (address && addressIndex) { orderFormStore.setSubAccountIndex(addressIndex); orderFormStore.setAddress(address); + + let umAsset: AssetInfo | undefined; + if (registryUM) { + umAsset = AssetInfo.fromMetadata(registryUM); + } + + if (umAsset && orderFormStore.feeAsset?.symbol !== umAsset.symbol) { + orderFormStore.setFeeAsset(umAsset); + orderFormStore.resetGasFee(); + } } - }, [address, addressIndex]); + }, [address, addressIndex, registryUM]); useEffect(() => { if (marketPrice) { @@ -355,19 +402,5 @@ export const useOrderFormStore = () => { } }, [marketPrice]); - useEffect(() => { - let umAsset: AssetInfo | undefined; - if (assets) { - const meta = assets.find(x => x.symbol === 'UM'); - if (meta) { - umAsset = AssetInfo.fromMetadata(meta); - } - } - - if (umAsset && orderFormStore.umAsset?.symbol !== umAsset.symbol) { - orderFormStore.setUmAsset(umAsset); - } - }, [assets]); - return orderFormStore; }; diff --git a/src/shared/api/metadata.ts b/src/shared/api/metadata.ts new file mode 100644 index 00000000..46f234d4 --- /dev/null +++ b/src/shared/api/metadata.ts @@ -0,0 +1,26 @@ +import { AssetId, Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { AssetMetadataByIdRequest } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { penumbra } from '../const/penumbra'; +import { ViewService } from '@penumbra-zone/protobuf'; +import { queryClient } from '../const/queryClient'; +import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64'; + +const assetMetadataQueryFn = async (assetId: AssetId) => { + const req = new AssetMetadataByIdRequest({ assetId }); + const { denomMetadata } = await penumbra.service(ViewService).assetMetadataById(req); + return denomMetadata; +}; + +const getAssetMetadataQueryOptions = (assetId: AssetId) => ({ + queryKey: ['assetMetadata', uint8ArrayToBase64(assetId.inner)], + queryFn: () => assetMetadataQueryFn(assetId), + staleTime: Infinity, +}); + +/** + * Fetch query (non-hook) that retrieves and caches asset metadata for a given + * asset ID from the Penumbra view service. + */ +export const getAssetMetadataById = async (assetId: AssetId): Promise => { + return queryClient.fetchQuery(getAssetMetadataQueryOptions(assetId)); +}; diff --git a/src/shared/api/registry.ts b/src/shared/api/registry.ts index a2f64ff2..3411dac9 100644 --- a/src/shared/api/registry.ts +++ b/src/shared/api/registry.ts @@ -2,6 +2,7 @@ import { ChainRegistryClient } from '@penumbra-labs/registry'; import { useQuery } from '@tanstack/react-query'; import { envQueryFn } from './env/env'; import { Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { getAssetMetadataById } from './metadata'; export const chainRegistryClient = new ChainRegistryClient(); @@ -10,6 +11,19 @@ export const registryQueryFn = async () => { return chainRegistryClient.remote.get(env.PENUMBRA_CHAIN_ID); }; +export const useStakingTokenMetadata = () => { + return useQuery({ + queryKey: ['stakingTokenMetadata'], + queryFn: async (): Promise => { + const { stakingAssetId } = chainRegistryClient.bundled.globals(); + const stakingAssetsMetadata = await getAssetMetadataById(stakingAssetId); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- staking token exists in registry + return stakingAssetsMetadata!; + }, + staleTime: Infinity, + }); +}; + export const useRegistryAssets = () => { return useQuery({ queryKey: ['penumbraRegistryAssets'], From 03cf9564e4f81b2c096b68adec78b88879997010 Mon Sep 17 00:00:00 2001 From: "Jason M. Hasperhoven" Date: Fri, 17 Jan 2025 02:35:35 +0400 Subject: [PATCH 8/8] Update withdrawn amounts & prices to show dash (#277) --- src/pages/trade/model/positions.ts | 4 +- src/pages/trade/ui/positions.tsx | 133 ++++++++++++++++------------- 2 files changed, 78 insertions(+), 59 deletions(-) diff --git a/src/pages/trade/model/positions.ts b/src/pages/trade/model/positions.ts index affeda79..df62149f 100644 --- a/src/pages/trade/model/positions.ts +++ b/src/pages/trade/model/positions.ts @@ -34,7 +34,7 @@ export interface DisplayPosition { quoteAsset: CalculatedAsset; }[]; fee: string; - isActive: boolean; + isWithdrawn: boolean; state: PositionState_PositionStateEnum; } @@ -416,7 +416,7 @@ class PositionsStore { // We do not yet filter `Closed` positions to allow auto-closing position to provide visual // feedback about execution. This is probably best later replaced by either a notification, or a // dedicated view. Fine for now. - isActive: state.state !== PositionState_PositionStateEnum.WITHDRAWN, + isWithdrawn: state.state === PositionState_PositionStateEnum.WITHDRAWN, state: state.state, }; }); diff --git a/src/pages/trade/ui/positions.tsx b/src/pages/trade/ui/positions.tsx index d4bf1ec0..1dcd97d9 100644 --- a/src/pages/trade/ui/positions.tsx +++ b/src/pages/trade/ui/positions.tsx @@ -77,6 +77,12 @@ const getStateLabel = ( } }; +const Dash = () => ( + + - + +); + const ActionButton = observer( ({ state, id }: { state: PositionState_PositionStateEnum; id: PositionId }) => { const { loading, closePositions, withdrawPositions } = positionsStore; @@ -94,11 +100,7 @@ const ActionButton = observer( ); } else { - return ( - - - - - ); + return ; } }, ); @@ -237,13 +239,14 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => { ))} {displayPositions - .filter(position => (showInactive ? true : position.isActive)) + .filter(position => (showInactive ? true : !position.isWithdrawn)) .map(position => { return (
{position.orders + .slice(0, position.isWithdrawn ? 1 : position.orders.length - 1) .map(order => getStateLabel(position.state, order.direction)) .map(({ label, color }, i) => ( @@ -254,51 +257,59 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => {
- {position.orders.map((order, i) => ( - - ))} + {position.isWithdrawn ? ( + + ) : ( + position.orders.map((order, i) => ( + + )) + )}
{/* Fight display inline 4 px spacing */}
- {position.orders.map((order, i) => ( - - - Base price: {pnum(order.basePrice).toFormattedString()} - - - Fee:{' '} - {pnum(order.basePrice) - .toBigNumber() - .minus(pnum(order.effectivePrice).toBigNumber()) - .toString()}{' '} - ({position.fee}) - - - Effective price:{' '} - {pnum(order.effectivePrice).toFormattedString()} - - - } - > -
- -
-
- ))} + {position.isWithdrawn ? ( + + ) : ( + position.orders.map((order, i) => ( + + + Base price: {pnum(order.basePrice).toFormattedString()} + + + Fee:{' '} + {pnum(order.basePrice) + .toBigNumber() + .minus(pnum(order.effectivePrice).toBigNumber()) + .toString()}{' '} + ({position.fee}) + + + Effective price:{' '} + {pnum(order.effectivePrice).toFormattedString()} + + + } + > +
+ +
+
+ )) + )}
@@ -308,21 +319,29 @@ const Positions = observer(({ showInactive }: { showInactive: boolean }) => {
- {position.orders.map((order, i) => ( - - ))} + {position.isWithdrawn ? ( + + ) : ( + position.orders.map((order, i) => ( + + )) + )}
- {position.orders.map((order, i) => ( - - ))} + {position.isWithdrawn ? ( + + ) : ( + position.orders.map((order, i) => ( + + )) + )}