Skip to content

Commit

Permalink
Add price impact to swap estimate (#828)
Browse files Browse the repository at this point in the history
* Add support for price impact

* Frontend styles
  • Loading branch information
grod220 authored Mar 26, 2024
1 parent a337a05 commit b524588
Show file tree
Hide file tree
Showing 11 changed files with 184 additions and 128 deletions.
63 changes: 43 additions & 20 deletions apps/minifront/src/components/swap/asset-out-box.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { cn } from '@penumbra-zone/ui/lib/utils';
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb';

import { isZero } from '@penumbra-zone/types/src/amount';
import { formatNumber, isZero } from '@penumbra-zone/types/src/amount';
import { getAmount } from '@penumbra-zone/getters/src/value-view';

const findMatchingBalance = (
Expand Down Expand Up @@ -56,33 +56,55 @@ export const AssetOutBox = ({ balances }: AssetOutBoxProps) => {
<div className='mb-2 flex items-center justify-between gap-1 md:gap-2'>
<p className='text-sm font-bold md:text-base'>Swap into</p>
</div>
<div className='flex items-center justify-between gap-4'>
{simulateOutResult ? (
<Result result={simulateOutResult} />
) : (
<EstimateButton simulateFn={simulateSwap} loading={simulateOutLoading} />
)}
<AssetOutSelector
balances={balances}
assetOut={matchingBalance}
setAssetOut={setAssetOut}
/>
</div>
<div className='mt-[6px] flex items-start justify-between'>
<div />
<div className='flex items-start gap-1'>
<img src='./wallet.svg' alt='Wallet' className='size-5' />
<ValueViewComponent view={matchingBalance} showIcon={false} />
<div className='flex justify-between gap-4'>
<div className='flex items-start justify-start'>
{simulateOutResult ? (
<Result result={simulateOutResult} />
) : (
<EstimateButton simulateFn={simulateSwap} loading={simulateOutLoading} />
)}
</div>
<div className='flex flex-col'>
<div className='ml-auto w-auto shrink-0'>
<AssetOutSelector assetOut={matchingBalance} setAssetOut={setAssetOut} />
</div>
<div className='mt-[6px] flex items-start justify-between'>
<div />
<div className='flex items-start gap-1'>
<img src='./wallet.svg' alt='Wallet' className='size-5' />
<ValueViewComponent view={matchingBalance} showIcon={false} />
</div>
</div>
</div>
</div>
</div>
);
};

const Result = ({ result: { output, unfilled } }: { result: SimulateSwapResult }) => {
// The price hit the user takes as a consequence of moving the market with the size of their trade
const PriceImpact = ({ amount = 0 }: { amount?: number }) => {
// e.g .041234245245 becomes 4.123
const percent = formatNumber(amount * 100, { precision: 3 });

return (
<div className={cn('flex flex-col text-gray-500 text-sm', amount < -0.1 && 'text-orange-400')}>
<div>Price impact:</div>
<div>{percent}%</div>
</div>
);
};

const Result = ({ result: { output, unfilled, priceImpact } }: { result: SimulateSwapResult }) => {
// If no part unfilled, just show plain output amount (no label)
if (isZero(getAmount(unfilled))) {
return <ValueViewComponent view={output} showDenom={false} showIcon={false} />;
return (
<div className='flex flex-col gap-2'>
<div>
<ValueViewComponent view={output} showDenom={false} showIcon={false} />
</div>
<PriceImpact amount={priceImpact} />
</div>
);
}

// Else is partially filled, show amounts with labels
Expand All @@ -96,6 +118,7 @@ const Result = ({ result: { output, unfilled } }: { result: SimulateSwapResult }
<ValueViewComponent view={unfilled} showIcon={false} />
<span className='font-mono text-[12px] italic text-gray-500'>Unfilled amount</span>
</div>
<PriceImpact amount={priceImpact} />
</div>
);
};
Expand Down
6 changes: 2 additions & 4 deletions apps/minifront/src/components/swap/asset-out-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,18 @@ import {
ValueView,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { localAssets } from '@penumbra-zone/constants/src/assets';
import { BalancesResponse } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { ValueViewComponent } from '@penumbra-zone/ui/components/ui/tx/view/value';

interface AssetOutSelectorProps {
balances: BalancesResponse[];
assetOut: ValueView | undefined;
setAssetOut: (metadata: Metadata) => void;
}

/** @todo Refactor to use `SelectTokenModal` */
export const AssetOutSelector = ({ balances, setAssetOut, assetOut }: AssetOutSelectorProps) => {
export const AssetOutSelector = ({ setAssetOut, assetOut }: AssetOutSelectorProps) => {
return (
<Dialog>
<DialogTrigger disabled={!balances.length}>
<DialogTrigger>
<div className='flex h-9 min-w-[100px] max-w-[150px] items-center justify-center gap-2 rounded-lg bg-light-brown px-2'>
<ValueViewComponent view={assetOut} showValue={false} />
</div>
Expand Down
19 changes: 16 additions & 3 deletions apps/minifront/src/components/swap/swap-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import { Button } from '@penumbra-zone/ui/components/ui/button';
import InputToken from '../shared/input-token';
import { useLoaderData } from 'react-router-dom';
import { useStore } from '../../state';
import { swapSelector } from '../../state/swap';
import { swapSelector, swapValidationErrors } from '../../state/swap';
import { AssetOutBox } from './asset-out-box';
import { SwapLoaderResponse } from './swap-loader';

export const SwapForm = () => {
const { assetBalances } = useLoaderData() as SwapLoaderResponse;
const { assetIn, setAssetIn, amount, setAmount, initiateSwapTx, txInProgress } =
useStore(swapSelector);
const validationErrs = useStore(swapValidationErrors);

return (
<form
Expand All @@ -30,11 +31,23 @@ export const SwapForm = () => {
if (Number(e.target.value) < 0) return;
setAmount(e.target.value);
}}
validations={[]}
validations={[
{
type: 'error',
issue: 'insufficient funds',
checkFn: () => validationErrs.amountErr,
},
]}
balances={assetBalances}
/>
<AssetOutBox balances={assetBalances} />
<Button type='submit' variant='gradient' className='mt-3' size='lg' disabled={txInProgress}>
<Button
type='submit'
variant='gradient'
className='mt-3'
size='lg'
disabled={txInProgress || Object.values(validationErrs).find(Boolean)}
>
Swap
</Button>
</form>
Expand Down
6 changes: 4 additions & 2 deletions apps/minifront/src/state/ibc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { getAddressIndex } from '@penumbra-zone/getters/src/address-view';
import { typeRegistry } from '@penumbra-zone/types/src/registry';
import { toBaseUnit } from '@penumbra-zone/types/src/lo-hi';
import { planBuildBroadcast } from './helpers';
import { validateAmount } from './send';
import { amountMoreThanBalance } from './send';
import { IbcLoaderResponse } from '../components/ibc/ibc-loader';
import { getAssetId } from '@penumbra-zone/getters/src/metadata';
import {
Expand Down Expand Up @@ -163,7 +163,9 @@ export const ibcValidationErrors = (state: AllSlices) => {
recipientErr: !state.ibc.destinationChainAddress
? false
: !validateAddress(state.ibc.chain, state.ibc.destinationChainAddress),
amountErr: !state.ibc.selection ? false : validateAmount(state.ibc.selection, state.ibc.amount),
amountErr: !state.ibc.selection
? false
: amountMoreThanBalance(state.ibc.selection, state.ibc.amount),
};
};

Expand Down
4 changes: 2 additions & 2 deletions apps/minifront/src/state/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ const assembleRequest = ({ amount, feeTier, recipient, selection, memo }: SendSl
});
};

export const validateAmount = (
export const amountMoreThanBalance = (
asset: BalancesResponse,
/**
* The amount that a user types into the interface will always be in the
Expand Down Expand Up @@ -173,7 +173,7 @@ export const sendValidationErrors = (
): SendValidationFields => {
return {
recipientErr: Boolean(recipient) && !isPenumbraAddr(recipient),
amountErr: !asset ? false : validateAmount(asset, amount),
amountErr: !asset ? false : amountMoreThanBalance(asset, amount),
// The memo cannot exceed 512 bytes
// return address uses 80 bytes
// so 512-80=432 bytes for memo text
Expand Down
18 changes: 15 additions & 3 deletions apps/minifront/src/state/swap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ describe('Swap Slice', () => {
test('changing assetIn clears simulation', () => {
expect(useStore.getState().swap.simulateOutResult).toBeUndefined();
useStore.setState(state => {
state.swap.simulateOutResult = { output: new ValueView(), unfilled: new ValueView() };
state.swap.simulateOutResult = {
output: new ValueView(),
unfilled: new ValueView(),
priceImpact: undefined,
};
return state;
});
expect(useStore.getState().swap.simulateOutResult).toBeDefined();
Expand All @@ -84,7 +88,11 @@ describe('Swap Slice', () => {
test('changing assetOut clears simulation', () => {
expect(useStore.getState().swap.simulateOutResult).toBeUndefined();
useStore.setState(state => {
state.swap.simulateOutResult = { output: new ValueView(), unfilled: new ValueView() };
state.swap.simulateOutResult = {
output: new ValueView(),
unfilled: new ValueView(),
priceImpact: undefined,
};
return state;
});
expect(useStore.getState().swap.simulateOutResult).toBeDefined();
Expand All @@ -95,7 +103,11 @@ describe('Swap Slice', () => {
test('changing amount clears simulation', () => {
expect(useStore.getState().swap.simulateOutResult).toBeUndefined();
useStore.setState(state => {
state.swap.simulateOutResult = { output: new ValueView(), unfilled: new ValueView() };
state.swap.simulateOutResult = {
output: new ValueView(),
unfilled: new ValueView(),
priceImpact: undefined,
};
return state;
});
expect(useStore.getState().swap.simulateOutResult).toBeDefined();
Expand Down
61 changes: 59 additions & 2 deletions apps/minifront/src/state/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/view/v1/view_pb';
import { planBuildBroadcast } from './helpers';
import {
AssetId,
Metadata,
Value,
ValueView,
Expand All @@ -13,7 +14,10 @@ import { BigNumber } from 'bignumber.js';
import { getAddressByIndex } from '../fetchers/address';
import { StateCommitment } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/crypto/tct/v1/tct_pb';
import { errorToast } from '@penumbra-zone/ui/lib/toast/presets';
import { SimulateTradeRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb';
import {
SimulateTradeRequest,
SwapExecution,
} from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/component/dex/v1/dex_pb';
import { simulateClient } from '../clients';
import {
getAssetIdFromValueView,
Expand All @@ -24,10 +28,15 @@ import { getAssetId } from '@penumbra-zone/getters/src/metadata';
import { getSwapCommitmentFromTx } from '@penumbra-zone/getters/src/transaction';
import { getAddressIndex } from '@penumbra-zone/getters/src/address-view';
import { toBaseUnit } from '@penumbra-zone/types/src/lo-hi';
import { getAmountFromValue, getAssetIdFromValue } from '@penumbra-zone/getters/src/value';
import { Amount } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/num/v1/num_pb';
import { divideAmounts } from '@penumbra-zone/types/src/amount';
import { amountMoreThanBalance } from './send';

export interface SimulateSwapResult {
output: ValueView;
unfilled: ValueView;
priceImpact: number | undefined;
}

export interface SwapSlice {
Expand Down Expand Up @@ -114,7 +123,11 @@ export const createSwapSlice = (): SliceCreator<SwapSlice> => (set, get) => {
});

set(({ swap }) => {
swap.simulateOutResult = { output, unfilled };
swap.simulateOutResult = {
output,
unfilled,
priceImpact: calculatePriceImpact(res.output),
};
});
} catch (e) {
errorToast(e, 'Error estimating swap').render();
Expand Down Expand Up @@ -187,4 +200,48 @@ export const issueSwapClaim = async (swapCommitment: StateCommitment) => {
await planBuildBroadcast('swapClaim', req, { skipAuth: true });
};

/*
Price impact is the change in price as a consequence of the trade's size. In SwapExecution, the \
first trace in the array is the best execution for the swap. To calculate price impact, take
the price of the trade and see the % diff off the best execution trace.
*/
const calculatePriceImpact = (swapExec?: SwapExecution): number | undefined => {
if (!swapExec?.traces.length || !swapExec.output || !swapExec.input) return undefined;

// Get the price of the estimate for the swap total
const inputAmount = getAmountFromValue(swapExec.input);
const outputAmount = getAmountFromValue(swapExec.output);
const swapEstimatePrice = divideAmounts(outputAmount, inputAmount);

// Get the price in the best execution trace
const inputAssetId = getAssetIdFromValue(swapExec.input);
const outputAssetId = getAssetIdFromValue(swapExec.output);
const bestTrace = swapExec.traces[0]!;
const bestInputAmount = getMatchingAmount(bestTrace.value, inputAssetId);
const bestOutputAmount = getMatchingAmount(bestTrace.value, outputAssetId);
const bestTraceEstimatedPrice = divideAmounts(bestOutputAmount, bestInputAmount);

// Difference = (priceB - priceA) / priceA
const percentDifference = swapEstimatePrice
.minus(bestTraceEstimatedPrice)
.div(bestTraceEstimatedPrice);

return percentDifference.toNumber();
};

const getMatchingAmount = (values: Value[], toMatch: AssetId): Amount => {
const match = values.find(v => toMatch.equals(v.assetId));
if (!match?.amount) throw new Error('No match in values array found');

return match.amount;
};

export const swapValidationErrors = ({ swap }: AllSlices) => {
return {
assetInErr: !swap.assetIn,
assetOutErr: !swap.assetOut,
amountErr: (swap.assetIn && amountMoreThanBalance(swap.assetIn, swap.amount)) ?? false,
};
};

export const swapSelector = (state: AllSlices) => state.swap;
6 changes: 6 additions & 0 deletions packages/getters/src/value.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Value } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/asset/v1/asset_pb';
import { createGetter } from './utils/create-getter';

export const getAssetIdFromValue = createGetter((value?: Value) => value?.assetId);

export const getAmountFromValue = createGetter((value?: Value) => value?.amount);
1 change: 1 addition & 0 deletions packages/getters/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default defineConfig({
'validator-info-response': './src/validator-info-response.ts',
'validator-state': './src/validator-state.ts',
'validator-status': './src/validator-status.ts',
value: './src/value.ts',
'value-view': './src/value-view.ts',
},
formats: ['es'],
Expand Down
Loading

0 comments on commit b524588

Please sign in to comment.