Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Fix auto-closing orders withdrawal #282

Merged
merged 10 commits into from
Jan 17, 2025
59 changes: 45 additions & 14 deletions src/pages/trade/model/positions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { BigNumber } from 'bignumber.js';
export interface DisplayPosition {
id: PositionId;
idString: string;
position: Position;
orders: {
direction: string;
amount: ValueView;
Expand All @@ -34,7 +35,9 @@ export interface DisplayPosition {
quoteAsset: CalculatedAsset;
}[];
fee: string;
isActive: boolean;
isWithdrawn: boolean;
isOpened: boolean;
isClosed: boolean;
state: PositionState_PositionStateEnum;
}

Expand Down Expand Up @@ -76,12 +79,12 @@ class PositionsStore {
this.loading = state;
}

closePositions = async (positions: PositionId[]): Promise<void> => {
closePositions = async (positions: { id: PositionId; position: Position }[]): Promise<void> => {
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 }),
});

Expand All @@ -98,22 +101,49 @@ class PositionsStore {
}
};

withdrawPositions = async (positions: PositionId[]): Promise<void> => {
withdrawPositions = async (
positions: { id: PositionId; position: Position }[],
): Promise<void> => {
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);

// 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);
Copy link
Member

Choose a reason for hiding this comment

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

👍


const positionWithdraws = positions.map(({ id }, i) => ({
positionId: id,
tradingPair: latestPositionData[i]?.data?.phi?.pair,
reserves: latestPositionData[i]?.data?.reserves,
}));

const positionCloses = positionIdsToClose.length
? positionWithdraws.filter(position =>
positionIdsToClose.some(id => id.equals(position.positionId)),
)
: undefined;

const planReq = new TransactionPlannerRequest({
positionWithdraws: positions.map((positionId, i) => ({
positionId,
tradingPair: latestPositionData[i]?.data?.phi?.pair,
reserves: latestPositionData[i]?.data?.reserves,
})),
positionWithdraws,
positionCloses,
source: new AddressIndex({ account: connectionStore.subaccount }),
});

Expand Down Expand Up @@ -410,18 +440,19 @@ class PositionsStore {
return {
id: new PositionId(positionIdFromBech32(id)),
idString: id,
position,
orders,
fee: `${pnum(component.fee / 100).toFormattedString({ decimals: 2 })}%`,
// TODO:
// 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,
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)
}
}

Expand Down
1 change: 1 addition & 0 deletions src/pages/trade/ui/order-form/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export const plan = async (

const build = async (
req: PartialMessage<AuthorizeAndBuildRequest> | PartialMessage<WitnessAndBuildRequest>,
// TODO: investigate @connectrpc/connect versions (1.6.1 vs 1.4.0)
buildFn: PromiseClient<typeof ViewService>['authorizeAndBuild' | 'witnessAndBuild'],
onStatusUpdate: (
status?: (AuthorizeAndBuildResponse | WitnessAndBuildResponse)['status'],
Expand Down
6 changes: 5 additions & 1 deletion src/pages/trade/ui/order-form/store/MarketOrderFormStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
91 changes: 62 additions & 29 deletions src/pages/trade/ui/order-form/store/OrderFormStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';

Expand All @@ -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;

Expand All @@ -63,26 +64,36 @@ export class OrderFormStore {
}

private estimateGasFee = async (): Promise<void> => {
if (!this.plan || !this._umAsset) {
if (!this.plan) {
this.resetGasFee();
return;
}

runInAction(() => {
this._gasFeeLoading = true;
});
try {
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) {
Expand All @@ -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) => {
Expand All @@ -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 } {
Expand All @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -179,7 +214,8 @@ export class OrderFormStore {
});
}
const plan = this._range.plan;
if (plan === undefined) {
if (!plan) {
this.resetGasFee();
return undefined;
}
return new TransactionPlannerRequest({
Expand All @@ -198,6 +234,7 @@ export class OrderFormStore {
const source = this.subAccountIndex;
// Redundant, but makes typescript happier.
if (!plan || !source) {
this.resetGasFee();
return;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -346,28 +383,24 @@ 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) {
orderFormStore.setMarketPrice(marketPrice);
}
}, [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;
};
42 changes: 42 additions & 0 deletions src/pages/trade/ui/positions/action-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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(({ id, position }: { id: PositionId; position: Position }) => {
const { loading, closePositions, withdrawPositions } = positionsStore;
const state = position.state;

if (state?.state === PositionState_PositionStateEnum.OPENED) {
return (
<Button
density='slim'
onClick={() => void closePositions([{ position, id }])}
disabled={loading}
>
Close
</Button>
);
} else if (state?.state === PositionState_PositionStateEnum.CLOSED) {
return (
<Button
density='slim'
disabled={loading}
onClick={() => void withdrawPositions([{ position, id }])}
>
Withdraw
</Button>
);
} else {
return (
<Text detail color='text.secondary'>
-
</Text>
);
}
});
12 changes: 12 additions & 0 deletions src/pages/trade/ui/positions/error-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { BlockchainError } from '@/shared/ui/blockchain-error';

export const ErrorNotice = () => {
return (
<div className='min-h-screen flex items-center justify-center'>
<BlockchainError
message='An error occurred while loading data from the blockchain'
direction='column'
/>
</div>
);
};
Loading