diff --git a/apps/minifront/src/components/swap/asset-out-box.tsx b/apps/minifront/src/components/swap/asset-out-box.tsx index fdf8241ca2..c3daf7b6a4 100644 --- a/apps/minifront/src/components/swap/asset-out-box.tsx +++ b/apps/minifront/src/components/swap/asset-out-box.tsx @@ -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 = ( @@ -56,33 +56,55 @@ export const AssetOutBox = ({ balances }: AssetOutBoxProps) => {

Swap into

-
- {simulateOutResult ? ( - - ) : ( - - )} - -
-
-
-
- Wallet - +
+
+ {simulateOutResult ? ( + + ) : ( + + )} +
+
+
+ +
+
+
+
+ Wallet + +
+
); }; -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 ( +
+
Price impact:
+
{percent}%
+
+ ); +}; + +const Result = ({ result: { output, unfilled, priceImpact } }: { result: SimulateSwapResult }) => { // If no part unfilled, just show plain output amount (no label) if (isZero(getAmount(unfilled))) { - return ; + return ( +
+
+ +
+ +
+ ); } // Else is partially filled, show amounts with labels @@ -96,6 +118,7 @@ const Result = ({ result: { output, unfilled } }: { result: SimulateSwapResult } Unfilled amount
+
); }; diff --git a/apps/minifront/src/components/swap/asset-out-selector.tsx b/apps/minifront/src/components/swap/asset-out-selector.tsx index e152666ae7..22ecc3c138 100644 --- a/apps/minifront/src/components/swap/asset-out-selector.tsx +++ b/apps/minifront/src/components/swap/asset-out-selector.tsx @@ -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 ( - +
diff --git a/apps/minifront/src/components/swap/swap-form.tsx b/apps/minifront/src/components/swap/swap-form.tsx index e2a9f7e335..aa11d8b1a7 100644 --- a/apps/minifront/src/components/swap/swap-form.tsx +++ b/apps/minifront/src/components/swap/swap-form.tsx @@ -2,7 +2,7 @@ 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'; @@ -10,6 +10,7 @@ export const SwapForm = () => { const { assetBalances } = useLoaderData() as SwapLoaderResponse; const { assetIn, setAssetIn, amount, setAmount, initiateSwapTx, txInProgress } = useStore(swapSelector); + const validationErrs = useStore(swapValidationErrors); return (
{ if (Number(e.target.value) < 0) return; setAmount(e.target.value); }} - validations={[]} + validations={[ + { + type: 'error', + issue: 'insufficient funds', + checkFn: () => validationErrs.amountErr, + }, + ]} balances={assetBalances} /> - diff --git a/apps/minifront/src/state/ibc.ts b/apps/minifront/src/state/ibc.ts index 393c833ade..963266d6e8 100644 --- a/apps/minifront/src/state/ibc.ts +++ b/apps/minifront/src/state/ibc.ts @@ -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 { @@ -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), }; }; diff --git a/apps/minifront/src/state/send.ts b/apps/minifront/src/state/send.ts index bbf07d108c..d400db4ae2 100644 --- a/apps/minifront/src/state/send.ts +++ b/apps/minifront/src/state/send.ts @@ -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 @@ -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 diff --git a/apps/minifront/src/state/swap.test.ts b/apps/minifront/src/state/swap.test.ts index 2060164876..f320871f74 100644 --- a/apps/minifront/src/state/swap.test.ts +++ b/apps/minifront/src/state/swap.test.ts @@ -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(); @@ -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(); @@ -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(); diff --git a/apps/minifront/src/state/swap.ts b/apps/minifront/src/state/swap.ts index 49ca99fe9e..3f3a5d15a0 100644 --- a/apps/minifront/src/state/swap.ts +++ b/apps/minifront/src/state/swap.ts @@ -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, @@ -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, @@ -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 { @@ -114,7 +123,11 @@ export const createSwapSlice = (): SliceCreator => (set, get) => { }); set(({ swap }) => { - swap.simulateOutResult = { output, unfilled }; + swap.simulateOutResult = { + output, + unfilled, + priceImpact: calculatePriceImpact(res.output), + }; }); } catch (e) { errorToast(e, 'Error estimating swap').render(); @@ -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; diff --git a/packages/getters/src/value.ts b/packages/getters/src/value.ts new file mode 100644 index 0000000000..994ccb1cb5 --- /dev/null +++ b/packages/getters/src/value.ts @@ -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); diff --git a/packages/getters/vite.config.ts b/packages/getters/vite.config.ts index 685fbc5e61..88900e692f 100644 --- a/packages/getters/vite.config.ts +++ b/packages/getters/vite.config.ts @@ -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'], diff --git a/packages/types/src/amount.test.ts b/packages/types/src/amount.test.ts index 2e20a8af32..4d382544d0 100644 --- a/packages/types/src/amount.test.ts +++ b/packages/types/src/amount.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest'; import { addAmounts, - displayAmount, - displayUsd, divideAmounts, + formatNumber, fromBaseUnitAmount, fromValueView, isZero, @@ -180,62 +179,6 @@ describe('divideAmounts', () => { }); }); -describe('Formatting', () => { - describe('displayAmount()', () => { - it('no decimals', () => { - expect(displayAmount(2000)).toBe('2,000'); - }); - - it('one decimal place', () => { - expect(displayAmount(2001.1)).toBe('2,001.1'); - }); - - it('many decimals, if above 1, rounds to three places', () => { - expect(displayAmount(2001.124125)).toBe('2,001.124'); - }); - - it('many decimals, if less than 1, shows all places', () => { - expect(displayAmount(0.000012)).toBe('0.000012'); - }); - - it('negative numbers work too', () => { - expect(displayAmount(-2001.124125)).toBe('-2,001.124'); - }); - }); - - describe('displayUsd()', () => { - it('should format numbers with no decimals', () => { - expect(displayUsd(2000)).toBe('2,000.00'); - }); - - it('should format numbers with one decimal place', () => { - expect(displayUsd(2001.1)).toBe('2,001.10'); - }); - - it('should format numbers with two decimal places', () => { - expect(displayUsd(2001.12)).toBe('2,001.12'); - }); - - it('should round numbers with more than two decimal places', () => { - expect(displayUsd(2001.124)).toBe('2,001.12'); - expect(displayUsd(2001.125)).toBe('2,001.13'); // testing rounding - }); - - it('should format numbers less than one', () => { - expect(displayUsd(0.1)).toBe('0.10'); - expect(displayUsd(0.01)).toBe('0.01'); - expect(displayUsd(0.001)).toBe('0.00'); // testing rounding - }); - - it('should format negative numbers', () => { - expect(displayUsd(-2001)).toBe('-2,001.00'); - expect(displayUsd(-2001.1)).toBe('-2,001.10'); - expect(displayUsd(-2001.12)).toBe('-2,001.12'); - expect(displayUsd(-2001.125)).toBe('-2,001.13'); // testing rounding - }); - }); -}); - describe('isZero', () => { it('works with zero amount', () => { const amount = new Amount({ lo: 0n, hi: 0n }); @@ -288,3 +231,25 @@ describe('multiplyAmountByNumber()', () => { ); }); }); + +describe('formatNumber', () => { + it('formats number with zero precision', () => { + expect(formatNumber(123.456, { precision: 0 })).toBe('123'); + }); + + it('formats number with non-zero precision', () => { + expect(formatNumber(123.456, { precision: 2 })).toBe('123.46'); + }); + + it('handles zero value with non-zero precision', () => { + expect(formatNumber(0, { precision: 2 })).toBe('0'); + }); + + it('removes unnecessary trailing zeros', () => { + expect(formatNumber(123.4, { precision: 3 })).toBe('123.4'); + }); + + it('formats negative number correctly', () => { + expect(formatNumber(-123.456, { precision: 2 })).toBe('-123.46'); + }); +}); diff --git a/packages/types/src/amount.ts b/packages/types/src/amount.ts index 5f825c4808..f9962d4bc8 100644 --- a/packages/types/src/amount.ts +++ b/packages/types/src/amount.ts @@ -48,47 +48,26 @@ export const multiplyAmountByNumber = (amount: Amount, multiplier: number): Amou return new Amount(loHi); }; -export const divideAmounts = (dividend: Amount, divider: Amount): BigNumber => { - if (isZero(divider)) throw new Error('Division by zero'); +export const divideAmounts = (dividend: Amount, divisor: Amount): BigNumber => { + if (isZero(divisor)) throw new Error('Division by zero'); const joinedDividend = new BigNumber(joinLoHiAmount(dividend).toString()); - const joinedDivider = new BigNumber(joinLoHiAmount(divider).toString()); + const joinedDivisor = new BigNumber(joinLoHiAmount(divisor).toString()); - return joinedDividend.dividedBy(joinedDivider); + return joinedDividend.dividedBy(joinedDivisor); }; -// This function takes a number and formats it in a display-friendly way (en-US locale) -// Examples: -// 2000 -> 2,000 -// 2001.1 -> 2,000.1 -// 2001.124125 -> 2,001.124 -// 0.000012 -> 0.000012 -export const displayAmount = (num: number): string => { - const split = num.toString().split('.'); - const integer = parseInt(split[0]!); - let decimal = split[1]; +interface FormatOptions { + precision: number; +} - const formattedInt = new Intl.NumberFormat('en-US').format(integer); +export const formatNumber = (number: number, options: FormatOptions): string => { + const { precision } = options; - if (!decimal) return formattedInt; - - if (Math.abs(num) >= 1) { - decimal = decimal.slice(0, 3); - } - - return `${formattedInt}.${decimal}`; -}; - -// Takes a number and represents it as a formatted $usd value -// 2000 -> 2,000 -// 2001.1 -> 2,000.10 -// 2001.124125 -> 2,001.12 -// 0.000012 -> 0.00 -export const displayUsd = (number: number): string => { - return new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(number); + // Use toFixed to set the precision and then remove unnecessary trailing zeros + return precision === 0 + ? number.toFixed(precision) + : parseFloat(number.toFixed(precision)).toString(); }; /**