From 11449abb3ef0c14f0ba3f4dcb99512cfb9799315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Nagy?= Date: Mon, 25 Nov 2024 20:48:19 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=97=20Embrace=20the=201-Click:=20Add?= =?UTF-8?q?=201-Click=20enable=20and=20modify=20to=20swap=20review=20(#395?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add 1click trade panel to review order * feat: handle 1CT status change * refactor: move formatSpendLimit into utils/formatter.ts * feat: make 1CT remainig time translations configurable without breaking existing usages ... :) * feat: add active 1CT session info to review order * feat: 1CT settings modal scaffold w/ placeholder modal the diff is big, but the change is simple -> better to separate * feat: use the 1CT settings modal * docs: why 2 signs needed after enabling 1CT on swap review * refactor: move edit into a separate component * refactor: use consistent naming for one click trading related concerns * refactor: move remaining spend limit fetch into hook * refactor: move session periods translations to oneClickTrading block these defintions are reused in the review order screens too * feat: add optional change tracking to useOneClickTradingParams * fix: wait for `oneClickTradingInfo` to load when using it as inital value there can't be a 1CT session without account address but if we return early in the `useOneClickSession`, the default value will stop the loading flag which introduces bugs down the line e.g. In `useSessionParams` we use the `info`/`oneClickTradingInfo` to set the inital params for the `transaction1CTParams` which the user can start modifying or sending this should reflect the params that the user has set when activated with the last session but instead it's always set to the default value with this change we can reliably wait until the `loading` is set to false and then start setting the inital value * fix: use params changes for create/change/edit 1CT params flow - with the panel the user can toggle on/off the state of the `transaction1CTParams` - the user can modify the params - for a new session: when clicking on the change button - for existing session: when clicking on the overview - we should send a 1CT tx if - the user has turned it on/off - when turning on we should use - use the default params unless user changes them - the changed params if any - user has made changes to the params and - had the feature turned on and haven't turned it off - has the feture turned on - has turned on the feature * fix:

shouldn't be inside

* fix: keep all the changes in 1CT settings previously the current state was overwritten by the new and only the last change was marked with green * feat: make trpc logs configurable in dev mode * fix: on rollback use inital params first and current params as fallback * fix: discard current changes on settings modal close * fix: update user settings after removing 1CT session the `shouldUseOneClickTrading` from account base store is used in the calculation is `isOneClickTradingEnabled` which is the basis of the enabled state checking for 1CT session * refactor: use change tracking by default in `useSessionParams` it's cheap and simplifies the code * refactor: drop shouldShow prop * feat: implement exceeded error state * refactor: use static translation keys and don't calculate them from props * fix: linter problems * feat: update useOneClickTradingParams to update when input changes now we can change the session parameters in 2 places so we need the transaction1CTParams that we receive from the hook to always reflect the latest state unless there are changes made to the draft value of course * feat: disable confirm button until 1CT session start loads * feat: close review modal after 1CT enable otherwise the review ui starts changing and updating for the enabled 1CT which is confusing and misleading for the user * fix: linter happiness is important * fix: coderabbit suggestions Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Revert "fix: coderabbit suggestions" This reverts commit f014e48d5729e804b8e2b1ce8b07a5fc76628bab. * fix: coderabbit suggestion with proper syntax * fix: add nextparam check from coderabbit * docs: update docs to follow the api * fix: don't use remove display on create rollback * refactor: give price formatter with symbol a descriptive name * fix: review comments 1st batch * docs: info about why spreading changes is necessary * fix: wrap sendSwapTx in callback * fix: add signOptions to estimate-tx-fees queryKey the gas amount needed for the tx is different when using one click trading so we need to update the estimated tx fee * feat: sign only once when enabling 1Ct through review order the `useAsync` adds some delay in picking up mobx store changes so we need to wait for it a bit for the changes to properly propagate and the `useSwap` to update before we can start executing the actual swap tx otherwise we either need to sign the tx manually or the broadcast will fail * refactor: rework mobx <> 1CT session reactivity to be push based The useSwap hook was not updating instantly after the create1CTSession mutation, but before the onSuccess callback. This caused the next swap transaction to either fail or not get signed properly through 1CT. This could be worked around with waiting for the changes to propagate, but that solution can be error prone. To implement a proper fix, the logic for handling updates has been moved into the mobx store. The `useOneClickTradingSession` now uses mobx’s reaction() to react immediately to changes and update the state as soon as it changes. This ensures that the session info is always available and up to date after change — eg after create1CT mutation but before the `onSuccess` callback. The session data is stored as regular state, so any components using this hook can rely on react’s built-in reactivity without needing to work with mobx observables directly. Finally, a useEffect is used to clean up resources when the component unmounts. * feat: use the updated 1CT session * feat: before sending swap tx wait for networkfee to update when enabling 1CT through the review order modal rarely it can happen that the networkfee is refetching when the `sendTradeTokenInTx` is called which can result in low gas fee for the tx this safety measure gives networkfee a 1s safety buffer with 100ms rechecks to update * fix: add feature flag and new wallet checks - don't show 1CT for new wallets that are without funding - use the feature flag from launch darkly * feat: read 1CT over spend params from simulation error for some high volume tokens — eg OSMO — the token value is not included in the spend limit the source of truth for the tokens is the contract so we need to rely on its error when running a simulation * feat: remove 1CT spend limit error from swap tool handling this concern is moved to the review order modal where the user will be able to increase the spend limit during the swap flow note: there was no option for the user to manually sign if 1CT is enabled and there is spend limit error (they could proceed to the review order modal, but there the confirm button was disabled) if they want to proceed with the swap they need to increase the spend limit * feat: use swap simulation result in checking if swap would exceed limit * fix: typo * fixup! feat: read 1CT over spend params from simulation error * fix: rabbit haunts the past * Revert "refactor: rework mobx <> 1CT session reactivity to be push based" This reverts commit 2758eef306bc607b032086153ea19ed753d571ea. * Revert "feat: use the updated 1CT session" This reverts commit 747dbc3c406c9880e932ba3e15694423130eecf4. * fix: typo * docs: explain the refetch flow for 1CT session * fix: don't include `isEnabled` in changes in 1CT settings standalone mode isEnabled as a change is requied to be tracked in the review order modal flow but in standalone mode when the modal is opened through the profile the enabled toggle is treated differently from other parameter changes and has its own lifecycle * fixup! feat: sign only once when enabling 1Ct through review order * fix: convert uusdc to usdc from over spend error * fix: coderabbit * fix: coderabbit * Revert "fix: coderabbit" This reverts commit f8ec361d3dd13a23d2c4b1677758777a3f7e5b11. * fix: send log event on 1CT sesssion start from review order --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/stores/src/account/base.ts | 6 +- packages/web/.env | 5 +- .../alert/__tests__/prettify.spec.ts | 54 +- packages/web/components/alert/prettify.ts | 30 +- .../one-click-remaining-time.tsx | 8 +- .../one-click-trading-settings.tsx | 215 ++-- .../profile-one-click-trading-settings.tsx | 5 +- .../screens/session-period-screen.tsx | 12 +- packages/web/components/swap-tool/index.tsx | 57 +- .../use-remove-one-click-trading-session.ts | 1 + .../use-one-click-trading-params.spec.ts | 261 ++++ packages/web/hooks/one-click-trading/index.ts | 1 + .../use-one-click-trading-params.ts | 162 ++- .../use-one-click-trading-session-manager.ts | 317 +++++ .../use-one-click-trading-session.ts | 8 +- packages/web/hooks/use-estimate-tx-fees.ts | 6 +- packages/web/hooks/use-swap.tsx | 57 +- packages/web/localizations/de.json | 47 +- packages/web/localizations/en.json | 47 +- packages/web/localizations/es.json | 47 +- packages/web/localizations/fa.json | 47 +- packages/web/localizations/fr.json | 47 +- packages/web/localizations/gu.json | 47 +- packages/web/localizations/hi.json | 47 +- packages/web/localizations/ja.json | 47 +- packages/web/localizations/ko.json | 47 +- packages/web/localizations/pl.json | 47 +- packages/web/localizations/pt-br.json | 47 +- packages/web/localizations/ro.json | 47 +- packages/web/localizations/ru.json | 47 +- packages/web/localizations/tr.json | 47 +- packages/web/localizations/zh-cn.json | 47 +- packages/web/localizations/zh-hk.json | 47 +- packages/web/localizations/zh-tw.json | 47 +- packages/web/modals/review-order.tsx | 1105 ++++++++++------- .../public/images/1ct-rounded-rectangle.svg | 5 + packages/web/utils/date.ts | 38 +- packages/web/utils/trpc.ts | 5 +- 38 files changed, 2290 insertions(+), 867 deletions(-) create mode 100644 packages/web/hooks/one-click-trading/__tests__/use-one-click-trading-params.spec.ts create mode 100644 packages/web/hooks/one-click-trading/use-one-click-trading-session-manager.ts create mode 100644 packages/web/public/images/1ct-rounded-rectangle.svg diff --git a/packages/stores/src/account/base.ts b/packages/stores/src/account/base.ts index b09fc7b4ac..ba5a5ab90d 100644 --- a/packages/stores/src/account/base.ts +++ b/packages/stores/src/account/base.ts @@ -216,7 +216,7 @@ export class AccountStore[] = []> { makeObservable(this); autorun(async () => { - const isOneClickTradingEnabled = await this.getShouldUseOneClickTrading(); + const isOneClickTradingEnabled = await this.isOneClickTradingEnabled(); const oneClickTradingInfo = await this.getOneClickTradingInfo(); const hasUsedOneClickTrading = await this.getHasUsedOneClickTrading(); runInAction(() => { @@ -1472,7 +1472,7 @@ export class AccountStore[] = []> { }: { messages: readonly EncodeObject[]; }): Promise { - const isOneClickTradingEnabled = await this.isOneCLickTradingEnabled(); + const isOneClickTradingEnabled = await this.isOneClickTradingEnabled(); const oneClickTradingInfo = await this.getOneClickTradingInfo(); if (!oneClickTradingInfo || !isOneClickTradingEnabled) { @@ -1535,7 +1535,7 @@ export class AccountStore[] = []> { }); } - async isOneCLickTradingEnabled(): Promise { + async isOneClickTradingEnabled(): Promise { const oneClickTradingInfo = await this.getOneClickTradingInfo(); if (isNil(oneClickTradingInfo)) return false; diff --git a/packages/web/.env b/packages/web/.env index d0f78286ff..cf30c16291 100644 --- a/packages/web/.env +++ b/packages/web/.env @@ -48,4 +48,7 @@ TWITTER_API_URL=https://api.twitter.com/ BLOCKAID_BASE_URL=http://api.blockaid.io:80 # BLOCKAID_API_KEY= -NEXT_PUBLIC_SPEND_LIMIT_CONTRACT_ADDRESS=osmo10xqv8rlpkflywm92k5wdmplzy7khtasl9c2c08psmvlu543k724sy94k74 \ No newline at end of file +NEXT_PUBLIC_SPEND_LIMIT_CONTRACT_ADDRESS=osmo10xqv8rlpkflywm92k5wdmplzy7khtasl9c2c08psmvlu543k724sy94k74 + +# Disable TRPC logs in development +# NEXT_PUBLIC_TRPC_LOGS=off diff --git a/packages/web/components/alert/__tests__/prettify.spec.ts b/packages/web/components/alert/__tests__/prettify.spec.ts index 68a35e5f09..90e0bb343c 100644 --- a/packages/web/components/alert/__tests__/prettify.spec.ts +++ b/packages/web/components/alert/__tests__/prettify.spec.ts @@ -1,6 +1,11 @@ +import { Dec } from "@keplr-wallet/unit"; import cases from "jest-in-case"; -import { isOverspendErrorMessage, isRejectedTxErrorMessage } from "../prettify"; +import { + getParametersFromOverspendErrorMessage, + isOverspendErrorMessage, + isRejectedTxErrorMessage, +} from "../prettify"; cases( "isOverspendErrorMessage", @@ -45,6 +50,53 @@ cases( ] ); +cases( + "getParametersFromOverspendErrorMessage", + ({ message, result }) => { + expect(getParametersFromOverspendErrorMessage(message)).toEqual(result); + }, + [ + { + name: "should extract parameters from valid overspend error message", + message: + "Fetch error. Spend limit error: Overspend: 2000000 has been spent but limit is 1000000.", + result: { + wouldSpendTotal: new Dec("2000000", 6), + limit: new Dec("1000000", 6), + }, + }, + { + name: "should extract parameters from complex overspend error message", + message: + "Fetch error. execution blocked by authenticator (account = osmo1sh8lreekwcytxpqr6lxmw5cl7kdrfsdfat2ujlvz, authenticator id = 208, msg index = 0, msg type url = /osmosis.poolmanager.v1beta1.MsgSwapExactAmountIn): Spend limit error: Overspend: 50065777 has been spent but limit is 1000000: execute wasm contract failed", + result: { + wouldSpendTotal: new Dec("50065777", 6), + limit: new Dec("1000000", 6), + }, + }, + { + name: "should handle empty message", + message: "", + result: undefined, + }, + { + name: "should handle undefined message", + message: undefined, + result: undefined, + }, + { + name: "should return undefined for non-overspend error message", + message: "execution succeeded", + result: undefined, + }, + { + name: "should return undefined for invalid overspend error format", + message: "Spend limit error: Invalid format", + result: undefined, + }, + ] +); + cases( "isRejectedTxErrorMessage", ({ message, result }) => { diff --git a/packages/web/components/alert/prettify.ts b/packages/web/components/alert/prettify.ts index 7b53c3ea30..7f4038cdfd 100644 --- a/packages/web/components/alert/prettify.ts +++ b/packages/web/components/alert/prettify.ts @@ -1,5 +1,5 @@ import { AppCurrency } from "@keplr-wallet/types"; -import { CoinPretty, Int } from "@keplr-wallet/unit"; +import { CoinPretty, Dec, Int } from "@keplr-wallet/unit"; import { isInsufficientFeeError, isSlippageErrorMessage, @@ -27,6 +27,34 @@ const regexRejectedTx = /Request rejected/; const regexOverspendError = /Spend limit error: Overspend: (\d+) has been spent but limit is (\d+)/; +export function getParametersFromOverspendErrorMessage( + message: string | undefined +): { wouldSpendTotal: Dec; limit: Dec } | undefined { + if (!message) return; + + const match = message.match(regexOverspendError); + if (!match) return; + + const [, wouldSpendTotal, limit] = match; + + if (!wouldSpendTotal || !limit) return; + + try { + // Validate that extracted values are valid numbers + if (isNaN(Number(wouldSpendTotal)) || isNaN(Number(limit))) { + return; + } + + return { + wouldSpendTotal: new Dec(wouldSpendTotal, 6), + limit: new Dec(limit, 6), + }; + } catch (error) { + console.error("Failed to parse overspend error parameters:", error); + return; + } +} + export function isOverspendErrorMessage({ message, }: { diff --git a/packages/web/components/one-click-trading/one-click-remaining-time.tsx b/packages/web/components/one-click-trading/one-click-remaining-time.tsx index c4ffe12bff..d2fc8146e7 100644 --- a/packages/web/components/one-click-trading/one-click-remaining-time.tsx +++ b/packages/web/components/one-click-trading/one-click-remaining-time.tsx @@ -8,7 +8,8 @@ import { humanizeTime } from "~/utils/date"; export const OneClickTradingRemainingTime: FunctionComponent<{ className?: string; -}> = ({ className }) => { + useShortTimeUnits?: boolean; +}> = ({ className, useShortTimeUnits }) => { const { oneClickTradingInfo, isOneClickTradingExpired } = useOneClickTradingSession(); const { t } = useTranslation(); @@ -24,7 +25,8 @@ export const OneClickTradingRemainingTime: FunctionComponent<{ humanizeTime( dayjs.unix( unixNanoSecondsToSeconds(oneClickTradingInfo.sessionPeriod.end) - ) + ), + useShortTimeUnits ) ); }; @@ -39,7 +41,7 @@ export const OneClickTradingRemainingTime: FunctionComponent<{ ); return () => clearInterval(intervalId); - }, [oneClickTradingInfo]); + }, [oneClickTradingInfo, useShortTimeUnits]); if (isOneClickTradingExpired) { return ( diff --git a/packages/web/components/one-click-trading/one-click-trading-settings.tsx b/packages/web/components/one-click-trading/one-click-trading-settings.tsx index 84940d2219..a29fc5d18f 100644 --- a/packages/web/components/one-click-trading/one-click-trading-settings.tsx +++ b/packages/web/components/one-click-trading/one-click-trading-settings.tsx @@ -1,4 +1,4 @@ -import { Dec, PricePretty } from "@keplr-wallet/unit"; +import { Dec } from "@keplr-wallet/unit"; import { makeRemoveAuthenticatorMsg } from "@osmosis-labs/tx"; import { OneClickTradingTransactionParams } from "@osmosis-labs/types"; import { noop, runIfFn } from "@osmosis-labs/utils"; @@ -26,15 +26,16 @@ import { Button, buttonVariants, GoBackButton } from "~/components/ui/button"; import { Switch } from "~/components/ui/switch"; import { EventName } from "~/config"; import { + OneClickTradingParamsChanges, useAmplitudeAnalytics, useDisclosure, useOneClickTradingSession, useTranslation, } from "~/hooks"; +import { formatSpendLimit } from "~/hooks/one-click-trading/use-one-click-trading-session-manager"; import { useEstimateTxFees } from "~/hooks/use-estimate-tx-fees"; import { ModalBase, ModalCloseButton } from "~/modals"; import { useStore } from "~/stores"; -import { trimPlaceholderZeros } from "~/utils/number"; import { api } from "~/utils/trpc"; type Classes = "root"; @@ -45,14 +46,13 @@ enum SettingsScreens { SessionPeriod = "sessionPeriod", } -interface OneClickTradingSettingsProps { +type BaseOneClickTradingSettingsProps = { classes?: Partial>; onGoBack: () => void; transaction1CTParams: OneClickTradingTransactionParams | undefined; setTransaction1CTParams: Dispatch< SetStateAction >; - onStartTrading: () => void; onEndSession?: () => void; onClose: () => void; isLoading?: boolean; @@ -60,7 +60,37 @@ interface OneClickTradingSettingsProps { isEndingSession?: boolean; hideBackButton?: boolean; hasExistingSession?: boolean; -} +}; + +type StandaloneProps = BaseOneClickTradingSettingsProps & { + /** + * Whether component is in standalone mode. + * When true or undefined, the component acts as a full settings page with start/edit functionality + */ + standalone?: true; + /** Required callback when user clicks start/edit trading button */ + onStartTrading: () => void; + /** In standalone mode the changes are tracked internally. Initial changes are not accepted */ + externalChanges?: undefined; + /** Not needed in standalone mode */ + setExternalChanges?: Dispatch>; +}; + +type NonStandaloneProps = BaseOneClickTradingSettingsProps & { + /** + * Whether component is in standalone mode. + * When false, the component acts as a settings panel within another component + */ + standalone: false; + /** Start trading callback is not allowed in non-standalone mode */ + onStartTrading?: never; + /** Initial changes to be applied to the settings */ + externalChanges: OneClickTradingParamsChanges; + /** Callback to set initial changes */ + setExternalChanges: (value: OneClickTradingParamsChanges) => void; +}; + +type OneClickTradingSettingsProps = StandaloneProps | NonStandaloneProps; /** * Compares the changes between two sets of OneClickTradingTransactionParams. @@ -70,26 +100,28 @@ export function compare1CTTransactionParams({ prevParams, nextParams, }: { - prevParams: OneClickTradingTransactionParams; - nextParams: OneClickTradingTransactionParams; -}): Array<"spendLimit" | "resetPeriod" | "sessionPeriod"> { - let changes = new Set<"spendLimit" | "resetPeriod" | "sessionPeriod">(); + prevParams?: OneClickTradingTransactionParams; + nextParams?: OneClickTradingTransactionParams; +}): OneClickTradingParamsChanges { + let changes: OneClickTradingParamsChanges = []; if (prevParams?.spendLimit.toString() !== nextParams?.spendLimit.toString()) { - changes.add("spendLimit"); + changes.push("spendLimit"); } if (prevParams?.sessionPeriod.end !== nextParams?.sessionPeriod.end) { - changes.add("sessionPeriod"); + changes.push("sessionPeriod"); } - return Array.from(changes); -} + if (prevParams?.isOneClickEnabled !== nextParams?.isOneClickEnabled) { + changes.push("isEnabled"); + } + + if (prevParams?.networkFeeLimit !== nextParams?.networkFeeLimit) { + changes.push("networkFeeLimit"); + } -function formatSpendLimit(spendLimit: PricePretty | undefined) { - return `${spendLimit?.symbol}${trimPlaceholderZeros( - spendLimit?.toDec().toString(2) ?? "" - )}`; + return changes; } export const OneClickTradingSettings = ({ @@ -105,11 +137,16 @@ export const OneClickTradingSettings = ({ hasExistingSession, onEndSession, onClose, + standalone = true, + externalChanges = [], + setExternalChanges, }: OneClickTradingSettingsProps) => { const { t } = useTranslation(); - const [changes, setChanges] = useState< - Array<"spendLimit" | "resetPeriod" | "sessionPeriod"> - >([]); + + const [changes, setChanges] = useState([]); + + const [initialChanges, setInitialChanges] = + useState([]); const [initialTransaction1CTParams, setInitialTransaction1CTParams] = useState(); @@ -147,7 +184,9 @@ export const OneClickTradingSettings = ({ useEffect(() => { if (!transaction1CTParams || initialTransaction1CTParams) return; setInitialTransaction1CTParams(transaction1CTParams); - }, [initialTransaction1CTParams, transaction1CTParams]); + setChanges(externalChanges); + setInitialChanges(externalChanges); + }, [externalChanges, initialTransaction1CTParams, transaction1CTParams]); const { isOpen: isDiscardDialogOpen, @@ -170,11 +209,30 @@ export const OneClickTradingSettings = ({ ) => { setTransaction1CTParamsProp((prevParams) => { const nextParams = runIfFn(newParamsOrFn, prevParams); + + if (!initialTransaction1CTParams || !nextParams) { + console.error("Transaction params are undefined"); + return prevParams; + } + setChanges( - compare1CTTransactionParams({ - prevParams: initialTransaction1CTParams!, - nextParams: nextParams!, - }) + Array.from( + new Set([ + /** + * Spreading the changes and then merging with the new changes + * ensures that the changes from the previous modal open are not overwritten + * + * If I have already made a change, and returned to the review order modal, + * then I start editing again I expect my previous changes to be in the state + * and the previously changed parameters displayed the same way as current change + */ + ...changes, + ...compare1CTTransactionParams({ + prevParams: initialTransaction1CTParams!, + nextParams: nextParams!, + }), + ]) + ) ); return nextParams; @@ -193,6 +251,8 @@ export const OneClickTradingSettings = ({ )} / ${formatSpendLimit(initialTransaction1CTParams.spendLimit)}` : undefined; + const changesWithoutIsEnabled = changes.filter((c) => c !== "isEnabled"); + return ( <> { - if (changes.length > 0) { - return openCloseConfirmDialog(); + if (standalone) { + if (changesWithoutIsEnabled.length > 0) { + return openCloseConfirmDialog(); + } + } else { + setTransaction1CTParams(initialTransaction1CTParams); + setExternalChanges?.(initialChanges); } onClose(); @@ -228,7 +293,7 @@ export const OneClickTradingSettings = ({ { - if (changes.length > 0) { + if (standalone && changesWithoutIsEnabled.length > 0) { return onOpenDiscardDialog(); } onGoBack(); @@ -268,46 +333,51 @@ export const OneClickTradingSettings = ({

- { - if (hasExistingSession) onEndSession?.(); - setTransaction1CTParams((params) => { - if (!params) throw new Error("1CT Params is undefined"); - - const nextValue = !params.isOneClickEnabled; - if (nextValue) { - logEvent([ - EventName.OneClickTrading.enableOneClickTrading, - ]); - } - - return { - ...params, - isOneClickEnabled: nextValue, - }; - }); - }} - content={ -
- {hasExistingSession && isEndingSession && ( -

- {t("oneClickTrading.settings.endingSession")} -

- )} - {(isLoading || isEndingSession) && ( - - )}{" "} - { + if (hasExistingSession) onEndSession?.(); + setTransaction1CTParams((params) => { + if (!params) + throw new Error("1CT Params is undefined"); + + const nextValue = !params.isOneClickEnabled; + if (nextValue) { + logEvent([ + EventName.OneClickTrading.enableOneClickTrading, + ]); } - /> -
- } - isDisabled={isSendingTx || isEndingSession} - /> + + return { + ...params, + isOneClickEnabled: nextValue, + }; + }); + }} + content={ +
+ {hasExistingSession && isEndingSession && ( +

+ {t("oneClickTrading.settings.endingSession")} +

+ )} + {(isLoading || isEndingSession) && ( + + )}{" "} + +
+ } + isDisabled={isSendingTx || isEndingSession} + /> + )} setCurrentScreen(SettingsScreens.SpendLimit)} @@ -378,8 +448,9 @@ export const OneClickTradingSettings = ({ />
- {hasExistingSession && - changes.length > 0 && + {standalone && + hasExistingSession && + changes.filter((c) => c !== "isEnabled").length > 0 && (!isSendingTx || !isEndingSession) && (
- - ); - } - const isLoadingMaxButton = featureFlags.swapToolSimulateFee && !isNil(account?.address) && @@ -341,7 +326,16 @@ export const SwapTool: FunctionComponent = observer( !Boolean(swapState.quote) || isSwapToolLoading || Boolean(swapState.error) || - Boolean(swapState.networkFeeError))); + Boolean( + swapState.networkFeeError && + /** + * We can increase spend limit from the review order modal + * so the decision to disable the button should be made there + */ + !isOverspendErrorMessage({ + message: swapState.networkFeeError.message, + }) + ))); const showTokenSelectRecommendedTokens = isNil(forceSwapInPoolId); @@ -626,16 +620,6 @@ export const SwapTool: FunctionComponent = observer(
- {!isNil(warningText) && ( -
- {warningText} -
- )} {swapButton ?? (
+ {show1CT && showOneClickTradingSettings && ( +
+ setShowOneClickTradingSettings(false)} + onClose={() => setShowOneClickTradingSettings(false)} + transaction1CTParams={transaction1CTParams} + setTransaction1CTParams={setTransaction1CTParams} + standalone={false} + />
-
- {orderType === "limit" && tab !== "swap" && ( -
-
-
- {(tab === "buy" && !isBeyondOppositePrice) || - (tab === "sell" && isBeyondOppositePrice) ? ( - - +
+
{title}
+ +
+
+ {orderType === "limit" && tab !== "swap" && ( +
+
+
+ {(tab === "buy" && !isBeyondOppositePrice) || + (tab === "sell" && isBeyondOppositePrice) ? ( + + + + ) : ( + - - ) : ( - + )} +
+ + {t("limitOrders.priceReaches", { + denom: baseDenom ?? "", + price: limitPriceFiat + ? formatPretty( + limitPriceFiat, + getPriceExtendedFormatOptions( + limitPriceFiat.toDec() + ) + ) + : "", + })} + + {percentAdjusted && ( +
+
+ {!percentAdjusted.isZero() && ( + + )} +
+ + {formatPretty(percentAdjusted.mul(new Dec(100)).abs(), { + maxDecimals: 3, + })} + % + +
)}
- - {t("limitOrders.priceReaches", { - denom: baseDenom ?? "", - price: limitPriceFiat - ? formatPretty( - limitPriceFiat, - getPriceExtendedFormatOptions(limitPriceFiat.toDec()) - ) - : "", - })} - - {percentAdjusted && ( -
-
- {!percentAdjusted.isZero() && ( - - )} -
- - {formatPretty(percentAdjusted.mul(new Dec(100)).abs(), { - maxDecimals: 3, - })} - % - -
- )}
-
- )} -
-
-
- {fromAsset && ( - {`${fromAsset.coinDenom} - )} -
-

- {tab === "buy" - ? t("limitOrders.pay") - : t("limitOrders.sell")} -

- {inAmountToken && ( - - {formatPretty(inAmountToken)} - +
+
+
+ {fromAsset && ( + {`${fromAsset.coinDenom} )} +
+

+ {tab === "buy" + ? t("limitOrders.pay") + : t("limitOrders.sell")} +

+ {inAmountToken && ( + + {formatPretty(inAmountToken)} + + )} +
-
-
- {formatFiatPrice( - inAmountFiat ?? new PricePretty(DEFAULT_VS_CURRENCY, 0) - )} -
-
-
-
-
- +
+ {formatFiatPrice( + inAmountFiat ?? new PricePretty(DEFAULT_VS_CURRENCY, 0) + )}
-
-
-
- {toAsset && ( - {`${toAsset.coinDenom} - )} -
-

- {tab === "sell" - ? t("limitOrders.receive") - : t("portfolio.buy")} -

- - {expectedOutput && ( - <> - {formatPretty(expectedOutput.toDec(), { - minimumSignificantDigits: 6, - maximumSignificantDigits: 6, - maxDecimals: 10, - notation: "standard", - })}{" "} - {toAsset?.coinDenom} - - )} - +
+
+
+ +
-
-

- {outputDifference && ( - {`${ - outputDifference.toDec().isPositive() ? "-" : "+" - }${new RatePretty(outputDifference.toDec().abs())}`} +

+
+ {toAsset && ( + {`${toAsset.coinDenom} )} - - {formatFiatPrice( - expectedOutputFiat ?? - new PricePretty(DEFAULT_VS_CURRENCY, 0) +
+

+ {tab === "sell" + ? t("limitOrders.receive") + : t("portfolio.buy")} +

+ + {expectedOutput && ( + <> + {formatPretty(expectedOutput.toDec(), { + minimumSignificantDigits: 6, + maximumSignificantDigits: 6, + maxDecimals: 10, + notation: "standard", + })}{" "} + {toAsset?.coinDenom} + + )} + +
+
+
+

+ {outputDifference && ( + {`${ + outputDifference.toDec().isPositive() ? "-" : "+" + }${new RatePretty( + outputDifference.toDec().abs() + )}`} )} - -

+ + {formatFiatPrice( + expectedOutputFiat ?? + new PricePretty(DEFAULT_VS_CURRENCY, 0) + )} + +

+
-
-
-
- - {tab === "buy" - ? t("limitOrders.aboveMarket.title") - : t("limitOrders.belowMarket.title")} - - } - body={ - - {tab === "buy" - ? t("limitOrders.aboveMarket.description") - : t("limitOrders.belowMarket.description")} - - } - > -
- {isBeyondOppositePrice && ( - - )} - {orderType === "limit" - ? t("limitOrders.limit") - : t("limitOrders.market")} -
- - } - /> - {slippageConfig && orderType === "market" && ( -
+
+
+ + {tab === "buy" + ? t("limitOrders.aboveMarket.title") + : t("limitOrders.belowMarket.title")} + + } + body={ + + {tab === "buy" + ? t("limitOrders.aboveMarket.description") + : t("limitOrders.belowMarket.description")} + + } + > +
+ {isBeyondOppositePrice && ( + + )} + {orderType === "limit" + ? t("limitOrders.limit") + : t("limitOrders.market")} +
+ + } + /> + + {show1CT && is1CTEnabled && ( -
- setShowOneClickTradingSettings(true)} + remainingSpendLimit={remaining1CTSpendLimit} + wouldExceedSpendLimit={wouldExceedSpendLimit} + /> + } + /> + )} + {slippageConfig && orderType === "market" && ( +
+ +
{ - slippageConfig?.setIsManualSlippage(true); - setIsEditingSlippage(true); - }} - onBlur={() => { - if ( - isManualSlippageTooHigh && - +manualSlippage > 50 - ) { - handleManualSlippageChange( - (+manualSlippage).toString().split("")[0] - ); + > + { - handleManualSlippageChange(e.target.value); - - logEvent([ - EventName.Swap.slippageToleranceSet, + className="sm:caption w-fit bg-transparent px-0" + inputClassName={classNames( + "!bg-transparent focus:text-center text-right placeholder:text-wosmongton-300 transition-all focus-visible:outline-none", { - fromToken: fromAsset?.coinDenom, - toToken: toAsset?.coinDenom, - isOnHome: true, - percentage: - slippageConfig?.slippage.toString(), - page, - }, - ]); - }} + "text-rust-400 placeholder:text-rust-400": + isManualSlippageTooHigh, + } + )} + value={manualSlippage} + onFocus={() => { + slippageConfig?.setIsManualSlippage(true); + setIsEditingSlippage(true); + }} + onBlur={() => { + if ( + isManualSlippageTooHigh && + +manualSlippage > 50 + ) { + handleManualSlippageChange( + (+manualSlippage).toString().split("")[0] + ); + } + setIsEditingSlippage(false); + }} + onChange={(e) => { + handleManualSlippageChange(e.target.value); + + logEvent([ + EventName.Swap.slippageToleranceSet, + { + fromToken: fromAsset?.coinDenom, + toToken: toAsset?.coinDenom, + isOnHome: true, + percentage: + slippageConfig?.slippage.toString(), + page, + }, + ]); + }} + /> + {manualSlippage !== "" && ( + + % + + )} +
+
+ } + /> + {isManualSlippageTooHigh && ( +
+ +
+ + {t( + "limitOrders.errors.tradeMayResultInLossOfValue" + )} + + + {t("limitOrders.lowerSlippageToleranceRecommended")} + +
+
+ )} + {isManualSlippageTooLow && ( +
+ + - {manualSlippage !== "" && ( - - % - - )} + +
+ + {t("limitOrders.errors.tradeMayNotExecuted")} + + + {t("limitOrders.tryHigherSlippage")} +
+ )} +
+ )} + {orderType === "market" && ( +
+ )} + {orderType === "market" ? ( + + {amountWithSlippage && + fiatAmountWithSlippage && + toAsset && ( + + {formatPretty(amountWithSlippage, { + maxDecimals: 6, + })}{" "} + {quoteType === "out-given-in" + ? toAsset.coinDenom + : fromAsset?.coinDenom} + + )}{" "} + {fiatAmountWithSlippage && ( + + (~ + {formatPretty(fiatAmountWithSlippage, { + ...getPriceExtendedFormatOptions( + fiatAmountWithSlippage.toDec() + ), + })} + ) + + )} + } /> - {isManualSlippageTooHigh && ( -
- -
- - {t("limitOrders.errors.tradeMayResultInLossOfValue")} - - - {t("limitOrders.lowerSlippageToleranceRecommended")} - -
-
- )} - {isManualSlippageTooLow && ( -
- - - -
- - {t("limitOrders.errors.tradeMayNotExecuted")} - - - {t("limitOrders.tryHigherSlippage")} - -
-
- )} -
- )} - {orderType === "market" && ( -
- )} - {orderType === "market" ? ( + ) : ( + + {t("transfer.free")} + + } + /> + )} - {amountWithSlippage && - fiatAmountWithSlippage && - toAsset && ( - - {formatPretty(amountWithSlippage, { - maxDecimals: 6, - })}{" "} - {quoteType === "out-given-in" - ? toAsset.coinDenom - : fromAsset?.coinDenom} - - )}{" "} - {fiatAmountWithSlippage && ( - - (~ - {formatPretty(fiatAmountWithSlippage, { - ...getPriceExtendedFormatOptions( - fiatAmountWithSlippage.toDec() - ), - })} - ) - - )} - + // Do not show skeleton unless there has been no estimation/error yet + !!gasAmount || !!gasError ? ( + GasEstimation + ) : ( + + ) } /> - ) : ( - - {t("transfer.free")} +
+ {isBeyondOppositePrice && orderType === "limit" && ( +
+ +
+ + {tab === "buy" + ? t("limitOrders.aboveMarket.title") + : t("limitOrders.belowMarket.title")} + + + {tab === "buy" + ? t("limitOrders.aboveMarket.description") + : t("limitOrders.belowMarket.description")} +
+
+ )} + {show1CT && !is1CTEnabled && ( + + setTransaction1CTParams((prev) => { + if (!prev) return; + + return { + ...prev, + isOneClickEnabled: !prev.isOneClickEnabled, + }; + }) } + onParamsChange={() => setShowOneClickTradingSettings(true)} /> )} - - ) - } - /> + {!diffGteSlippage && ( +
+ +
+ )}
- {isBeyondOppositePrice && orderType === "limit" && ( -
- -
- - {tab === "buy" - ? t("limitOrders.aboveMarket.title") - : t("limitOrders.belowMarket.title")} - - - {tab === "buy" - ? t("limitOrders.aboveMarket.description") - : t("limitOrders.belowMarket.description")} - +
+ {diffGteSlippage && ( +
+
+
+
-
- )} - {!diffGteSlippage && ( -
+ + {t("limitOrders.quoteUpdated")} +
- )} -
+
+ )}
- {diffGteSlippage && ( -
-
-
- -
- - {t("limitOrders.quoteUpdated")} - - + )} + + ); +} + +const OneClickTradingPanel = ({ + t, + transactionParams, + onClick, + onParamsChange, +}: { + t: MultiLanguageT; + transactionParams: OneClickTradingTransactionParams | undefined; + onClick: () => void; + onParamsChange: () => void; +}) => { + return ( + <> +
+
+ 1ct rounded rectangle icon +
+
+

+ {t("oneClickTrading.reviewOrder.enableTitle")} +

+ +

+ {t("oneClickTrading.reviewOrder.enableDescription")} +

+
+ +
+
+ {transactionParams?.isOneClickEnabled && ( +

+ {t("oneClickTrading.reviewOrder.paramsDescription", { + sessionLength: t( + `oneClickTrading.sessionPeriods.${ + transactionParams?.sessionPeriod.end ?? "1hour" + }` + ), + spendLimit: + transactionParams?.spendLimit.toString() ?? + t("oneClickTrading.reviewOrder.defaultSpendLimit"), + })} + {" · "} + + {t("oneClickTrading.reviewOrder.change")} + +

)}
- + ); -} +}; + +const OneClickTradingActiveSessionParamsEdit = ({ + onClick, + changes = [], + transactionParams, + remainingSpendLimit, + wouldExceedSpendLimit, +}: { + remainingSpendLimit?: string; + changes: OneClickTradingParamsChanges; + transactionParams: OneClickTradingTransactionParams | undefined; + onClick: () => void; + wouldExceedSpendLimit?: boolean; +}) => { + const { t } = useTranslation(); + + return wouldExceedSpendLimit ? ( + <> +
+ + {t("oneClickTrading.reviewOrder.edit")} + + + + {t("oneClickTrading.reviewOrder.exceeded")} + + +
+ + ) : ( + + + {changes.includes("spendLimit") ? ( + + {transactionParams?.spendLimit.toString()} + + ) : ( + {remainingSpendLimit} + )} + {" / "} + {changes.includes("sessionPeriod") ? ( + + {transactionParams?.sessionPeriod.end} + + ) : ( + + )} + + + ); +}; diff --git a/packages/web/public/images/1ct-rounded-rectangle.svg b/packages/web/public/images/1ct-rounded-rectangle.svg new file mode 100644 index 0000000000..475eb0481d --- /dev/null +++ b/packages/web/public/images/1ct-rounded-rectangle.svg @@ -0,0 +1,5 @@ + + + + diff --git a/packages/web/utils/date.ts b/packages/web/utils/date.ts index 92e3542b0d..c0885f39ac 100644 --- a/packages/web/utils/date.ts +++ b/packages/web/utils/date.ts @@ -1,6 +1,9 @@ import dayjs from "dayjs"; -export function humanizeTime(date: dayjs.Dayjs): { +export function humanizeTime( + date: dayjs.Dayjs, + useShortTimeUnits = false +): { value: number | string; unitTranslationKey: string; } { @@ -10,7 +13,13 @@ export function humanizeTime(date: dayjs.Dayjs): { return { value: Math.max(secondsDiff, 0), unitTranslationKey: - secondsDiff === 1 ? "timeUnits.second" : "timeUnits.seconds", + secondsDiff === 1 + ? useShortTimeUnits + ? "timeUnitsShort.second" + : "timeUnits.second" + : useShortTimeUnits + ? "timeUnitsShort.seconds" + : "timeUnits.seconds", }; } @@ -19,7 +28,13 @@ export function humanizeTime(date: dayjs.Dayjs): { return { value: minutesDiff, unitTranslationKey: - minutesDiff === 1 ? "timeUnits.minute" : "timeUnits.minutes", + minutesDiff === 1 + ? useShortTimeUnits + ? "timeUnitsShort.minute" + : "timeUnits.minute" + : useShortTimeUnits + ? "timeUnitsShort.minutes" + : "timeUnits.minutes", }; } @@ -28,7 +43,13 @@ export function humanizeTime(date: dayjs.Dayjs): { return { value: hoursDiff, unitTranslationKey: - hoursDiff === 1 ? "timeUnits.hour" : "timeUnits.hours", + hoursDiff === 1 + ? useShortTimeUnits + ? "timeUnitsShort.hour" + : "timeUnits.hour" + : useShortTimeUnits + ? "timeUnitsShort.hours" + : "timeUnits.hours", }; } @@ -36,7 +57,14 @@ export function humanizeTime(date: dayjs.Dayjs): { if (daysDiff < 30) { return { value: daysDiff, - unitTranslationKey: daysDiff === 1 ? "timeUnits.day" : "timeUnits.days", + unitTranslationKey: + daysDiff === 1 + ? useShortTimeUnits + ? "timeUnitsShort.day" + : "timeUnits.day" + : useShortTimeUnits + ? "timeUnitsShort.days" + : "timeUnits.days", }; } diff --git a/packages/web/utils/trpc.ts b/packages/web/utils/trpc.ts index 0262453604..77b1146a0e 100644 --- a/packages/web/utils/trpc.ts +++ b/packages/web/utils/trpc.ts @@ -121,8 +121,9 @@ export const api = createTRPCNext({ links: [ loggerLink({ enabled: (opts) => - process.env.NODE_ENV === "development" || - (opts.direction === "down" && opts.result instanceof Error), + process.env.NEXT_PUBLIC_TRPC_LOGS !== "off" && + (process.env.NODE_ENV === "development" || + (opts.direction === "down" && opts.result instanceof Error)), }), /** * Split calls to the node server and the edge server.