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

feat(ui): #2053: implement SwapClaim action view #2061

Merged
merged 4 commits into from
Feb 21, 2025
Merged
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
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 @@ -30,15 +30,24 @@ import { CommunityPoolOutputAction } from './actions/community-pool-output';
import { CommunityPoolSpendAction } from './actions/community-pool-spend';

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 @@ -67,9 +76,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