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),
});
};