Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Display LP positions #1786

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 91 additions & 10 deletions apps/minifront/src/components/swap/lp-positions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,107 @@ 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
Comment on lines -6 to -7
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer relevant

export const LpPositions = () => {
const { data, error } = useOwnedPositions();
const { onAction, txInProgress } = useStoreShallow(lPActionSelector);

return !data?.length ? (
<div className='hidden xl:block' />
) : (
<Card layout>
<GradientHeader layout>Limit orders</GradientHeader>
{!!error && <div>❌ There was an error loading your limit orders: ${String(error)}</div>}
{data.map(({ positionId }) => {
const base64Id = bech32mPositionId(positionId ?? { inner: new Uint8Array() });
return (
<div key={base64Id} className='flex items-center gap-4 p-2'>
{base64Id}
</div>
);
})}
<div className='flex flex-col gap-4'>
{data.map(p => {
const bech32Id = bech32mPositionId(p.id);
return (
<div key={bech32Id} className='flex flex-col gap-4 p-2'>
<div className='flex justify-between gap-2'>
<div className='flex grow flex-col gap-2'>
<div className='flex items-center gap-2'>
<div
className={cn(
'text-white flex items-center justify-center rounded p-1 h-7',
p.position.state?.state === PositionState_PositionStateEnum.OPENED
? 'bg-teal'
: 'bg-rust',
)}
>
<span className='mt-1'>{stateToString(p.position.state?.state)}</span>
</div>
<div className='max-w-[250px] overflow-hidden truncate text-gray-300 lg:max-w-[400px]'>
{bech32Id}
</div>
</div>
<div className='flex flex-wrap gap-2'>
<ValueViewComponent view={p.r1ValueView} />
<ValueViewComponent view={p.r2ValueView} />
</div>
</div>
<div className='shrink-0'>
{p.position.state?.state === PositionState_PositionStateEnum.OPENED && (
<Button
size='sm'
variant='secondary'
className='w-full'
disabled={txInProgress}
onClick={() => void onAction('positionClose', p)}
>
Close
</Button>
)}
{p.position.state?.state === PositionState_PositionStateEnum.CLOSED && (
<Button
size='sm'
variant='secondary'
className='w-full'
disabled={txInProgress}
onClick={() => void onAction('positionWithdraw', p)}
>
Withdraw
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
</Card>
);
};
6 changes: 3 additions & 3 deletions apps/minifront/src/components/swap/swap-form/limit-order.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<h1>Limit order</h1>
<button onClick={() => void onSubmit()}>SEND LIMIT ORDER</button>
<button onClick={() => void open()}>SEND LIMIT ORDER</button>
</div>
);
};
194 changes: 160 additions & 34 deletions apps/minifront/src/state/swap/lp-positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PositionWithId> {
// 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];
},
};
Expand All @@ -37,11 +119,12 @@ export const { ownedPositions, useOwnedPositions } = createZQuery({
});

interface Actions {
onSubmit: () => Promise<void>;
onAction: (action: 'positionClose' | 'positionWithdraw', p: PositionWithId) => Promise<void>;
open: () => Promise<void>;
}

interface State {
ownedPositions: ZQueryState<OwnedPositionIdsResponse[]>;
ownedPositions: ZQueryState<PositionWithId[]>;
}

export type LpPositionsSlice = Actions & State;
Expand All @@ -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<AddressIndex | undefined> => {
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<LpPositionsSlice> => (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;
Expand All @@ -75,7 +192,31 @@ export const createLpPositionsSlice = (): SliceCreator<LpPositionsSlice> => (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');
Expand Down Expand Up @@ -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),
});
};
Loading