From e7d07671672d7e315dd9a6f3bd6f7eb0ec9e23fb Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Thu, 29 Aug 2024 09:27:20 +0200 Subject: [PATCH] LP action views (#1739) * Owned positions swap section * working example * Add metadata & action views * Review updates * Fix function labels --- .changeset/nice-mugs-dance.md | 6 + .changeset/quick-buttons-care.md | 7 + apps/minifront/src/components/swap/layout.tsx | 3 + .../src/components/swap/lp-positions.tsx | 27 ++++ .../src/components/swap/swap-form/index.tsx | 3 + .../components/swap/swap-form/limit-order.tsx | 20 +++ apps/minifront/src/state/swap/index.ts | 7 +- apps/minifront/src/state/swap/lp-positions.ts | 125 +++++++++++++++++ .../perspective/src/plan/view-action-plan.ts | 83 ++++++++++-- packages/query/src/block-processor.ts | 6 +- packages/ui/components/ui/tx/action-view.tsx | 9 +- .../ui/tx/actions-views/position-close.tsx | 21 +++ .../ui/tx/actions-views/position-open.tsx | 126 ++++++++++++++++++ .../ui/tx/actions-views/position-withdraw.tsx | 32 +++++ packages/wasm/crate/src/metadata.rs | 76 +++++++++++ packages/wasm/crate/src/planner.rs | 44 ++++-- 16 files changed, 567 insertions(+), 28 deletions(-) create mode 100644 .changeset/nice-mugs-dance.md create mode 100644 .changeset/quick-buttons-care.md create mode 100644 apps/minifront/src/components/swap/lp-positions.tsx create mode 100644 apps/minifront/src/components/swap/swap-form/limit-order.tsx create mode 100644 apps/minifront/src/state/swap/lp-positions.ts create mode 100644 packages/ui/components/ui/tx/actions-views/position-close.tsx create mode 100644 packages/ui/components/ui/tx/actions-views/position-open.tsx create mode 100644 packages/ui/components/ui/tx/actions-views/position-withdraw.tsx diff --git a/.changeset/nice-mugs-dance.md b/.changeset/nice-mugs-dance.md new file mode 100644 index 0000000000..8ca83f1ae0 --- /dev/null +++ b/.changeset/nice-mugs-dance.md @@ -0,0 +1,6 @@ +--- +'@penumbra-zone/query': minor +'@penumbra-zone/wasm': minor +--- + +Customize symbol for LP position NFTs diff --git a/.changeset/quick-buttons-care.md b/.changeset/quick-buttons-care.md new file mode 100644 index 0000000000..2505207813 --- /dev/null +++ b/.changeset/quick-buttons-care.md @@ -0,0 +1,7 @@ +--- +'@penumbra-zone/perspective': minor +'minifront': minor +'@penumbra-zone/ui': minor +--- + +Support for displaying LP position action views diff --git a/apps/minifront/src/components/swap/layout.tsx b/apps/minifront/src/components/swap/layout.tsx index 4ca72eb538..0b2bc29509 100644 --- a/apps/minifront/src/components/swap/layout.tsx +++ b/apps/minifront/src/components/swap/layout.tsx @@ -15,6 +15,9 @@ export const SwapLayout = () => { + {/* TODO: Will enable in subsequent PR */} + {/* */} + diff --git a/apps/minifront/src/components/swap/lp-positions.tsx b/apps/minifront/src/components/swap/lp-positions.tsx new file mode 100644 index 0000000000..161e4e4219 --- /dev/null +++ b/apps/minifront/src/components/swap/lp-positions.tsx @@ -0,0 +1,27 @@ +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'; + +// 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(); + + return !data?.length ? ( +
+ ) : ( + + 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} +
+ ); + })} +
+ ); +}; diff --git a/apps/minifront/src/components/swap/swap-form/index.tsx b/apps/minifront/src/components/swap/swap-form/index.tsx index 97f013aff9..c61b45d4ff 100644 --- a/apps/minifront/src/components/swap/swap-form/index.tsx +++ b/apps/minifront/src/components/swap/swap-form/index.tsx @@ -44,6 +44,9 @@ export const SwapForm = () => { + {/* TODO: Enable in subsequent PR */} + {/* */} + {duration === 'instant' ? ( ) : ( diff --git a/apps/minifront/src/components/swap/swap-form/limit-order.tsx b/apps/minifront/src/components/swap/swap-form/limit-order.tsx new file mode 100644 index 0000000000..5895ffe2dd --- /dev/null +++ b/apps/minifront/src/components/swap/swap-form/limit-order.tsx @@ -0,0 +1,20 @@ +import { AllSlices } from '../../../state'; +import { useStoreShallow } from '../../../utils/use-store-shallow.ts'; + +const limitOrderSelector = (state: AllSlices) => ({ + assetIn: state.swap.assetIn, + assetOut: state.swap.assetOut, + amount: state.swap.amount, + onSubmit: state.swap.lpPositions.onSubmit, +}); + +export const LimitOrder = () => { + const { onSubmit } = useStoreShallow(limitOrderSelector); + + return ( +
+

Limit order

+ +
+ ); +}; diff --git a/apps/minifront/src/state/swap/index.ts b/apps/minifront/src/state/swap/index.ts index 683e29f00c..da145cc96f 100644 --- a/apps/minifront/src/state/swap/index.ts +++ b/apps/minifront/src/state/swap/index.ts @@ -17,17 +17,18 @@ import { createPriceHistorySlice, PriceHistorySlice } from './price-history'; import { isValidAmount } from '../helpers'; import { setSwapQueryParams } from './query-params'; -import { swappableBalancesResponsesSelector, swappableAssetsSelector } from './helpers'; +import { swappableAssetsSelector, swappableBalancesResponsesSelector } from './helpers'; import { getMetadataFromBalancesResponse } from '@penumbra-zone/getters/balances-response'; import { getAddressIndex } from '@penumbra-zone/getters/address-view'; import { emptyBalanceResponse } from '../../utils/empty-balance-response'; import { balancesResponseAndMetadataAreSameAsset, + getBalanceByMatchingMetadataAndAddressIndex, getFirstBalancesResponseMatchingMetadata, getFirstBalancesResponseNotMatchingMetadata, getFirstMetadataNotMatchingBalancesResponse, - getBalanceByMatchingMetadataAndAddressIndex, } from './getters'; +import { createLpPositionsSlice, LpPositionsSlice } from './lp-positions.ts'; export interface SimulateSwapResult { metadataByAssetId: Record; @@ -58,6 +59,7 @@ interface Subslices { dutchAuction: DutchAuctionSlice; instantSwap: InstantSwapSlice; priceHistory: PriceHistorySlice; + lpPositions: LpPositionsSlice; } const INITIAL_STATE: State = { @@ -72,6 +74,7 @@ export const createSwapSlice = (): SliceCreator => (set, get, store) ...INITIAL_STATE, dutchAuction: createDutchAuctionSlice()(set, get, store), instantSwap: createInstantSwapSlice()(set, get, store), + lpPositions: createLpPositionsSlice()(set, get, store), priceHistory: createPriceHistorySlice()(set, get, store), setAssetIn: asset => { get().swap.resetSubslices(); diff --git a/apps/minifront/src/state/swap/lp-positions.ts b/apps/minifront/src/state/swap/lp-positions.ts new file mode 100644 index 0000000000..88fedc5561 --- /dev/null +++ b/apps/minifront/src/state/swap/lp-positions.ts @@ -0,0 +1,125 @@ +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 { 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'; + +export const { ownedPositions, useOwnedPositions } = createZQuery({ + name: 'ownedPositions', + fetch: () => penumbra.service(ViewService).ownedPositionIds({}), + stream: () => { + return { + onValue: ( + prevState: OwnedPositionIdsResponse[] | undefined, + response: OwnedPositionIdsResponse, + ) => { + return [...(prevState ?? []), response]; + }, + }; + }, + getUseStore: () => useStore, + get: state => state.swap.lpPositions.ownedPositions, + set: setter => { + const newState = setter(useStore.getState().swap.lpPositions.ownedPositions); + useStore.setState(state => { + state.swap.lpPositions.ownedPositions = newState; + }); + }, +}); + +interface Actions { + onSubmit: () => Promise; +} + +interface State { + ownedPositions: ZQueryState; +} + +export type LpPositionsSlice = Actions & State; + +const INITIAL_STATE: State = { + ownedPositions, +}; + +export const createLpPositionsSlice = (): SliceCreator => (set, get) => { + return { + ...INITIAL_STATE, + onSubmit: async () => { + try { + set(state => { + state.swap.txInProgress = true; + }); + + const txPlannerRequest = assembleLimitOrderReq(get().swap); + await planBuildBroadcast('positionOpen', txPlannerRequest); + + set(state => { + state.swap.amount = ''; + }); + get().shared.balancesResponses.revalidate(); + } finally { + set(state => { + state.swap.txInProgress = false; + }); + } + }, + }; +}; + +// TODO: This is temporary data for testing purposes. Update with inputs when component is ready. +const assembleLimitOrderReq = ({ assetIn, amount, assetOut }: SwapSlice) => { + if (!assetIn) { + throw new Error('`assetIn` is undefined'); + } + if (!assetOut) { + throw new Error('`assetOut` is undefined'); + } + if (!isValidAmount(amount, assetIn)) { + throw new Error('Invalid amount'); + } + + return new TransactionPlannerRequest({ + positionOpens: [ + { + position: { + phi: { + component: { p: { lo: 1000000n }, q: { lo: 1000000n } }, + pair: { + asset1: getAssetIdFromValueView(assetIn.balanceView), + asset2: assetOut.penumbraAssetId, + }, + }, + nonce: crypto.getRandomValues(new Uint8Array(32)), + state: { state: PositionState_PositionStateEnum.OPENED }, + reserves: { r1: { lo: 1n }, r2: {} }, + closeOnFill: true, + }, + }, + ], + 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), + }); +}; diff --git a/packages/perspective/src/plan/view-action-plan.ts b/packages/perspective/src/plan/view-action-plan.ts index 76700b78b2..be136dad31 100644 --- a/packages/perspective/src/plan/view-action-plan.ts +++ b/packages/perspective/src/plan/view-action-plan.ts @@ -379,21 +379,82 @@ export const viewActionPlan = actionView: actionPlan.action, }); - case undefined: - throw new Error('No action case in action plan'); - default: - /** - * `` only renders data about the `spend` and - * `output` cases. For all other cases, it just renders the action name. - * - * @todo As we render more data about other action types, add them as - * cases above. - */ + case 'positionOpen': + return new ActionView({ + actionView: actionPlan.action, + }); + + case 'positionClose': + return new ActionView({ + actionView: actionPlan.action, + }); + + case 'positionWithdraw': + return new ActionView({ + actionView: actionPlan.action, + }); + + case 'validatorDefinition': { + return new ActionView({ + actionView: actionPlan.action, + }); + } + + case 'ibcRelayAction': { + return new ActionView({ + actionView: actionPlan.action, + }); + } + + case 'proposalSubmit': { + return new ActionView({ + actionView: actionPlan.action, + }); + } + + case 'proposalWithdraw': { + return new ActionView({ + actionView: actionPlan.action, + }); + } + + case 'proposalDepositClaim': { + return new ActionView({ + actionView: actionPlan.action, + }); + } + + case 'communityPoolSpend': { + return new ActionView({ + actionView: actionPlan.action, + }); + } + + case 'communityPoolOutput': { + return new ActionView({ + actionView: actionPlan.action, + }); + } + + case 'communityPoolDeposit': { + return new ActionView({ + actionView: actionPlan.action, + }); + } + + // Deprecated + case 'positionRewardClaim': { return new ActionView({ actionView: { - case: actionPlan.action.case, + case: 'positionRewardClaim', value: {}, }, }); + } + + case undefined: + return new ActionView({ + actionView: actionPlan.action, + }); } }; diff --git a/packages/query/src/block-processor.ts b/packages/query/src/block-processor.ts index 42a8d0f49f..b2365872fc 100644 --- a/packages/query/src/block-processor.ts +++ b/packages/query/src/block-processor.ts @@ -594,8 +594,9 @@ export class BlockProcessor implements BlockProcessorInterface { if (action.case === 'positionOpen' && action.value.position) { for (const state of POSITION_STATES) { const metadata = getLpNftMetadata(computePositionId(action.value.position), state); + const customized = customizeSymbol(metadata); await this.indexedDb.saveAssetsMetadata({ - ...metadata, + ...customized, penumbraAssetId: getAssetId(metadata), }); } @@ -619,8 +620,9 @@ export class BlockProcessor implements BlockProcessorInterface { sequence: action.value.sequence, }); const metadata = getLpNftMetadata(action.value.positionId, positionState); + const customized = customizeSymbol(metadata); await this.indexedDb.saveAssetsMetadata({ - ...metadata, + ...customized, penumbraAssetId: getAssetId(metadata), }); diff --git a/packages/ui/components/ui/tx/action-view.tsx b/packages/ui/components/ui/tx/action-view.tsx index f5eb829da2..fdecac6e23 100644 --- a/packages/ui/components/ui/tx/action-view.tsx +++ b/packages/ui/components/ui/tx/action-view.tsx @@ -14,6 +14,9 @@ import { ActionDutchAuctionWithdrawViewComponent } from './actions-views/action- import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb'; import { DelegatorVoteComponent } from './actions-views/delegator-vote.tsx'; import { ValidatorVoteComponent } from './actions-views/validator-vote.tsx'; +import { PositionOpenComponent } from './actions-views/position-open.tsx'; +import { PositionCloseComponent } from './actions-views/position-close.tsx'; +import { PositionWithdrawComponent } from './actions-views/position-withdraw.tsx'; type Case = Exclude; @@ -121,13 +124,13 @@ export const ActionViewComponent = ({ return ; case 'positionOpen': - return ; + return ; case 'positionClose': - return ; + return ; case 'positionWithdraw': - return ; + return ; case 'positionRewardClaim': return ; diff --git a/packages/ui/components/ui/tx/actions-views/position-close.tsx b/packages/ui/components/ui/tx/actions-views/position-close.tsx new file mode 100644 index 0000000000..c5a486a79d --- /dev/null +++ b/packages/ui/components/ui/tx/actions-views/position-close.tsx @@ -0,0 +1,21 @@ +import { ViewBox } from '../viewbox'; +import { ActionDetails } from './action-details'; +import { PositionClose } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { bech32mPositionId } from '@penumbra-zone/bech32m/plpid'; + +export const PositionCloseComponent = ({ value }: { value: PositionClose }) => { + return ( + + {value.positionId && ( + + {bech32mPositionId(value.positionId)} + + )} + + } + /> + ); +}; diff --git a/packages/ui/components/ui/tx/actions-views/position-open.tsx b/packages/ui/components/ui/tx/actions-views/position-open.tsx new file mode 100644 index 0000000000..60617ccc71 --- /dev/null +++ b/packages/ui/components/ui/tx/actions-views/position-open.tsx @@ -0,0 +1,126 @@ +import { ViewBox } from '../viewbox'; +import { ActionDetails } from './action-details'; +import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64'; +import { + PositionOpen, + PositionState_PositionStateEnum, +} from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { joinLoHiAmount } from '@penumbra-zone/types/amount'; +import { bech32mAssetId } from '@penumbra-zone/bech32m/passet'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../tooltip'; +import { InfoIcon } from 'lucide-react'; + +export const PositionOpenComponent = ({ value }: { value: PositionOpen }) => { + return ( + + + {stateToString(value.position?.state?.state)} + + + + {value.position?.state?.sequence ? value.position.state.sequence.toString() : '0'} + + + {!!value.position?.phi?.pair?.asset1 && ( + + + {bech32mAssetId(value.position.phi.pair.asset1)} + + + )} + + {!!value.position?.phi?.pair?.asset2 && ( + + + {bech32mAssetId(value.position.phi.pair.asset2)} + + + )} + + {!!value.position?.phi?.component?.fee && ( + {value.position.phi.component.fee} + )} + + {value.position?.nonce && ( + + + {uint8ArrayToBase64(value.position.nonce)} + + + )} + + + {value.position?.closeOnFill ? 'true' : 'false'} + + +
+

Trading Parameters

+ + + + + + +

+ p and q are the price coefficients of the trading function: phi(r1, r2) = p * r1 + + q * r2, where r1 and r2 represent the old and new reserves. +

+
+
+
+
+ + {value.position?.phi?.component?.p && ( + + {joinLoHiAmount(value.position.phi.component.p).toString()} + + )} + + {value.position?.phi?.component?.q && ( + + {joinLoHiAmount(value.position.phi.component.q).toString()} + + )} + + {value.position?.reserves?.r1 && ( + + {joinLoHiAmount(value.position.reserves.r1).toString()} + + )} + + {value.position?.reserves?.r2 && ( + + {joinLoHiAmount(value.position.reserves.r2).toString()} + + )} + + } + /> + ); +}; + +export 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'; + } + } +}; diff --git a/packages/ui/components/ui/tx/actions-views/position-withdraw.tsx b/packages/ui/components/ui/tx/actions-views/position-withdraw.tsx new file mode 100644 index 0000000000..cb1ad87472 --- /dev/null +++ b/packages/ui/components/ui/tx/actions-views/position-withdraw.tsx @@ -0,0 +1,32 @@ +import { ViewBox } from '../viewbox'; +import { ActionDetails } from './action-details'; +import { PositionWithdraw } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb'; +import { bech32mPositionId } from '@penumbra-zone/bech32m/plpid'; +import { uint8ArrayToBase64 } from '@penumbra-zone/types/base64'; + +export const PositionWithdrawComponent = ({ value }: { value: PositionWithdraw }) => { + return ( + + {value.positionId && ( + + {bech32mPositionId(value.positionId)} + + )} + + + {value.sequence ? value.sequence.toString() : '0'} + + + {value.reservesCommitment?.inner && ( + + {uint8ArrayToBase64(value.reservesCommitment.inner)} + + )} + + } + /> + ); +}; diff --git a/packages/wasm/crate/src/metadata.rs b/packages/wasm/crate/src/metadata.rs index 2f20f33a82..798b5ff4bb 100644 --- a/packages/wasm/crate/src/metadata.rs +++ b/packages/wasm/crate/src/metadata.rs @@ -12,6 +12,7 @@ pub static DELEGATION_TOKEN_REGEX: &str = pub static AUCTION_NFT_REGEX: &str = "^auctionnft_(?P(?[a-z_0-9]+)_pauctid1(?P[a-zA-HJ-NP-Z0-9]+))$"; pub static VOTING_RECEIPT_REGEX: &str = "^uvoted_on_(?P(?P[0-9]+))$"; +pub static LP_NFT_REGEX: &str = "^lpnft_(?P[a-z_0-9]+)_plpid1(?P[a-zA-HJ-NP-Z0-9]+)$"; /// Given a binary-encoded `Metadata`, returns a new binary-encoded `Metadata` /// with the symbol customized if the token is one of several specific types @@ -36,6 +37,7 @@ pub fn customize_symbol_inner(metadata: Metadata) -> WasmResult { let delegation_re = Regex::new(DELEGATION_TOKEN_REGEX)?; let auction_re = Regex::new(AUCTION_NFT_REGEX)?; let voting_re = Regex::new(VOTING_RECEIPT_REGEX)?; + let lp_nft_re = Regex::new(LP_NFT_REGEX)?; if let Some(captures) = unbonding_re.captures(&metadata.base) { let asset_id = collect_id(&captures)?; @@ -70,6 +72,13 @@ pub fn customize_symbol_inner(metadata: Metadata) -> WasmResult { symbol: format!("VotedOn{proposal_id}"), ..metadata }); + } else if let Some(captures) = lp_nft_re.captures(&metadata.base) { + let (state, id) = get_lp_info(&captures)?; + + return Ok(Metadata { + symbol: format!("lpNft:{state}({id})"), + ..metadata + }); } Ok(metadata) @@ -98,6 +107,19 @@ fn get_proposal_id(captures: ®ex::Captures) -> WasmResult { Ok(id_match.as_str().to_string()) } +fn get_lp_info(captures: ®ex::Captures) -> WasmResult<(String, String)> { + let state_match = captures + .name("lp_state") + .ok_or_else(|| anyhow!(" not matched in token regex"))?; + let id_match = captures + .name("id") + .ok_or_else(|| anyhow!(" not matched in lpnft token regex"))?; + Ok(( + state_match.as_str().to_string(), + id_match.as_str().to_string(), + )) +} + #[cfg(test)] mod test_helpers { use penumbra_proto::core::asset::v1::DenomUnit; @@ -243,6 +265,60 @@ mod customize_symbol_inner_tests { assert_eq!(customized_metadata.symbol, "VotedOn234"); } + + #[test] + fn it_modifies_lp_nft_opened() { + let metadata = Metadata { + name: String::from("xyz"), + symbol: String::from("xyz"), + ..test_helpers::get_metadata_for( + "lpnft_opened_plpid1pae7ssx6uwct9srws9uxznp7n087me8j9jvpmf5tve8fjupky4rqspvcd5", + true, + ) + }; + let customized_metadata = customize_symbol_inner(metadata.clone()).unwrap(); + + assert_eq!( + customized_metadata.symbol, + "lpNft:opened(pae7ssx6uwct9srws9uxznp7n087me8j9jvpmf5tve8fjupky4rqspvcd5)" + ); + } + + #[test] + fn it_modifies_lp_nft_closed() { + let metadata = Metadata { + name: String::from("xyz"), + symbol: String::from("xyz"), + ..test_helpers::get_metadata_for( + "lpnft_closed_plpid1y4y5y94wrtkrem0a2mv9pwqk8myv9ykfamkz28jy97ycs8zgw0ysrnqm3r", + true, + ) + }; + let customized_metadata = customize_symbol_inner(metadata.clone()).unwrap(); + + assert_eq!( + customized_metadata.symbol, + "lpNft:closed(y4y5y94wrtkrem0a2mv9pwqk8myv9ykfamkz28jy97ycs8zgw0ysrnqm3r)" + ); + } + + #[test] + fn it_modifies_lp_nft_withdrawn() { + let metadata = Metadata { + name: String::from("xyz"), + symbol: String::from("xyz"), + ..test_helpers::get_metadata_for( + "lpnft_withdrawn_0_plpid1pae7ssx6uwct9srws9uxznp7n087me8j9jvpmf5tve8fjupky4rqspvcd5", + true, + ) + }; + let customized_metadata = customize_symbol_inner(metadata.clone()).unwrap(); + + assert_eq!( + customized_metadata.symbol, + "lpNft:withdrawn_0(pae7ssx6uwct9srws9uxznp7n087me8j9jvpmf5tve8fjupky4rqspvcd5)" + ); + } } #[cfg(test)] diff --git a/packages/wasm/crate/src/planner.rs b/packages/wasm/crate/src/planner.rs index f4ff2e2ae1..406d56dba2 100644 --- a/packages/wasm/crate/src/planner.rs +++ b/packages/wasm/crate/src/planner.rs @@ -10,10 +10,11 @@ use penumbra_auction::auction::dutch::{ ActionDutchAuctionEnd, ActionDutchAuctionSchedule, DutchAuctionDescription, }; use penumbra_auction::auction::{AuctionId, AuctionNft}; +use penumbra_dex::lp::plan::PositionWithdrawPlan; use penumbra_dex::swap_claim::SwapClaimPlan; use penumbra_dex::{ swap::{SwapPlaintext, SwapPlan}, - TradingPair, + PositionClose, PositionOpen, TradingPair, }; use penumbra_fee::FeeTier; use penumbra_governance::DelegatorVotePlan; @@ -408,19 +409,42 @@ pub async fn plan_transaction_inner( actions_list.push(ActionPlan::Ics20Withdrawal(ics20_withdrawal.try_into()?)); } - #[allow(clippy::never_loop)] - for tpr::PositionOpen { .. } in request.position_opens { - return Err(anyhow!("PositionOpen not yet implemented").into()); + for tpr::PositionOpen { position } in request.position_opens { + actions_list.push(ActionPlan::PositionOpen(PositionOpen { + position: position + .ok_or_else(|| anyhow!("missing position in PositionOpen"))? + .try_into()?, + })); } - #[allow(clippy::never_loop)] - for tpr::PositionClose { .. } in request.position_closes { - return Err(anyhow!("PositionClose not yet implemented").into()); + for tpr::PositionClose { position_id } in request.position_closes { + actions_list.push(ActionPlan::PositionClose(PositionClose { + position_id: position_id + .ok_or_else(|| anyhow!("missing position_id in PositionClose"))? + .try_into()?, + })); } - #[allow(clippy::never_loop)] - for tpr::PositionWithdraw { .. } in request.position_withdraws { - return Err(anyhow!("PositionWithdraw not yet implemented").into()); + // Note: Currently this only supports an initial withdrawal from Closed, with no rewards. + for tpr::PositionWithdraw { + position_id, + reserves, + trading_pair, + } in request.position_withdraws + { + actions_list.push(ActionPlan::PositionWithdraw(PositionWithdrawPlan { + position_id: position_id + .ok_or_else(|| anyhow!("missing position_id in PositionWithdraw"))? + .try_into()?, + reserves: reserves + .ok_or_else(|| anyhow!("missing reserves in PositionWithdraw"))? + .try_into()?, + pair: trading_pair + .ok_or_else(|| anyhow!("missing trading_pair in PositionWithdraw"))? + .try_into()?, + sequence: 0, + rewards: vec![], + })); } for tpr::ActionDutchAuctionSchedule { description } in request.dutch_auction_schedule_actions {