diff --git a/apps/minifront/src/components/swap/lp-positions.tsx b/apps/minifront/src/components/swap/lp-positions.tsx index 161e4e4219..77194b8a0a 100644 --- a/apps/minifront/src/components/swap/lp-positions.tsx +++ b/apps/minifront/src/components/swap/lp-positions.tsx @@ -2,11 +2,44 @@ import { Card } from '@penumbra-zone/ui/components/ui/card'; import { GradientHeader } from '@penumbra-zone/ui/components/ui/gradient-header'; import { useOwnedPositions } from '../../state/swap/lp-positions.ts'; import { bech32mPositionId } from '@penumbra-zone/bech32m/plpid'; +import { PositionState_PositionStateEnum } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/value'; +import { Button } from '@penumbra-zone/ui/components/ui/button'; +import { cn } from '@penumbra-zone/ui/lib/utils'; +import { AllSlices } from '../../state'; +import { useStoreShallow } from '../../utils/use-store-shallow.ts'; + +const stateToString = (state?: PositionState_PositionStateEnum): string => { + switch (state) { + case PositionState_PositionStateEnum.UNSPECIFIED: { + return 'UNSPECIFIED'; + } + case PositionState_PositionStateEnum.OPENED: { + return 'OPENED'; + } + case PositionState_PositionStateEnum.CLOSED: { + return 'CLOSED'; + } + case PositionState_PositionStateEnum.WITHDRAWN: { + return 'WITHDRAWN'; + } + case PositionState_PositionStateEnum.CLAIMED: { + return 'CLAIMED'; + } + case undefined: { + return 'UNSPECIFIED'; + } + } +}; + +const lPActionSelector = ({ swap }: AllSlices) => ({ + onAction: swap.lpPositions.onAction, + txInProgress: swap.txInProgress, +}); -// TODO: Ids are not sufficient in taking action on these -// Required to move forward with this: https://github.com/penumbra-zone/penumbra/pull/4837 export const LpPositions = () => { const { data, error } = useOwnedPositions(); + const { onAction, txInProgress } = useStoreShallow(lPActionSelector); return !data?.length ? (
@@ -14,14 +47,62 @@ export const LpPositions = () => { Limit orders {!!error &&
❌ There was an error loading your limit orders: ${String(error)}
} - {data.map(({ positionId }) => { - const base64Id = bech32mPositionId(positionId ?? { inner: new Uint8Array() }); - return ( -
- {base64Id} -
- ); - })} +
+ {data.map(p => { + const bech32Id = bech32mPositionId(p.id); + return ( +
+
+
+
+
+ {stateToString(p.position.state?.state)} +
+
+ {bech32Id} +
+
+
+ + +
+
+
+ {p.position.state?.state === PositionState_PositionStateEnum.OPENED && ( + + )} + {p.position.state?.state === PositionState_PositionStateEnum.CLOSED && ( + + )} +
+
+
+ ); + })} +
); }; diff --git a/apps/minifront/src/components/swap/swap-form/limit-order.tsx b/apps/minifront/src/components/swap/swap-form/limit-order.tsx index 5895ffe2dd..85c879452c 100644 --- a/apps/minifront/src/components/swap/swap-form/limit-order.tsx +++ b/apps/minifront/src/components/swap/swap-form/limit-order.tsx @@ -5,16 +5,16 @@ const limitOrderSelector = (state: AllSlices) => ({ assetIn: state.swap.assetIn, assetOut: state.swap.assetOut, amount: state.swap.amount, - onSubmit: state.swap.lpPositions.onSubmit, + open: state.swap.lpPositions.open, }); export const LimitOrder = () => { - const { onSubmit } = useStoreShallow(limitOrderSelector); + const { open } = useStoreShallow(limitOrderSelector); return (

Limit order

- +
); }; diff --git a/apps/minifront/src/state/swap/lp-positions.ts b/apps/minifront/src/state/swap/lp-positions.ts index 88fedc5561..790244b8c6 100644 --- a/apps/minifront/src/state/swap/lp-positions.ts +++ b/apps/minifront/src/state/swap/lp-positions.ts @@ -2,26 +2,108 @@ import { SliceCreator, useStore } from '..'; import { createZQuery, ZQueryState } from '@penumbra-zone/zquery'; import { penumbra } from '../../prax.ts'; import { ViewService } from '@penumbra-zone/protobuf/penumbra/view/v1/view_connect'; -import { - OwnedPositionIdsResponse, - TransactionPlannerRequest, -} from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; +import { TransactionPlannerRequest } from '@penumbra-zone/protobuf/penumbra/view/v1/view_pb'; import { SwapSlice } from './index.ts'; import { isValidAmount, planBuildBroadcast } from '../helpers.ts'; import { getAddressIndex } from '@penumbra-zone/getters/address-view'; import { getAssetIdFromValueView } from '@penumbra-zone/getters/value-view'; -import { PositionState_PositionStateEnum } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; -import { base64ToUint8Array } from '@penumbra-zone/types/base64'; +import { + Position, + PositionId, + PositionState_PositionStateEnum, +} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { DexService } from '@penumbra-zone/protobuf'; +import { AssetId, ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; +import { Amount } from '@penumbra-zone/protobuf/penumbra/core/num/v1/num_pb'; +import { AddressIndex } from '@penumbra-zone/protobuf/penumbra/core/keys/v1/keys_pb'; +import { getBalancesStream } from '../../fetchers/balances'; +import { bech32mPositionId } from '@penumbra-zone/bech32m/plpid'; +import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; + +export interface PositionWithId { + id: PositionId; + position: Position; + r1ValueView: ValueView; + r2ValueView: ValueView; +} + +const toValueView = async (assetId?: AssetId, amount?: Amount) => { + if (!assetId) { + return new ValueView({ + valueView: { case: 'unknownAssetId', value: { amount, assetId } }, + }); + } + + const { denomMetadata } = await penumbra.service(ViewService).assetMetadataById({ assetId }); + if (denomMetadata) { + return new ValueView({ + valueView: { case: 'knownAssetId', value: { amount, metadata: denomMetadata } }, + }); + } else { + return new ValueView({ + valueView: { case: 'unknownAssetId', value: { amount, assetId } }, + }); + } +}; + +// Collects the stream of owned positions and then yields the positions results in a stream +const fetchOwnedPositions = async function* (): AsyncIterable { + // We only care about opened and closed state. If withdrawn, not helpful to display in the UI. + const openedIds = await Array.fromAsync( + penumbra + .service(ViewService) + .ownedPositionIds({ positionState: { state: PositionState_PositionStateEnum.OPENED } }), + ); + const closedIds = await Array.fromAsync( + penumbra + .service(ViewService) + .ownedPositionIds({ positionState: { state: PositionState_PositionStateEnum.CLOSED } }), + ); + + const allPositionIds = [...openedIds, ...closedIds] + .map(i => i.positionId) + .filter(Boolean) as PositionId[]; + + // We then need to retrieve the LP data for each of these ID's from the node + const iterable = penumbra + .service(DexService) + .liquidityPositionsById({ positionId: allPositionIds }); + + let index = 0; + for await (const res of iterable) { + const id = allPositionIds[index]; // responses are emitted in the order of the input ids + if (!id) { + throw new Error(`No corresponding ID in request array for position index ${index}`); + } + + if ( + res.data && + // TODO: Bug w/ testnet whale seedphrase on penumbra-1 LP position + // https://dex.penumbra.sevenseas.capital/lp/plpid17wqat9ppwkjk8hffpk2jz669c3u5hzm8268jjlf6j88qju7d238qak905k + // Stored as an opened position in indexeddb, but when querying `liquidityPositionsById()`, it's actually already withdrawn + // Doing a temp additional filter here so users don't see that state. + [PositionState_PositionStateEnum.OPENED, PositionState_PositionStateEnum.CLOSED].includes( + res.data.state?.state ?? PositionState_PositionStateEnum.UNSPECIFIED, + ) + ) { + yield { + id, + position: res.data, + r1ValueView: await toValueView(res.data.phi?.pair?.asset1, res.data.reserves?.r1), + r2ValueView: await toValueView(res.data.phi?.pair?.asset2, res.data.reserves?.r2), + }; + } + + index = index + 1; + } +}; export const { ownedPositions, useOwnedPositions } = createZQuery({ name: 'ownedPositions', - fetch: () => penumbra.service(ViewService).ownedPositionIds({}), + fetch: fetchOwnedPositions, stream: () => { return { - onValue: ( - prevState: OwnedPositionIdsResponse[] | undefined, - response: OwnedPositionIdsResponse, - ) => { + onValue: (prevState: PositionWithId[] | undefined, response: PositionWithId) => { return [...(prevState ?? []), response]; }, }; @@ -37,11 +119,12 @@ export const { ownedPositions, useOwnedPositions } = createZQuery({ }); interface Actions { - onSubmit: () => Promise; + onAction: (action: 'positionClose' | 'positionWithdraw', p: PositionWithId) => Promise; + open: () => Promise; } interface State { - ownedPositions: ZQueryState; + ownedPositions: ZQueryState; } export type LpPositionsSlice = Actions & State; @@ -50,22 +133,56 @@ const INITIAL_STATE: State = { ownedPositions, }; +// TODO: This is a slow operation. We should revise LiquidityPositionsResponse to: +// message LiquidityPositionsResponse { +// Position data = 1; +// // === new === +// PositionId position_id = 2; +// SpendableNoteRecord note_record = 3; +// ValueView r1_value_view = 4; +// ValueView r2_value_view = 5; +// } +const getSourceForPosition = async (pId: PositionId): Promise => { + const bech32Id = bech32mPositionId(pId); + const responses = await Array.fromAsync(getBalancesStream()); + for (const r of responses) { + const baseDenom = getMetadataFromBalancesResponse.optional(r)?.base; + if (baseDenom?.includes(bech32Id)) { + return getAddressIndex.optional(r.accountAddress); + } + } + return undefined; +}; + export const createLpPositionsSlice = (): SliceCreator => (set, get) => { return { ...INITIAL_STATE, - onSubmit: async () => { + onAction: async (action, { id, position }) => { try { set(state => { state.swap.txInProgress = true; }); - const txPlannerRequest = assembleLimitOrderReq(get().swap); - await planBuildBroadcast('positionOpen', txPlannerRequest); + const txPlannerRequest = await assembleReq(action, id, position); + await planBuildBroadcast(action, txPlannerRequest); + get().swap.lpPositions.ownedPositions.revalidate(); + } finally { + set(state => { + state.swap.txInProgress = false; + }); + } + }, + open: async () => { + try { set(state => { - state.swap.amount = ''; + state.swap.txInProgress = true; }); - get().shared.balancesResponses.revalidate(); + + const txPlannerRequest = assembleLimitOrderReq(get().swap); + await planBuildBroadcast('positionOpen', txPlannerRequest); + + get().swap.lpPositions.ownedPositions.revalidate(); } finally { set(state => { state.swap.txInProgress = false; @@ -75,7 +192,31 @@ export const createLpPositionsSlice = (): SliceCreator => (set }; }; -// TODO: This is temporary data for testing purposes. Update with inputs when component is ready. +const assembleReq = async (action: string, positionId: PositionId, position: Position) => { + const source = await getSourceForPosition(positionId); + if (!source) { + throw new Error(`Could not find source for ${bech32mPositionId(positionId)}`); + } + + if (action === 'positionClose') { + return new TransactionPlannerRequest({ + positionCloses: [{ positionId }], + source, + }); + } + + if (action === 'positionWithdraw') { + return new TransactionPlannerRequest({ + positionWithdraws: [ + { positionId, reserves: position.reserves, tradingPair: position.phi?.pair }, + ], + source, + }); + } + + throw new Error(`Action not implemented: ${action}`); +}; + const assembleLimitOrderReq = ({ assetIn, amount, assetOut }: SwapSlice) => { if (!assetIn) { throw new Error('`assetIn` is undefined'); @@ -105,21 +246,6 @@ const assembleLimitOrderReq = ({ assetIn, amount, assetOut }: SwapSlice) => { }, }, ], - positionCloses: [ - { - positionId: { inner: base64ToUint8Array('/C9cn0d8veH0IGt2SCghzfcCWkPWbgUDXpXOPgZyA8c=') }, - }, - ], - positionWithdraws: [ - { - positionId: { inner: base64ToUint8Array('+vbub7BbEAAKLqRorZbNZ4yixPNVFzGl1BAexym3mDc=') }, - reserves: { r1: { lo: 1000000n }, r2: {} }, - tradingPair: { - asset1: getAssetIdFromValueView(assetIn.balanceView), - asset2: assetOut.penumbraAssetId, - }, - }, - ], source: getAddressIndex(assetIn.accountAddress), }); };