Skip to content

Commit

Permalink
feat(ui): #2053: implement SwapClaim action view (#2061)
Browse files Browse the repository at this point in the history
* feat(ui): #2053: implement `SwapClaim` action view

* chore: changeset

* fix(ui): #2053: fix SwapClaim action view

* fix: format
  • Loading branch information
VanishMax authored Feb 21, 2025
1 parent 7ea03c7 commit d0cc2ee
Show file tree
Hide file tree
Showing 10 changed files with 279 additions and 50 deletions.
6 changes: 6 additions & 0 deletions .changeset/famous-meals-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@penumbra-zone/getters': minor
'@penumbra-zone/ui': minor
---

Implement `SwapClaim` action view
5 changes: 5 additions & 0 deletions packages/getters/src/swap-claim-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { SwapClaimView } from '@penumbra-zone/protobuf/penumbra/core/component/d
import { createGetter } from './utils/create-getter.js';
import { getValue } from './note-view.js';

export const getOutputData = createGetter(
(swapClaimView?: SwapClaimView) =>
swapClaimView?.swapClaimView.value?.swapClaim?.body?.outputData,
);

export const getOutput1 = createGetter((swapClaimView?: SwapClaimView) =>
swapClaimView?.swapClaimView.case === 'visible'
? swapClaimView.swapClaimView.value.output1
Expand Down
22 changes: 17 additions & 5 deletions packages/ui/src/ActionView/action-view.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { FC } from 'react';
import { ActionView as ActionViewMessage } from '@penumbra-zone/protobuf/penumbra/core/transaction/v1/transaction_pb';
import { ActionViewType, ActionViewValueType } from './types';
import { ActionViewType, ActionViewValueType, GetMetadataByAssetId } from './types';
import { UnknownAction } from './actions/unknown';

import { SpendAction } from './actions/spend';
Expand Down Expand Up @@ -31,15 +31,24 @@ import { CommunityPoolSpendAction } from './actions/community-pool-spend';
import { LiquidityTournamentVoteAction } from './actions/liquidity-tournament-vote';

export interface ActionViewProps {
/**
* The `ActionViewMessage` protobuf describing an action within a transaction in Penumbra.
* Can be one of multiple types: Spend, Output, Swap, SwapClaim, etc.
*/
action: ActionViewMessage;
/**
* A helper function that is needed for better fees calculation.
* Can be omitted, but it generally improves the rendering logic, especially for opaque views.
*/
getMetadataByAssetId?: GetMetadataByAssetId;
}

const componentMap = {
spend: SpendAction,
output: OutputAction,
swap: SwapAction,
// TODO: Implement the actions below
swapClaim: SwapClaimAction,
// TODO: Implement the actions below
delegate: DelegateAction,
delegatorVote: DelegatorVoteAction,
undelegate: UndelegateAction,
Expand Down Expand Up @@ -69,9 +78,12 @@ const componentMap = {
* In Penumbra, each transaction has 'actions' of different types,
* representing a blockchain state change performed by a transaction.
*/
export const ActionView = ({ action }: ActionViewProps) => {
export const ActionView = ({ action, getMetadataByAssetId }: ActionViewProps) => {
const type = action.actionView.case ?? 'unknown';
const Component = componentMap[type] as FC<{ value?: ActionViewValueType }>;
const Component = componentMap[type] as FC<{
value?: ActionViewValueType;
getMetadataByAssetId?: GetMetadataByAssetId;
}>;

return <Component value={action.actionView.value} />;
return <Component value={action.actionView.value} getMetadataByAssetId={getMetadataByAssetId} />;
};
137 changes: 134 additions & 3 deletions packages/ui/src/ActionView/actions/swap-claim.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,141 @@
import { useMemo } from 'react';
import { ArrowRight } from 'lucide-react';
import { isZero } from '@penumbra-zone/types/amount';
import { shorten } from '@penumbra-zone/types/string';
import { uint8ArrayToHex } from '@penumbra-zone/types/hex';
import { getAmount, getMetadata } from '@penumbra-zone/getters/value-view';
import { SwapClaimView } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb';
import { UnknownAction } from './unknown';
import {
getOutput1Value,
getOutput2Value,
getSwapClaimFee,
getOutputData,
} from '@penumbra-zone/getters/swap-claim-view';
import { Density } from '../../Density';
import { ValueViewComponent } from '../../ValueView';
import { useDensity } from '../../utils/density';
import { ActionRow } from './action-row';
import { ActionWrapper } from './wrapper';
import { parseSwapFees } from './swap';
import { GetMetadataByAssetId } from '../types';
import { ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';

export interface SwapClaimActionProps {
value: SwapClaimView;
/** A helper needed to calculate the SwapClaim fees */
getMetadataByAssetId?: GetMetadataByAssetId;
}

export const SwapClaimAction = ({ value }: SwapClaimActionProps) => {
return <UnknownAction label='Swap Claim' opaque={value.swapClaimView.case === 'opaque'} />;
/**
* Based on the visibility of the SwapClaim view, retrieves its values from the `value`
* property for 'visible', and from `outputData` for 'opaque'.
*/
const useSwapClaimValues = ({ value, getMetadataByAssetId }: SwapClaimActionProps) => {
if (value.swapClaimView.case === 'visible') {
const value1 = getOutput1Value.optional(value);
const value2 = getOutput2Value.optional(value);

return {
value1,
value2,
};
}

const outputData = getOutputData.optional(value);
const value1 =
outputData?.lambda1 &&
new ValueView({
valueView:
outputData.tradingPair?.asset1 && getMetadataByAssetId
? {
case: 'knownAssetId',
value: {
amount: outputData.lambda1,
metadata: getMetadataByAssetId(outputData.tradingPair.asset1),
},
}
: {
case: 'unknownAssetId',
value: {
amount: outputData.lambda1,
assetId: outputData.tradingPair?.asset1,
},
},
});

const value2 =
outputData?.lambda2 &&
new ValueView({
valueView:
outputData.tradingPair?.asset2 && getMetadataByAssetId
? {
case: 'knownAssetId',
value: {
amount: outputData.lambda2,
metadata: getMetadataByAssetId(outputData.tradingPair.asset2),
},
}
: {
case: 'unknownAssetId',
value: {
amount: outputData.lambda2,
assetId: outputData.tradingPair?.asset2,
},
},
});

return {
value1,
value2,
};
};

export const SwapClaimAction = ({ value, getMetadataByAssetId }: SwapClaimActionProps) => {
const density = useDensity();

const { value1, value2 } = useSwapClaimValues({ value, getMetadataByAssetId });

const amount1 = getAmount.optional(value1);
const amount2 = getAmount.optional(value2);

const txId = useMemo(() => {
if (value.swapClaimView.case === 'opaque' || !value.swapClaimView.value?.swapTx) {
return undefined;
}
return uint8ArrayToHex(value.swapClaimView.value.swapTx.inner);
}, [value]);

const fee = useMemo(() => {
const claimFee = getSwapClaimFee.optional(value);
const asset1 = getMetadata.optional(value1);
const asset2 = getMetadata.optional(value2);
return parseSwapFees(claimFee, asset1, asset2, getMetadataByAssetId);
}, [getMetadataByAssetId, value1, value2, value]);

return (
<ActionWrapper
title='Swap Claim'
opaque={value.swapClaimView.case === 'opaque'}
infoRows={
<>
{!!fee && <ActionRow label='Swap Claim Fee' info={fee} />}
{!!txId && <ActionRow label='Swap Transaction' info={shorten(txId, 8)} copyText={txId} />}
</>
}
>
<Density slim>
<ValueViewComponent
valueView={value1}
showValue={amount1 && !isZero(amount1)}
priority={density === 'sparse' ? 'primary' : 'tertiary'}
/>
<ArrowRight className='size-3 text-neutral-contrast' />
<ValueViewComponent
valueView={value2}
showValue={amount2 && !isZero(amount2)}
priority={density === 'sparse' ? 'primary' : 'tertiary'}
/>
</Density>
</ActionWrapper>
);
};
103 changes: 62 additions & 41 deletions packages/ui/src/ActionView/actions/swap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { ArrowRight } from 'lucide-react';
import { SwapView } from '@penumbra-zone/protobuf/penumbra/core/component/dex/v1/dex_pb';
import { Metadata, ValueView } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';
import { Fee } from '@penumbra-zone/protobuf/penumbra/core/component/fee/v1/fee_pb';
import {
getAsset1Metadata,
getAsset2Metadata,
Expand All @@ -14,6 +15,7 @@ import { isZero } from '@penumbra-zone/types/amount';
import { getFormattedAmtFromValueView } from '@penumbra-zone/types/value-view';
import { uint8ArrayToHex } from '@penumbra-zone/types/hex';
import { shorten } from '@penumbra-zone/types/string';
import { GetMetadataByAssetId } from '../types';
import { ValueViewComponent } from '../../ValueView';
import { useDensity } from '../../utils/density';
import { Density } from '../../Density';
Expand All @@ -22,6 +24,8 @@ import { ActionRow } from './action-row';

export interface SwapActionProps {
value: SwapView;
/** A helper needed to calculate the Swap fees */
getMetadataByAssetId: GetMetadataByAssetId;
}

const renderAmount = (value?: ValueView) => {
Expand All @@ -32,7 +36,61 @@ const renderAmount = (value?: ValueView) => {
return symbol ? `${getFormattedAmtFromValueView(value)} ${symbol}` : undefined;
};

export const SwapAction = ({ value }: SwapActionProps) => {
/**
* For Swap and SwapClaim actions, fees contain only the assetId and amount. This function
* calculates a Metadata from this assetId. It firstly tries to get the info from the action itself,
* and if it fails, it takes the Metadata from the registry (or ViewService, if passed).
*/
export const parseSwapFees = (
fee?: Fee,
asset1?: Metadata,
asset2?: Metadata,
getMetadataByAssetId?: SwapActionProps['getMetadataByAssetId'],
): string | undefined => {
if (!fee) {
return undefined;
}

let metadata: Metadata | undefined = undefined;
if (fee.assetId?.equals(asset1?.penumbraAssetId)) {
metadata = asset1;
}
if (fee.assetId?.equals(asset2?.penumbraAssetId)) {
metadata = asset1;
}

if (!metadata && fee.assetId && getMetadataByAssetId) {
metadata = getMetadataByAssetId(fee.assetId);
}

if (metadata) {
return renderAmount(
new ValueView({
valueView: {
case: 'knownAssetId',
value: {
metadata,
amount: fee.amount,
},
},
}),
);
}

return renderAmount(
new ValueView({
valueView: {
case: 'unknownAssetId',
value: {
assetId: fee.assetId,
amount: fee.amount,
},
},
}),
);
};

export const SwapAction = ({ value, getMetadataByAssetId }: SwapActionProps) => {
const density = useDensity();

const isOneWay = isOneWaySwap(value);
Expand All @@ -52,50 +110,13 @@ export const SwapAction = ({ value }: SwapActionProps) => {
return uint8ArrayToHex(claim.inner);
}, [value]);

// This function calculates metadata based on fee's AssetId from input or output metadata.
// TODO: implement fees paid from non-input/output assets (e.g. connect with registry)
const fee = useMemo(() => {
const claimFee = getClaimFeeFromSwapView.optional(value);
if (!claimFee) {
return undefined;
}

let metadata: Metadata | undefined = undefined;
const asset1 = getAsset1Metadata.optional(value);
const asset2 = getAsset2Metadata.optional(value);
if (claimFee.assetId?.equals(asset1?.penumbraAssetId)) {
metadata = asset1;
}
if (claimFee.assetId?.equals(asset2?.penumbraAssetId)) {
metadata = asset1;
}

if (metadata) {
return renderAmount(
new ValueView({
valueView: {
case: 'knownAssetId',
value: {
metadata,
amount: claimFee.amount,
},
},
}),
);
}

return renderAmount(
new ValueView({
valueView: {
case: 'unknownAssetId',
value: {
assetId: claimFee.assetId,
amount: claimFee.amount,
},
},
}),
);
}, [value]);
return parseSwapFees(claimFee, asset1, asset2, getMetadataByAssetId);
}, [getMetadataByAssetId, value]);

if (!isOneWay) {
return (
Expand All @@ -110,7 +131,7 @@ export const SwapAction = ({ value }: SwapActionProps) => {
infoRows={
isVisible && (
<>
{!!fee && <ActionRow label='Swap Claim fee' info={fee} />}
{!!fee && <ActionRow label='Swap Claim Fee' info={fee} />}
{!!txId && (
<ActionRow label='Swap Claim Transaction' info={shorten(txId, 8)} copyText={txId} />
)}
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/ActionView/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
OutputActionOpaque,
SwapActionOpaque,
SwapClaimAction,
SwapClaimActionOpaque,
registry,
} from '../utils/bufs';

const OPTIONS: Record<string, ActionViewMessage> = {
Expand All @@ -20,6 +22,7 @@ const OPTIONS: Record<string, ActionViewMessage> = {
'Opaque: Spend': SpendActionOpaque,
'Opaque: Output': OutputActionOpaque,
'Opaque: Swap': SwapActionOpaque,
'Opaque: SwapClaim': SwapClaimActionOpaque,
};

const meta: Meta<typeof ActionView> = {
Expand All @@ -39,5 +42,6 @@ type Story = StoryObj<typeof ActionView>;
export const Basic: Story = {
args: {
action: SpendAction,
getMetadataByAssetId: registry.tryGetMetadata,
},
};
3 changes: 3 additions & 0 deletions packages/ui/src/ActionView/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ActionView } from '@penumbra-zone/protobuf/penumbra/core/transaction/v1/transaction_pb';
import { AssetId, Metadata } from '@penumbra-zone/protobuf/penumbra/core/asset/v1/asset_pb';

export type ActionViewType = Exclude<ActionView['actionView']['case'], undefined>;

export type ActionViewValueType = Exclude<ActionView['actionView']['value'], undefined>;

export type GetMetadataByAssetId = (assetId: AssetId) => Metadata | undefined;
Loading

0 comments on commit d0cc2ee

Please sign in to comment.